mirror of
https://github.com/flarum/framework.git
synced 2024-11-29 04:33:47 +08:00
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:
parent
8ee783a06b
commit
4ceba63d27
17
framework/core/js/package-lock.json
generated
17
framework/core/js/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
316
framework/core/js/src/admin/components/AdminPage.tsx
Normal file
316
framework/core/js/src/admin/components/AdminPage.tsx
Normal 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));
|
||||
}
|
||||
}
|
1
framework/core/js/src/admin/utils/generateElementId.ts
Normal file
1
framework/core/js/src/admin/utils/generateElementId.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { nanoid as default } from 'nanoid';
|
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user