From 7db2d0f697bb12f288495d9b05078a2daaac7252 Mon Sep 17 00:00:00 2001 From: David Wheatley Date: Sun, 31 Oct 2021 00:29:10 +0200 Subject: [PATCH] chore: rewrite ModalManager and state to Typescript (#3007) * Rewrite ModalManagerState into Typescript - Fixes `attrs` parameter being marked as required - Add `isModalOpen` method * Rewrite ModalManager into Typescript * Fix incorrect type * Continue modal rewrite * Update attr typings * Fix correctly cast `this.constructor` calls * Cast to bool * Don't extend ModalAttrs by Record * Prevent missing abstract methods in child Modals from bricking frontend * Add missing `app` import * Address review comment Co-authored-by: David Sevilla Martin <6401250+datitisev@users.noreply.github.com> Co-authored-by: David Sevilla Martin <6401250+datitisev@users.noreply.github.com> --- js/src/common/Component.ts | 4 +- js/src/common/components/Modal.js | 149 --------------- js/src/common/components/Modal.tsx | 171 ++++++++++++++++++ .../{ModalManager.js => ModalManager.tsx} | 34 ++-- js/src/common/helpers/fireDebugWarning.ts | 2 + js/src/common/states/ModalManagerState.js | 46 ----- js/src/common/states/ModalManagerState.ts | 71 ++++++++ js/src/common/utils/RequestError.ts | 10 +- 8 files changed, 273 insertions(+), 214 deletions(-) delete mode 100644 js/src/common/components/Modal.js create mode 100644 js/src/common/components/Modal.tsx rename js/src/common/components/{ModalManager.js => ModalManager.tsx} (61%) delete mode 100644 js/src/common/states/ModalManagerState.js create mode 100644 js/src/common/states/ModalManagerState.ts diff --git a/js/src/common/Component.ts b/js/src/common/Component.ts index 09b91b0197..3659eae665 100644 --- a/js/src/common/Component.ts +++ b/js/src/common/Component.ts @@ -138,13 +138,13 @@ export default abstract class Component this.onready()); - } - - onbeforeremove(vnode) { - super.onbeforeremove(vnode); - - // If the global modal state currently contains a modal, - // we've just opened up a new one, and accordingly, - // we don't need to show a hide animation. - if (!this.attrs.state.modal) { - this.attrs.animateHide(); - // Here, we ensure that the animation has time to complete. - // See https://mithril.js.org/lifecycle-methods.html#onbeforeremove - // Bootstrap's Modal.TRANSITION_DURATION is 300 ms. - return new Promise((resolve) => setTimeout(resolve, 300)); - } - } - - view() { - if (this.alertAttrs) { - this.alertAttrs.dismissible = false; - } - - return ( -
-
- {this.constructor.isDismissible ? ( -
- {Button.component({ - icon: 'fas fa-times', - onclick: this.hide.bind(this), - className: 'Button Button--icon Button--link', - })} -
- ) : ( - '' - )} - -
-
-

{this.title()}

-
- - {this.alertAttrs ?
{Alert.component(this.alertAttrs)}
: ''} - - {this.content()} -
-
-
- ); - } - - /** - * Get the class name to apply to the modal. - * - * @return {String} - * @abstract - */ - className() {} - - /** - * Get the title of the modal dialog. - * - * @return {String} - * @abstract - */ - title() {} - - /** - * Get the content of the modal. - * - * @return {VirtualElement} - * @abstract - */ - content() {} - - /** - * Handle the modal form's submit event. - * - * @param {Event} e - */ - onsubmit() {} - - /** - * Focus on the first input when the modal is ready to be used. - */ - onready() { - this.$('form').find('input, select, textarea').first().focus().select(); - } - - /** - * Hide the modal. - */ - hide() { - this.attrs.state.close(); - } - - /** - * Stop loading. - */ - loaded() { - this.loading = false; - m.redraw(); - } - - /** - * Show an alert describing an error returned from the API, and give focus to - * the first relevant field. - * - * @param {RequestError} error - */ - onerror(error) { - this.alertAttrs = error.alert; - - m.redraw(); - - if (error.status === 422 && error.response.errors) { - this.$('form [name=' + error.response.errors[0].source.pointer.replace('/data/attributes/', '') + ']').select(); - } else { - this.onready(); - } - } -} diff --git a/js/src/common/components/Modal.tsx b/js/src/common/components/Modal.tsx new file mode 100644 index 0000000000..8391b24cf4 --- /dev/null +++ b/js/src/common/components/Modal.tsx @@ -0,0 +1,171 @@ +import Component from '../Component'; +import Alert, { AlertAttrs } from './Alert'; +import Button from './Button'; + +import type Mithril from 'mithril'; +import type ModalManagerState from '../states/ModalManagerState'; +import type RequestError from '../utils/RequestError'; +import type ModalManager from './ModalManager'; +import fireDebugWarning from '../helpers/fireDebugWarning'; + +interface IInternalModalAttrs { + state: ModalManagerState; + animateShow: ModalManager['animateShow']; + animateHide: ModalManager['animateHide']; +} + +/** + * The `Modal` component displays a modal dialog, wrapped in a form. Subclasses + * should implement the `className`, `title`, and `content` methods. + */ +export default abstract class Modal extends Component { + /** + * Determine whether or not the modal should be dismissible via an 'x' button. + */ + static readonly isDismissible = true; + + protected loading: boolean = false; + + /** + * Attributes for an alert component to show below the header. + */ + alertAttrs!: AlertAttrs; + + oninit(vnode: Mithril.VnodeDOM) { + super.oninit(vnode); + + // TODO: [Flarum 2.0] Remove the code below. + // This code prevents extensions which do not implement all abstract methods of this class from breaking + // the forum frontend. Without it, function calls would would error rather than returning `undefined.` + + const missingMethods: string[] = []; + + ['className', 'title', 'content', 'onsubmit'].forEach((method) => { + if (!(this as any)[method]) { + (this as any)[method] = function (): void {}; + missingMethods.push(method); + } + }); + + if (missingMethods.length > 0) { + fireDebugWarning( + `Modal \`${this.constructor.name}\` does not implement all abstract methods of the Modal super class. Missing methods: ${missingMethods.join( + ', ' + )}.` + ); + } + } + + oncreate(vnode: Mithril.VnodeDOM) { + super.oncreate(vnode); + + this.attrs.animateShow(() => this.onready()); + } + + onbeforeremove(vnode: Mithril.VnodeDOM): Promise | void { + super.onbeforeremove(vnode); + + // If the global modal state currently contains a modal, + // we've just opened up a new one, and accordingly, + // we don't need to show a hide animation. + if (!this.attrs.state.modal) { + this.attrs.animateHide(); + // Here, we ensure that the animation has time to complete. + // See https://mithril.js.org/lifecycle-methods.html#onbeforeremove + // Bootstrap's Modal.TRANSITION_DURATION is 300 ms. + return new Promise((resolve) => setTimeout(resolve, 300)); + } + } + + view() { + if (this.alertAttrs) { + this.alertAttrs.dismissible = false; + } + + return ( +
+
+ {(this.constructor as typeof Modal).isDismissible && ( +
+ {Button.component({ + icon: 'fas fa-times', + onclick: this.hide.bind(this), + className: 'Button Button--icon Button--link', + })} +
+ )} + +
+
+

{this.title()}

+
+ + {this.alertAttrs ?
{Alert.component(this.alertAttrs)}
: ''} + + {this.content()} +
+
+
+ ); + } + + /** + * Get the class name to apply to the modal. + */ + abstract className(): string; + + /** + * Get the title of the modal dialog. + */ + abstract title(): string; + + /** + * Get the content of the modal. + */ + abstract content(): Mithril.Children; + + /** + * Handle the modal form's submit event. + */ + abstract onsubmit(e: Event): void; + + /** + * Callback executed when the modal is shown and ready to be interacted with. + * + * @remark Focuses the first input in the modal. + */ + onready(): void { + this.$().find('input, select, textarea').first().trigger('focus').trigger('select'); + } + + /** + * Hides the modal. + */ + hide() { + this.attrs.state.close(); + } + + /** + * Sets `loading` to false and triggers a redraw. + */ + loaded() { + this.loading = false; + m.redraw(); + } + + /** + * Shows an alert describing an error returned from the API, and gives focus to + * the first relevant field involved in the error. + */ + onerror(error: RequestError) { + this.alertAttrs = error.alert; + + m.redraw(); + + if (error.status === 422 && error.response?.errors) { + this.$('form [name=' + (error.response.errors as any[])[0].source.pointer.replace('/data/attributes/', '') + ']').trigger('select'); + } else { + this.onready(); + } + } +} diff --git a/js/src/common/components/ModalManager.js b/js/src/common/components/ModalManager.tsx similarity index 61% rename from js/src/common/components/ModalManager.js rename to js/src/common/components/ModalManager.tsx index 9d0609c183..a9d55a1243 100644 --- a/js/src/common/components/ModalManager.js +++ b/js/src/common/components/ModalManager.tsx @@ -1,29 +1,35 @@ import Component from '../Component'; +import type Mithril from 'mithril'; +import type ModalManagerState from '../states/ModalManagerState'; + +interface IModalManagerAttrs { + state: ModalManagerState; +} + /** * The `ModalManager` component manages a modal dialog. Only one modal dialog * can be shown at once; loading a new component into the ModalManager will * overwrite the previous one. */ -export default class ModalManager extends Component { +export default class ModalManager extends Component { view() { const modal = this.attrs.state.modal; return (
- {modal - ? modal.componentClass.component({ - ...modal.attrs, - animateShow: this.animateShow.bind(this), - animateHide: this.animateHide.bind(this), - state: this.attrs.state, - }) - : ''} + {!!modal && + modal.componentClass.component({ + ...modal.attrs, + animateShow: this.animateShow.bind(this), + animateHide: this.animateHide.bind(this), + state: this.attrs.state, + })}
); } - oncreate(vnode) { + oncreate(vnode: Mithril.VnodeDOM) { super.oncreate(vnode); // Ensure the modal state is notified about a closed modal, even when the @@ -32,7 +38,9 @@ export default class ModalManager extends Component { this.$().on('hidden.bs.modal', this.attrs.state.close.bind(this.attrs.state)); } - animateShow(readyCallback) { + animateShow(readyCallback: () => void): void { + if (!this.attrs.state.modal) return; + const dismissible = !!this.attrs.state.modal.componentClass.isDismissible; // If we are opening this modal while another modal is already open, @@ -45,6 +53,7 @@ export default class ModalManager extends Component { this.$() .one('shown.bs.modal', readyCallback) + // @ts-expect-error: No typings available for Bootstrap modals. .modal({ backdrop: dismissible || 'static', keyboard: dismissible, @@ -52,7 +61,8 @@ export default class ModalManager extends Component { .modal('show'); } - animateHide() { + animateHide(): void { + // @ts-expect-error: No typings available for Bootstrap modals. this.$().modal('hide'); } } diff --git a/js/src/common/helpers/fireDebugWarning.ts b/js/src/common/helpers/fireDebugWarning.ts index 8c58e79863..159dfca1ad 100644 --- a/js/src/common/helpers/fireDebugWarning.ts +++ b/js/src/common/helpers/fireDebugWarning.ts @@ -1,3 +1,5 @@ +import app from '../app'; + /** * Calls `console.warn` with the provided arguments, but only if the forum is in debug mode. * diff --git a/js/src/common/states/ModalManagerState.js b/js/src/common/states/ModalManagerState.js deleted file mode 100644 index a11196279d..0000000000 --- a/js/src/common/states/ModalManagerState.js +++ /dev/null @@ -1,46 +0,0 @@ -import Modal from '../components/Modal'; - -export default class ModalManagerState { - constructor() { - this.modal = null; - } - - /** - * Show a modal dialog. - * - * @public - */ - show(componentClass, attrs) { - if (!(componentClass.prototype instanceof Modal)) { - // This is duplicated so that if the error is caught, an error message still shows up in the debug console. - const invalidModalWarning = 'The ModalManager can only show Modals.'; - console.error(invalidModalWarning); - throw new Error(invalidModalWarning); - } - - clearTimeout(this.closeTimeout); - - this.modal = { componentClass, attrs }; - - m.redraw.sync(); - } - - /** - * Close the modal dialog. - * - * @public - */ - close() { - if (!this.modal) return; - - // Don't hide the modal immediately, because if the consumer happens to call - // the `show` method straight after to show another modal dialog, it will - // cause Bootstrap's modal JS to misbehave. Instead we will wait for a tiny - // bit to give the `show` method the opportunity to prevent this from going - // ahead. - this.closeTimeout = setTimeout(() => { - this.modal = null; - m.redraw(); - }); - } -} diff --git a/js/src/common/states/ModalManagerState.ts b/js/src/common/states/ModalManagerState.ts new file mode 100644 index 0000000000..b01bc15e29 --- /dev/null +++ b/js/src/common/states/ModalManagerState.ts @@ -0,0 +1,71 @@ +import Modal from '../components/Modal'; + +/** + * Class used to manage modal state. + * + * Accessible on the `app` object via `app.modal` property. + */ +export default class ModalManagerState { + /** + * @internal + */ + modal: null | { + componentClass: typeof Modal; + attrs?: Record; + } = null; + + private closeTimeout?: number; + + /** + * Shows a modal dialog. + * + * If a modal is already open, the existing one will close and the new modal will replace it. + * + * @example Show a modal + * app.modal.show(MyCoolModal, { attr: 'value' }); + * + * @example Show a modal from a lifecycle method (`oncreate`, `view`, etc.) + * // This "hack" is needed due to quirks with nested redraws in Mithril. + * setTimeout(() => app.modal.show(MyCoolModal, { attr: 'value' }), 0); + */ + show(componentClass: typeof Modal, attrs: Record = {}): void { + if (!(componentClass.prototype instanceof Modal)) { + // This is duplicated so that if the error is caught, an error message still shows up in the debug console. + const invalidModalWarning = 'The ModalManager can only show Modals.'; + console.error(invalidModalWarning); + throw new Error(invalidModalWarning); + } + + clearTimeout(this.closeTimeout); + + this.modal = { componentClass, attrs }; + + m.redraw.sync(); + } + + /** + * Closes the currently open dialog, if one is open. + */ + close(): void { + if (!this.modal) return; + + // Don't hide the modal immediately, because if the consumer happens to call + // the `show` method straight after to show another modal dialog, it will + // cause Bootstrap's modal JS to misbehave. Instead we will wait for a tiny + // bit to give the `show` method the opportunity to prevent this from going + // ahead. + this.closeTimeout = setTimeout(() => { + this.modal = null; + m.redraw(); + }); + } + + /** + * Checks if a modal is currently open. + * + * @returns `true` if a modal dialog is currently open, otherwise `false`. + */ + isModalOpen(): boolean { + return !!this.modal; + } +} diff --git a/js/src/common/utils/RequestError.ts b/js/src/common/utils/RequestError.ts index 73e36f8549..e9a62589c3 100644 --- a/js/src/common/utils/RequestError.ts +++ b/js/src/common/utils/RequestError.ts @@ -1,21 +1,21 @@ export default class RequestError { - status: string; - options: object; + status: number; + options: Record; xhr: XMLHttpRequest; responseText: string | null; - response: object | null; + response: Record | null; alert: any; - constructor(status: string, responseText: string | null, options: object, xhr: XMLHttpRequest) { + constructor(status: number, responseText: string | null, options: Record, xhr: XMLHttpRequest) { this.status = status; this.responseText = responseText; this.options = options; this.xhr = xhr; try { - this.response = JSON.parse(responseText); + this.response = JSON.parse(responseText ?? 'null'); } catch (e) { this.response = null; }