diff --git a/js/package-lock.json b/js/package-lock.json
index 2b2d45a9d6..efe81d05ed 100644
--- a/js/package-lock.json
+++ b/js/package-lock.json
@@ -16,6 +16,7 @@
"jquery": "^3.6.0",
"jquery.hotkeys": "^0.1.0",
"mithril": "^2.0.4",
+ "nanoid": "^3.1.25",
"punycode": "^2.1.1",
"textarea-caret": "^3.1.0",
"throttle-debounce": "^3.0.1"
@@ -4649,6 +4650,17 @@
"integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==",
"optional": true
},
+ "node_modules/nanoid": {
+ "version": "3.1.25",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.25.tgz",
+ "integrity": "sha512-rdwtIXaXCLFAQbnfqDRnI6jaRHp9fTcYBjtFKE8eezcZ7LuLjhUaQGNeMXf1HmRoCH32CLz6XwX0TtxEOS/A3Q==",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
"node_modules/nanomatch": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
@@ -11081,6 +11093,11 @@
"integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==",
"optional": true
},
+ "nanoid": {
+ "version": "3.1.25",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.25.tgz",
+ "integrity": "sha512-rdwtIXaXCLFAQbnfqDRnI6jaRHp9fTcYBjtFKE8eezcZ7LuLjhUaQGNeMXf1HmRoCH32CLz6XwX0TtxEOS/A3Q=="
+ },
"nanomatch": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
diff --git a/js/package.json b/js/package.json
index 6ebed0b493..a43d17131e 100644
--- a/js/package.json
+++ b/js/package.json
@@ -13,6 +13,7 @@
"jquery": "^3.6.0",
"jquery.hotkeys": "^0.1.0",
"mithril": "^2.0.4",
+ "nanoid": "^3.1.25",
"punycode": "^2.1.1",
"textarea-caret": "^3.1.0",
"throttle-debounce": "^3.0.1"
diff --git a/js/src/admin/compat.js b/js/src/admin/compat.js
index 96b13bc5a5..9ff8d9b58b 100644
--- a/js/src/admin/compat.js
+++ b/js/src/admin/compat.js
@@ -34,12 +34,14 @@ import EditCustomCssModal from './components/EditCustomCssModal';
import EditGroupModal from './components/EditGroupModal';
import routes from './routes';
import AdminApplication from './AdminApplication';
+import generateElementId from './utils/generateElementId';
export default Object.assign(compat, {
'utils/saveSettings': saveSettings,
'utils/ExtensionData': ExtensionData,
'utils/isExtensionEnabled': isExtensionEnabled,
'utils/getCategorizedExtensions': getCategorizedExtensions,
+ 'utils/generateElementId': generateElementId,
'components/SettingDropdown': SettingDropdown,
'components/EditCustomFooterModal': EditCustomFooterModal,
'components/SessionDropdown': SessionDropdown,
diff --git a/js/src/admin/components/AdminPage.js b/js/src/admin/components/AdminPage.js
deleted file mode 100644
index aca2b44c98..0000000000
--- a/js/src/admin/components/AdminPage.js
+++ /dev/null
@@ -1,177 +0,0 @@
-import app from '../../admin/app';
-import Page from '../../common/components/Page';
-import Button from '../../common/components/Button';
-import Switch from '../../common/components/Switch';
-import Select from '../../common/components/Select';
-import classList from '../../common/utils/classList';
-import Stream from '../../common/utils/Stream';
-import saveSettings from '../utils/saveSettings';
-import AdminHeader from './AdminHeader';
-
-export default class AdminPage extends Page {
- oninit(vnode) {
- super.oninit(vnode);
-
- this.settings = {};
-
- this.loading = false;
- }
-
- view() {
- const className = classList(['AdminPage', this.headerInfo().className]);
-
- return (
-
- {this.header()}
-
{this.content()}
-
- );
- }
-
- content() {
- return '';
- }
-
- submitButton() {
- return (
-
- );
- }
-
- header() {
- const headerInfo = this.headerInfo();
-
- return (
-
- {headerInfo.title}
-
- );
- }
-
- headerInfo() {
- return {
- className: '',
- icon: '',
- title: '',
- description: '',
- };
- }
-
- /**
- * buildSettingComponent takes a settings object and turns it into a component.
- * Depending on the type of input, you can set the type to 'bool', 'select', or
- * any standard type. Any values inside the 'extra' object will be added
- * to the component as an attribute.
- *
- * Alternatively, you can pass a callback that will be executed in ExtensionPage's
- * context to include custom JSX elements.
- *
- * @example
- *
- * {
- * setting: 'acme.checkbox',
- * label: app.translator.trans('acme.admin.setting_label'),
- * type: 'bool',
- * help: app.translator.trans('acme.admin.setting_help'),
- * className: 'Setting-item'
- * }
- *
- * @example
- *
- * {
- * setting: 'acme.select',
- * label: app.translator.trans('acme.admin.setting_label'),
- * type: 'select',
- * options: {
- * 'option1': 'Option 1 label',
- * 'option2': 'Option 2 label',
- * },
- * default: 'option1',
- * }
- *
- * @param setting
- * @returns {JSX.Element}
- */
- buildSettingComponent(entry) {
- if (typeof entry === 'function') {
- return entry.call(this);
- }
-
- const { setting, help, type, label, ...componentAttrs } = entry;
-
- const value = this.setting(setting)();
-
- if (['bool', 'checkbox', 'switch', 'boolean'].includes(type)) {
- return (
-
- );
- } else if (['select', 'dropdown', 'selectdropdown'].includes(type)) {
- const { default: defaultValue, options } = componentAttrs;
-
- return (
-
- );
- } else {
- componentAttrs.className = classList(['FormControl', componentAttrs.className]);
-
- return (
-
- {label ?
: ''}
-
{help}
-
-
- );
- }
- }
-
- onsaved() {
- this.loading = false;
-
- app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.settings.saved_message'));
- }
-
- setting(key, fallback = '') {
- this.settings[key] = this.settings[key] || Stream(app.data.settings[key] || fallback);
-
- return this.settings[key];
- }
-
- dirty() {
- const dirty = {};
-
- Object.keys(this.settings).forEach((key) => {
- const value = this.settings[key]();
-
- if (value !== app.data.settings[key]) {
- dirty[key] = value;
- }
- });
-
- return dirty;
- }
-
- isChanged() {
- return Object.keys(this.dirty()).length;
- }
-
- saveSettings(e) {
- e.preventDefault();
-
- app.alerts.clear();
-
- this.loading = true;
-
- return saveSettings(this.dirty()).then(this.onsaved.bind(this));
- }
-}
diff --git a/js/src/admin/components/AdminPage.tsx b/js/src/admin/components/AdminPage.tsx
new file mode 100644
index 0000000000..b83d23a7b2
--- /dev/null
+++ b/js/src/admin/components/AdminPage.tsx
@@ -0,0 +1,316 @@
+import type Mithril from 'mithril';
+
+import app from '../app';
+import Page, { IPageAttrs } from '../../common/components/Page';
+import Button from '../../common/components/Button';
+import Switch from '../../common/components/Switch';
+import Select from '../../common/components/Select';
+import classList from '../../common/utils/classList';
+import Stream from '../../common/utils/Stream';
+import saveSettings from '../utils/saveSettings';
+import AdminHeader from './AdminHeader';
+import generateElementId from '../utils/generateElementId';
+
+export interface AdminHeaderOptions {
+ title: string;
+ description: string;
+ icon: string;
+ /**
+ * Will be used as the class for the AdminPage.
+ *
+ * Will also be appended with `-header` and set as the class for the `AdminHeader` component.
+ */
+ className: string;
+}
+
+/**
+ * A type that matches any valid value for the `type` attribute on an HTML `` element.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-type
+ *
+ * Note: this will be exported from a different location in the future.
+ *
+ * @see https://github.com/flarum/core/issues/3039
+ */
+export type HTMLInputTypes =
+ | 'button'
+ | 'checkbox'
+ | 'color'
+ | 'date'
+ | 'datetime-local'
+ | 'email'
+ | 'file'
+ | 'hidden'
+ | 'image'
+ | 'month'
+ | 'number'
+ | 'password'
+ | 'radio'
+ | 'range'
+ | 'reset'
+ | 'search'
+ | 'submit'
+ | 'tel'
+ | 'text'
+ | 'time'
+ | 'url'
+ | 'week';
+
+interface CommonSettingsItemOptions extends Mithril.Attributes {
+ setting: string;
+ label: Mithril.Children;
+ help?: Mithril.Children;
+ className?: string;
+}
+
+/**
+ * Valid options for the setting component builder to generate an HTML input element.
+ */
+export interface HTMLInputSettingsComponentOptions extends CommonSettingsItemOptions {
+ /**
+ * Any valid HTML input `type` value.
+ */
+ type: HTMLInputTypes;
+}
+
+const BooleanSettingTypes = ['bool', 'checkbox', 'switch', 'boolean'] as const;
+const SelectSettingTypes = ['select', 'dropdown', 'selectdropdown'] as const;
+
+/**
+ * Valid options for the setting component builder to generate a Switch.
+ */
+export interface SwitchSettingComponentOptions extends CommonSettingsItemOptions {
+ type: typeof BooleanSettingTypes[number];
+}
+
+/**
+ * Valid options for the setting component builder to generate a Select dropdown.
+ */
+export interface SelectSettingComponentOptions extends CommonSettingsItemOptions {
+ type: typeof SelectSettingTypes[number];
+ /**
+ * Map of values to their labels
+ */
+ options: { [value: string]: Mithril.Children };
+ default: string;
+}
+
+/**
+ * All valid options for the setting component builder.
+ */
+export type SettingsComponentOptions = HTMLInputSettingsComponentOptions | SwitchSettingComponentOptions | SelectSettingComponentOptions;
+
+/**
+ * Valid attrs that can be returned by the `headerInfo` function
+ */
+export type AdminHeaderAttrs = AdminHeaderOptions & Partial>;
+
+export default abstract class AdminPage extends Page {
+ settings!: Record>;
+ loading: boolean = false;
+
+ view(vnode: Mithril.Vnode): Mithril.Children {
+ const className = classList('AdminPage', this.headerInfo().className);
+
+ return (
+
+ {this.header(vnode)}
+
{this.content(vnode)}
+
+ );
+ }
+
+ /**
+ * Returns the content of the AdminPage.
+ */
+ abstract content(vnode: Mithril.Vnode): Mithril.Children;
+
+ /**
+ * Returns the submit button for this AdminPage.
+ *
+ * Calls `this.saveSettings` when the button is clicked.
+ */
+ submitButton(vnode: Mithril.Vnode): Mithril.Children {
+ return (
+
+ );
+ }
+
+ /**
+ * Returns the Header component for this AdminPage.
+ */
+ header(vnode: Mithril.Vnode): Mithril.Children {
+ const { title, className, ...headerAttrs } = this.headerInfo();
+
+ return (
+
+ {title}
+
+ );
+ }
+
+ /**
+ * Returns the options passed to the AdminHeader component.
+ */
+ headerInfo(): AdminHeaderAttrs {
+ return {
+ className: '',
+ icon: '',
+ title: '',
+ description: '',
+ };
+ }
+
+ /**
+ * `buildSettingComponent` takes a settings object and turns it into a component.
+ * Depending on the type of input, you can set the type to 'bool', 'select', or
+ * any standard type. Any values inside the 'extra' object will be added
+ * to the component as an attribute.
+ *
+ * Alternatively, you can pass a callback that will be executed in ExtensionPage's
+ * context to include custom JSX elements.
+ *
+ * @example
+ *
+ * {
+ * setting: 'acme.checkbox',
+ * label: app.translator.trans('acme.admin.setting_label'),
+ * type: 'bool',
+ * help: app.translator.trans('acme.admin.setting_help'),
+ * className: 'Setting-item'
+ * }
+ *
+ * @example
+ *
+ * {
+ * setting: 'acme.select',
+ * label: app.translator.trans('acme.admin.setting_label'),
+ * type: 'select',
+ * options: {
+ * 'option1': 'Option 1 label',
+ * 'option2': 'Option 2 label',
+ * },
+ * default: 'option1',
+ * }
+ *
+ * @example
+ *
+ * () => {
+ * return My cool component
;
+ * }
+ */
+ buildSettingComponent(entry: ((this: typeof this) => Mithril.Children) | SettingsComponentOptions): Mithril.Children {
+ if (typeof entry === 'function') {
+ return entry.call(this);
+ }
+
+ const { setting, help, type, label, ...componentAttrs } = entry;
+
+ const value = this.setting(setting)();
+
+ const [inputId, helpTextId] = [generateElementId(), generateElementId()];
+
+ // Typescript being Typescript
+ // https://github.com/microsoft/TypeScript/issues/14520
+ if ((BooleanSettingTypes as readonly string[]).includes(type)) {
+ return (
+ // TODO: Add aria-describedby for switch help text.
+ //? Requires changes to Checkbox component to allow providing attrs directly for the element(s).
+
+ );
+ } else if ((SelectSettingTypes as readonly string[]).includes(type)) {
+ const { default: defaultValue, options, ...otherAttrs } = componentAttrs;
+
+ return (
+
+
+
+ {help}
+
+
+
+ );
+ } else {
+ componentAttrs.className = classList(['FormControl', componentAttrs.className]);
+
+ return (
+
+ {label &&
}
+
+ {help}
+
+
+
+ );
+ }
+ }
+
+ /**
+ * Called when `saveSettings` completes successfully.
+ */
+ onsaved(): void {
+ this.loading = false;
+
+ app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.settings.saved_message'));
+ }
+
+ /**
+ * Returns a function that fetches the setting from the `app` global.
+ */
+ setting(key: string, fallback: string = ''): Stream {
+ this.settings[key] = this.settings[key] || Stream(app.data.settings[key] || fallback);
+
+ return this.settings[key];
+ }
+
+ /**
+ * Returns a map of settings keys to values which includes only those which have been modified but not yet saved.
+ */
+ dirty(): Record {
+ const dirty: Record = {};
+
+ Object.keys(this.settings).forEach((key) => {
+ const value = this.settings[key]();
+
+ if (value !== app.data.settings[key]) {
+ dirty[key] = value;
+ }
+ });
+
+ return dirty;
+ }
+
+ /**
+ * Returns the number of settings that have been modified.
+ */
+ isChanged(): number {
+ return Object.keys(this.dirty()).length;
+ }
+
+ /**
+ * Saves the modified settings to the database.
+ */
+ saveSettings(e: SubmitEvent & { redraw: boolean }) {
+ e.preventDefault();
+
+ app.alerts.clear();
+
+ this.loading = true;
+
+ return saveSettings(this.dirty()).then(this.onsaved.bind(this));
+ }
+}
diff --git a/js/src/admin/utils/generateElementId.ts b/js/src/admin/utils/generateElementId.ts
new file mode 100644
index 0000000000..f7c758f3bc
--- /dev/null
+++ b/js/src/admin/utils/generateElementId.ts
@@ -0,0 +1 @@
+export { nanoid as default } from 'nanoid';
diff --git a/js/src/common/components/Page.js b/js/src/common/components/Page.tsx
similarity index 85%
rename from js/src/common/components/Page.js
rename to js/src/common/components/Page.tsx
index 531ffa3171..5cb79c067f 100644
--- a/js/src/common/components/Page.js
+++ b/js/src/common/components/Page.tsx
@@ -1,13 +1,18 @@
-import app from '../../common/app';
+import app from '../app';
import Component from '../Component';
import PageState from '../states/PageState';
+export interface IPageAttrs {
+ key?: number;
+ routeName: string;
+}
+
/**
* The `Page` component
*
* @abstract
*/
-export default class Page extends Component {
+export default abstract class Page extends Component {
oninit(vnode) {
super.oninit(vnode);