Skip to content

Commit

Permalink
chore: rewrite ModalManager and state to Typescript (#3007)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

Co-authored-by: David Sevilla Martin <[email protected]>
  • Loading branch information
davwheat and dsevillamartin authored Oct 30, 2021
1 parent a0a0697 commit 7db2d0f
Show file tree
Hide file tree
Showing 8 changed files with 273 additions and 214 deletions.
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
149 changes: 0 additions & 149 deletions js/src/common/components/Modal.js

This file was deleted.

171 changes: 171 additions & 0 deletions js/src/common/components/Modal.tsx
Original file line number Diff line number Diff line change
@@ -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<ModalAttrs = {}> extends Component<ModalAttrs & IInternalModalAttrs> {
/**
* 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<ModalAttrs & IInternalModalAttrs, this>) {
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<ModalAttrs & IInternalModalAttrs, this>) {
super.oncreate(vnode);

this.attrs.animateShow(() => this.onready());
}

onbeforeremove(vnode: Mithril.VnodeDOM<ModalAttrs & IInternalModalAttrs, this>): Promise<void> | 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 (
<div className={'Modal modal-dialog ' + this.className()}>
<div className="Modal-content">
{(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)}>
<div className="Modal-header">
<h3 className="App-titleControl App-titleControl--text">{this.title()}</h3>
</div>

{this.alertAttrs ? <div className="Modal-alert">{Alert.component(this.alertAttrs)}</div> : ''}

{this.content()}
</form>
</div>
</div>
);
}

/**
* 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();
}
}
}
Loading

0 comments on commit 7db2d0f

Please sign in to comment.