Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rewrite ModalManager and state to Typescript #3007

Merged
merged 11 commits into from
Oct 30, 2021
4 changes: 2 additions & 2 deletions js/src/common/Component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,13 +138,13 @@ export default abstract class Component<Attrs extends ComponentAttrs = Component
if ('children' in attrs) {
throw new Error(
`[${
(this.constructor as any).name
(this.constructor as typeof Component).name
}] The "children" attribute of attrs should never be used. Either pass children in as the vnode children or rename the attribute`
);
}

if ('tag' in attrs) {
throw new Error(`[${(this.constructor as any).name}] You cannot use the "tag" attribute name with Mithril 2.`);
throw new Error(`[${(this.constructor as typeof Component).name}] You cannot use the "tag" attribute name with Mithril 2.`);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,42 @@
import Component from '../Component';
import Alert from './Alert';
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';

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.
*
* @abstract
*/
export default class Modal extends Component {
export default abstract class Modal<ModalAttrs extends Record<string, unknown> = {}> extends Component<ModalAttrs & IInternalModalAttrs> {
davwheat marked this conversation as resolved.
Show resolved Hide resolved
/**
* Determine whether or not the modal should be dismissible via an 'x' button.
*/
static isDismissible = true;
static readonly isDismissible = true;

protected loading: boolean = false;

/**
* Attributes for an alert component to show below the header.
*
* @type {object}
*/
alertAttrs = null;
alertAttrs!: AlertAttrs;

oncreate(vnode) {
oncreate(vnode: Mithril.VnodeDOM<ModalAttrs & IInternalModalAttrs, this>) {
super.oncreate(vnode);

this.attrs.animateShow(() => this.onready());
this.attrs.animateShow(this.onready);
davwheat marked this conversation as resolved.
Show resolved Hide resolved
}

onbeforeremove(vnode) {
onbeforeremove(vnode: Mithril.VnodeDOM<ModalAttrs & IInternalModalAttrs, this>): Promise<void> | void {
super.onbeforeremove(vnode);

// If the global modal state currently contains a modal,
Expand All @@ -50,16 +59,14 @@ export default class Modal extends Component {
return (
<div className={'Modal modal-dialog ' + this.className()}>
<div className="Modal-content">
{this.constructor.isDismissible ? (
{(this.constructor as typeof Modal).isDismissible && (
<div className="Modal-close App-backControl">
{Button.component({
icon: 'fas fa-times',
onclick: this.hide.bind(this),
className: 'Button Button--icon Button--link',
})}
</div>
) : (
''
)}

<form onsubmit={this.onsubmit.bind(this)}>
Expand All @@ -78,70 +85,59 @@ export default class Modal extends Component {

/**
* Get the class name to apply to the modal.
*
* @return {String}
* @abstract
*/
className() {}
abstract className(): string;
davwheat marked this conversation as resolved.
Show resolved Hide resolved

/**
* Get the title of the modal dialog.
*
* @return {String}
* @abstract
*/
title() {}
abstract title(): string;

/**
* Get the content of the modal.
*
* @return {VirtualElement}
* @abstract
*/
content() {}
abstract content(): Mithril.Children;

/**
* Handle the modal form's submit event.
*
* @param {Event} e
*/
onsubmit() {}
abstract onsubmit(e: Event): void;

/**
* Focus on the first input when the modal is ready to be used.
* Callback executed when the modal is shown and ready to be interacted with.
*
* @remark Focuses the first input in the modal.
*/
onready() {
this.$('form').find('input, select, textarea').first().focus().select();
onready(): void {
this.$().find('input, select, textarea').first().trigger('focus').trigger('select');
}

/**
* Hide the modal.
* Hides the modal.
*/
hide() {
this.attrs.state.close();
}

/**
* Stop loading.
* Sets `loading` to false and triggers a redraw.
*/
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
* Shows an alert describing an error returned from the API, and gives focus to
* the first relevant field involved in the error.
*/
onerror(error) {
onerror(error: RequestError) {
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();
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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IModalManagerAttrs> {
view() {
const modal = this.attrs.state.modal;

return (
<div className="ModalManager modal fade">
{modal
? modal.componentClass.component({
...modal.attrs,
animateShow: this.animateShow.bind(this),
animateHide: this.animateHide.bind(this),
state: this.attrs.state,
})
: ''}
{modal &&
davwheat marked this conversation as resolved.
Show resolved Hide resolved
modal.componentClass.component({
...modal.attrs,
animateShow: this.animateShow.bind(this),
animateHide: this.animateHide.bind(this),
state: this.attrs.state,
})}
</div>
);
}

oncreate(vnode) {
oncreate(vnode: Mithril.VnodeDOM<IModalManagerAttrs, this>) {
super.oncreate(vnode);

// Ensure the modal state is notified about a closed modal, even when the
Expand All @@ -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,
Expand All @@ -45,14 +53,16 @@ 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,
})
.modal('show');
}

animateHide() {
animateHide(): void {
// @ts-expect-error: No typings available for Bootstrap modals.
this.$().modal('hide');
}
}
46 changes: 0 additions & 46 deletions js/src/common/states/ModalManagerState.js

This file was deleted.

71 changes: 71 additions & 0 deletions js/src/common/states/ModalManagerState.ts
Original file line number Diff line number Diff line change
@@ -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 | {
davwheat marked this conversation as resolved.
Show resolved Hide resolved
componentClass: typeof Modal;
attrs?: Record<string, unknown>;
} = 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 <caption>Show a modal</caption>
* app.modal.show(MyCoolModal, { attr: 'value' });
*
* @example <caption>Show a modal from a lifecycle method (`oncreate`, `view`, etc.)</caption>
* // 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<string, unknown> = {}): 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;
askvortsov1 marked this conversation as resolved.
Show resolved Hide resolved
}
}
10 changes: 5 additions & 5 deletions js/src/common/utils/RequestError.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
export default class RequestError {
status: string;
options: object;
status: number;
options: Record<string, unknown>;
xhr: XMLHttpRequest;

responseText: string | null;
response: object | null;
response: Record<string, unknown> | null;

alert: any;

constructor(status: string, responseText: string | null, options: object, xhr: XMLHttpRequest) {
constructor(status: number, responseText: string | null, options: Record<string, unknown>, 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;
}
Expand Down