Rewrite AdminPage abstract component into Typescript (#2996)

* Rewrite AdminPage.js into Typescript

* Export more interfaces and types

* Use Stream type

* Update js/src/admin/components/AdminPage.tsx

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Move `HTMLInputTypes` type to global declarations

* Add missing app import

* Export options interface

* Remove unused method

* Add random element ID generator

* Add attrs for Page component

Full rewrite needed later

* Provide correct attrs

* Add missing a11y attributes for help text and labels

* Update TSDoc comment

* Allow Children to be passed for label/help text

* Extract setting types to arrays

* Make Page class abstract; fix incorrect Component generic call

* Mark AdminPage as abstract

* Mark `content` as abstract

* Revert "Move `HTMLInputTypes` type to global declarations"

This reverts commit c900cb3f6d.

* Restore TSDoc on HTMLInputTypes type

* Fix typo

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>
This commit is contained in:
David Wheatley 2021-08-23 01:59:50 +01:00 committed by GitHub
parent 8ee783a06b
commit 4ceba63d27
7 changed files with 344 additions and 179 deletions

View File

@ -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",

View File

@ -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"

View File

@ -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,

View File

@ -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 (
<div className={className}>
{this.header()}
<div className="container">{this.content()}</div>
</div>
);
}
content() {
return '';
}
submitButton() {
return (
<Button onclick={this.saveSettings.bind(this)} className="Button Button--primary" loading={this.loading} disabled={!this.isChanged()}>
{app.translator.trans('core.admin.settings.submit_button')}
</Button>
);
}
header() {
const headerInfo = this.headerInfo();
return (
<AdminHeader icon={headerInfo.icon} description={headerInfo.description} className={headerInfo.className + '-header'}>
{headerInfo.title}
</AdminHeader>
);
}
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 <input> 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 (
<div className="Form-group">
<Switch state={!!value && value !== '0'} onchange={this.settings[setting]} {...componentAttrs}>
{label}
</Switch>
<div className="helpText">{help}</div>
</div>
);
} else if (['select', 'dropdown', 'selectdropdown'].includes(type)) {
const { default: defaultValue, options } = componentAttrs;
return (
<div className="Form-group">
<label>{label}</label>
<div className="helpText">{help}</div>
<Select value={value || defaultValue} options={options} onchange={this.settings[setting]} {...componentAttrs} />
</div>
);
} else {
componentAttrs.className = classList(['FormControl', componentAttrs.className]);
return (
<div className="Form-group">
{label ? <label>{label}</label> : ''}
<div className="helpText">{help}</div>
<input type={type} bidi={this.setting(setting)} {...componentAttrs} />
</div>
);
}
}
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));
}
}

View File

@ -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 `<input>` 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<Omit<Mithril.Attributes, 'class'>>;
export default abstract class AdminPage<CustomAttrs extends IPageAttrs = IPageAttrs> extends Page<CustomAttrs> {
settings!: Record<string, Stream<string>>;
loading: boolean = false;
view(vnode: Mithril.Vnode<CustomAttrs, this>): Mithril.Children {
const className = classList('AdminPage', this.headerInfo().className);
return (
<div className={className}>
{this.header(vnode)}
<div className="container">{this.content(vnode)}</div>
</div>
);
}
/**
* Returns the content of the AdminPage.
*/
abstract content(vnode: Mithril.Vnode<CustomAttrs, this>): Mithril.Children;
/**
* Returns the submit button for this AdminPage.
*
* Calls `this.saveSettings` when the button is clicked.
*/
submitButton(vnode: Mithril.Vnode<CustomAttrs, this>): Mithril.Children {
return (
<Button onclick={this.saveSettings.bind(this)} className="Button Button--primary" loading={this.loading} disabled={!this.isChanged()}>
{app.translator.trans('core.admin.settings.submit_button')}
</Button>
);
}
/**
* Returns the Header component for this AdminPage.
*/
header(vnode: Mithril.Vnode<CustomAttrs, this>): Mithril.Children {
const { title, className, ...headerAttrs } = this.headerInfo();
return (
<AdminHeader className={className ? `${className}-header` : undefined} {...headerAttrs}>
{title}
</AdminHeader>
);
}
/**
* 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 <input> 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 <p>My cool component</p>;
* }
*/
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).
<div className="Form-group">
<Switch state={!!value && value !== '0'} onchange={this.settings[setting]} {...componentAttrs}>
{label}
</Switch>
<div className="helpText">{help}</div>
</div>
);
} else if ((SelectSettingTypes as readonly string[]).includes(type)) {
const { default: defaultValue, options, ...otherAttrs } = componentAttrs;
return (
<div className="Form-group">
<label for={inputId}>{label}</label>
<div className="helpText" id={helpTextId}>
{help}
</div>
<Select
id={inputId}
aria-describedby={helpTextId}
value={value || defaultValue}
options={options}
onchange={this.settings[setting]}
{...otherAttrs}
/>
</div>
);
} else {
componentAttrs.className = classList(['FormControl', componentAttrs.className]);
return (
<div className="Form-group">
{label && <label for={inputId}>{label}</label>}
<div id={helpTextId} className="helpText">
{help}
</div>
<input id={inputId} aria-describedby={helpTextId} type={type} bidi={this.setting(setting)} {...componentAttrs} />
</div>
);
}
}
/**
* 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<string> {
this.settings[key] = this.settings[key] || Stream<string>(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<string, string> {
const dirty: Record<string, string> = {};
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));
}
}

View File

@ -0,0 +1 @@
export { nanoid as default } from 'nanoid';

View File

@ -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<CustomAttrs extends IPageAttrs = IPageAttrs> extends Component<CustomAttrs> {
oninit(vnode) {
super.oninit(vnode);