mirror of
https://github.com/flarum/core.git
synced 2025-07-18 07:11:17 +02: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:
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": "^3.6.0",
|
||||||
"jquery.hotkeys": "^0.1.0",
|
"jquery.hotkeys": "^0.1.0",
|
||||||
"mithril": "^2.0.4",
|
"mithril": "^2.0.4",
|
||||||
|
"nanoid": "^3.1.25",
|
||||||
"punycode": "^2.1.1",
|
"punycode": "^2.1.1",
|
||||||
"textarea-caret": "^3.1.0",
|
"textarea-caret": "^3.1.0",
|
||||||
"throttle-debounce": "^3.0.1"
|
"throttle-debounce": "^3.0.1"
|
||||||
@@ -4649,6 +4650,17 @@
|
|||||||
"integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==",
|
"integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==",
|
||||||
"optional": true
|
"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": {
|
"node_modules/nanomatch": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
|
||||||
@@ -11081,6 +11093,11 @@
|
|||||||
"integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==",
|
"integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"nanoid": {
|
||||||
|
"version": "3.1.25",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.25.tgz",
|
||||||
|
"integrity": "sha512-rdwtIXaXCLFAQbnfqDRnI6jaRHp9fTcYBjtFKE8eezcZ7LuLjhUaQGNeMXf1HmRoCH32CLz6XwX0TtxEOS/A3Q=="
|
||||||
|
},
|
||||||
"nanomatch": {
|
"nanomatch": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
|
||||||
|
@@ -13,6 +13,7 @@
|
|||||||
"jquery": "^3.6.0",
|
"jquery": "^3.6.0",
|
||||||
"jquery.hotkeys": "^0.1.0",
|
"jquery.hotkeys": "^0.1.0",
|
||||||
"mithril": "^2.0.4",
|
"mithril": "^2.0.4",
|
||||||
|
"nanoid": "^3.1.25",
|
||||||
"punycode": "^2.1.1",
|
"punycode": "^2.1.1",
|
||||||
"textarea-caret": "^3.1.0",
|
"textarea-caret": "^3.1.0",
|
||||||
"throttle-debounce": "^3.0.1"
|
"throttle-debounce": "^3.0.1"
|
||||||
|
@@ -34,12 +34,14 @@ import EditCustomCssModal from './components/EditCustomCssModal';
|
|||||||
import EditGroupModal from './components/EditGroupModal';
|
import EditGroupModal from './components/EditGroupModal';
|
||||||
import routes from './routes';
|
import routes from './routes';
|
||||||
import AdminApplication from './AdminApplication';
|
import AdminApplication from './AdminApplication';
|
||||||
|
import generateElementId from './utils/generateElementId';
|
||||||
|
|
||||||
export default Object.assign(compat, {
|
export default Object.assign(compat, {
|
||||||
'utils/saveSettings': saveSettings,
|
'utils/saveSettings': saveSettings,
|
||||||
'utils/ExtensionData': ExtensionData,
|
'utils/ExtensionData': ExtensionData,
|
||||||
'utils/isExtensionEnabled': isExtensionEnabled,
|
'utils/isExtensionEnabled': isExtensionEnabled,
|
||||||
'utils/getCategorizedExtensions': getCategorizedExtensions,
|
'utils/getCategorizedExtensions': getCategorizedExtensions,
|
||||||
|
'utils/generateElementId': generateElementId,
|
||||||
'components/SettingDropdown': SettingDropdown,
|
'components/SettingDropdown': SettingDropdown,
|
||||||
'components/EditCustomFooterModal': EditCustomFooterModal,
|
'components/EditCustomFooterModal': EditCustomFooterModal,
|
||||||
'components/SessionDropdown': SessionDropdown,
|
'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 Component from '../Component';
|
||||||
import PageState from '../states/PageState';
|
import PageState from '../states/PageState';
|
||||||
|
|
||||||
|
export interface IPageAttrs {
|
||||||
|
key?: number;
|
||||||
|
routeName: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `Page` component
|
* The `Page` component
|
||||||
*
|
*
|
||||||
* @abstract
|
* @abstract
|
||||||
*/
|
*/
|
||||||
export default class Page extends Component {
|
export default abstract class Page<CustomAttrs extends IPageAttrs = IPageAttrs> extends Component<CustomAttrs> {
|
||||||
oninit(vnode) {
|
oninit(vnode) {
|
||||||
super.oninit(vnode);
|
super.oninit(vnode);
|
||||||
|
|
Reference in New Issue
Block a user