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 ( -
- - {label} - -
{help}
-
- ); - } else if (['select', 'dropdown', 'selectdropdown'].includes(type)) { - const { default: defaultValue, options } = componentAttrs; - - return ( -
- -
{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). +
+ + {label} + +
{help}
+
+ ); + } else if ((SelectSettingTypes as readonly string[]).includes(type)) { + const { default: defaultValue, options, ...otherAttrs } = componentAttrs; + + return ( +
+ +
+ {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);