diff --git a/js/src/admin/components/AdminPage.js b/js/src/admin/components/AdminPage.js deleted file mode 100644 index aca2b44c9..000000000 --- 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 000000000..2f4dc859f --- /dev/null +++ b/js/src/admin/components/AdminPage.tsx @@ -0,0 +1,275 @@ +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'; +import type Mithril from 'mithril'; + +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; +} + +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: string | ReturnType; + help?: string | ReturnType; + className?: string; +} + +interface HTMLInputSettingsComponentOptions extends CommonSettingsItemOptions { + /** + * Any valid HTML input `type` value. + */ + type: HTMLInputTypes; +} + +interface SwitchSettingComponentOptions extends CommonSettingsItemOptions { + type: 'bool' | 'checkbox' | 'switch' | 'boolean'; +} + +interface SelectSettingComponentOptions extends CommonSettingsItemOptions { + type: 'select' | 'dropdown' | 'selectdropdown'; + /** + * Map of values to their labels + */ + options: { [value: string]: string | ReturnType }; + default: string; +} + +export type SettingsComponentOptions = HTMLInputSettingsComponentOptions | SwitchSettingComponentOptions | SelectSettingComponentOptions; + +export type AdminHeaderAttrs = AdminHeaderOptions & Partial>; + +export default class AdminPage extends Page { + settings: Record string> = {}; + loading: boolean = false; + + oninit(vnode: Mithril.Vnode, this>) { + super.oninit(vnode); + } + + view(vnode: Mithril.Vnode, this>) { + const className = classList('AdminPage', this.headerInfo().className); + + return ( +
+ {this.header(vnode)} +
{this.content(vnode)}
+
+ ); + } + + /** + * Returns the content of the AdminPage. + */ + content(vnode: Mithril.Vnode, this>): Mithril.Children { + return ''; + } + + /** + * Returns the submit button for this AdminPage. + * + * Calls `this.saveSettings` when the button is clicked. + */ + submitButton(vnode: Mithril.Vnode, this>): Mithril.Children { + return ( + + ); + } + + /** + * Returns the Header component for this AdminPage. + */ + header(vnode: Mithril.Vnode, this>): 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)(); + + if (['bool', 'checkbox', 'switch', 'boolean'].includes(type)) { + return ( +
+ + {label} + +
{help}
+
+ ); + } else if (['select', 'dropdown', 'selectdropdown'].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 = ''): () => string { + this.settings[key] = this.settings[key] || Stream(app.data.settings[key] || fallback); + + return this.settings[key]; + } + + /** + * Returns a list of settings that 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)); + } +}