chore: Usa `mithril-utilities` per Component, Form e Request

This commit is contained in:
Maicol Battistini 2023-05-04 22:05:58 +02:00
parent 18ba65ec38
commit 37162d5e79
No known key found for this signature in database
25 changed files with 69 additions and 500 deletions

View File

@ -1,174 +0,0 @@
import classnames, {Argument as ClassNames} from 'classnames';
import collect, {Collection} from 'collect.js';
import Mithril from 'mithril';
import type {
Children,
ClassComponent,
Vnode,
VnodeDOM
} from 'mithril';
export interface Attributes {
}
interface AttributesCollection<T extends Attributes> extends Collection<T> {
addClassNames(...classNames: ClassNames[]): void;
}
// noinspection SpellCheckingInspection,JSUnusedGlobalSymbols,JSUnusedLocalSymbols
/**
* @abstract
*
* The `Component` class defines a user interface 'building block'. A component
* generates a virtual DOM to be rendered on each redraw.
*
* Essentially, this is a wrapper for Mithril's components that adds several useful features:
*
* In the `oninit` and `onbeforeupdate` lifecycle hooks, we store vnode attrs in `this.attrs.
* This allows us to use attrs across components without having to pass the vnode to every single
* method.
* The static `initAttrs` method allows a convenient way to provide defaults (or to otherwise
* modify) the attrs that have been passed into a component.
* When the component is created in the DOM, we store its DOM element under `this.element`;
* this lets us use Cash to modify child DOM state from internal methods via the `this.$()`
* method.
* A convenience `component` method, which serves as an alternative to hyperscript and JSX.
*
* As with other Mithril components, components extending Component can be initialized
* and nested using JSX. The `component` method can also
* be used.
*
* @example
* return m('div', <MyComponent foo="bar"><p>Hello World</p></MyComponent>);
*
* @see https://mithril.js.org/components.html
*/
export abstract class Component<
A extends Attributes = Attributes,
S = undefined
> implements ClassComponent<A> {
/**
* The root DOM element for the component.
*/
element!: Element;
/**
* The attributes passed into the component.
*
* @see https://mithril.js.org/components.html#passing-data-to-components
* @see initAttrs
*/
attrs!: AttributesCollection<A>;
/**
* Class component state that is persisted between redraws.
*
* Updating this will **not** automatically trigger a redraw, unlike
* other frameworks.
*
* This is different to Vnode state, which is always an instance of your
* class component.
*
* This is `undefined` by default.
*/
state!: S;
/**
* Used for attribute code completion in JSX.
* @private
*/
private __attrs!: A;
/**
* @inheritdoc
*/
abstract view(vnode: Vnode<A, this>): Children;
/**
* @inheritdoc
*/
oninit(vnode: Vnode<A, this>) {
this.setAttrs(vnode.attrs);
}
/**
* @inheritdoc
*/
oncreate(vnode: VnodeDOM<A, this>) {
this.element = vnode.dom;
}
/**
* @inheritdoc
*/
onbeforeupdate(vnode: VnodeDOM<A, this>) {
this.setAttrs(vnode.attrs);
}
/**
* @inheritdoc
*/
onupdate(vnode: VnodeDOM<A, this>) {
}
/**
* @inheritdoc
*/
onbeforeremove(vnode: VnodeDOM<A, this>) {
}
/**
* @inheritdoc
*/
onremove(vnode: VnodeDOM<A, this>) {
}
/**
* Saves a reference to the vnode attrs after running them through initAttrs,
* and checking for common issues.
*
* @private
*/
setAttrs(attributes: A): void {
this.initAttrs(attributes);
if (attributes) {
if ('children' in attributes) {
// noinspection JSUnresolvedVariable
throw new Error(
`[${this.constructor.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 attributes) {
// noinspection JSUnresolvedVariable
throw new Error(
`[${this.constructor.name}] You cannot use the "tag" attribute name with Mithril 2.`
);
}
}
const attributesCollection = collect<A>(attributes);
attributesCollection.macro('addClassNames', (...classNames: ClassNames[]) => {
attributesCollection.put(
'className',
classnames(attributesCollection.get('className') as ClassNames, ...classNames)
);
});
this.attrs = attributesCollection as AttributesCollection<A>;
}
// noinspection JSUnusedLocalSymbols
/**
* Initialize the component's attrs.
*
* This can be used to assign default values for missing, optional attrs.
*
* @protected
*/
initAttrs(attributes: A): void {
}
}

View File

@ -14,7 +14,7 @@ import {
import {
Attributes,
Component
} from '~/Components/Component';
} from 'mithril-utilities';
import MdIcon from '~/Components/MdIcon';
export interface DataTableAttributes extends Attributes {

View File

@ -10,7 +10,7 @@ import {Vnode} from 'mithril';
import {
Attributes,
Component
} from '~/Components/Component';
} from 'mithril-utilities';
import MdIcon from '~/Components/MdIcon';
export interface DataTableColumnAttributes extends Attributes, Partial<JSX.IntrinsicElements['md-data-table-column']> {

View File

@ -5,12 +5,12 @@ import {
Vnode,
VnodeDOM
} from 'mithril';
import {Form} from 'mithril-utilities';
import Stream from 'mithril/stream';
import {Class} from 'type-fest';
import Form from '~/Components/Form';
import MdIcon from '~/Components/MdIcon';
import RecordDialog, {RecordDialogAttributes} from '~/Components/Dialogs/RecordDialog';
import MdIcon from '~/Components/MdIcon';
import Model from '~/Models/Model';
import {
VnodeCollection,

View File

@ -2,10 +2,10 @@ import {
Children,
Vnode
} from 'mithril';
import {RequestError} from 'mithril-utilities';
import Model from '~/Models/Model';
import {showSnackbar} from '~/utils/misc';
import {RequestError} from '~/utils/Request';
import RecordDialog, {RecordDialogAttributes} from './RecordDialog';

View File

@ -6,12 +6,12 @@ import {
Vnode,
VnodeDOM
} from 'mithril';
import Stream from 'mithril/stream';
import {
Attributes,
Component
} from '../Component';
} from 'mithril-utilities';
import Stream from 'mithril/stream';
export interface DialogAttributes extends Attributes, Partial<Pick<MDDialog,
'fullscreen' | 'fullscreenBreakpoint' | 'footerHidden' | 'stacked' | 'defaultAction' |

View File

@ -1,97 +0,0 @@
import {
ChildArray,
Children,
Vnode,
VnodeDOM
} from 'mithril';
import Stream from 'mithril/stream';
import {Without} from 'type-fest/source/merge-exclusive';
import {isVnode} from '~/utils/misc';
import {Component} from './Component';
export type FormSubmitEvent = SubmitEvent & {data: FormData};
export interface FormAttributes extends Partial<Omit<HTMLElementTagNameMap['form'], 'style' | 'onsubmit'>> {
onsubmit?: (event: FormSubmitEvent) => void,
state?: Record<string, Stream<string | any>> | Map<string, Stream<string | any>>
}
export default class Form<A extends FormAttributes = FormAttributes> extends Component<A, Record<string, Stream<string | any>> | Map<string, Stream<string | any>>> {
element!: HTMLFormElement;
onsubmitFunction?: A['onsubmit'];
// TODO: Change all states to Map?
state!: NonNullable<A['state']>;
oninit(vnode: Vnode<A, this>) {
super.oninit(vnode);
this.onsubmitFunction = vnode.attrs.onsubmit;
this.state = vnode.attrs.state ?? {};
delete vnode.attrs.state;
}
view(vnode: Vnode<A>) {
const attributes = this.attrs.except(['onsubmit', 'state']);
return (
<form {...attributes.all()} onsubmit={this.onsubmit.bind(this)}>
{(vnode.children as ChildArray).map(this.attachStreamToInput.bind(this))}
</form>
);
}
attachStreamToInput(child: Children) {
// Check if child is a Vnode
if (isVnode<{name?: string, id: string, value: unknown, oninput?: (event: Event) => void, state?: Stream<any>}>(child)) {
const stream = child.attrs.state ?? this.getState(child.attrs.name ?? child.attrs.id);
if (stream) {
child.attrs.value = stream();
const originalOninput = child.attrs.oninput;
// This ESLint rule is disabled because it doesn't recognize that the `oninput` attribute is being set and Mithril uses it instead of adding an event listener
// eslint-disable-next-line unicorn/prefer-add-event-listener
child.attrs.oninput = (event: Event) => {
stream((event.target as HTMLInputElement).value);
if (originalOninput) {
originalOninput(event);
}
};
delete child.attrs.state;
}
// Check if `child` has children and recursively call this function on them.
if (Array.isArray(child.children)) {
child.children = child.children.map(this.attachStreamToInput.bind(this));
}
}
return child;
}
oncreate(vnode: VnodeDOM<A, this>) {
super.oncreate(vnode);
const submitter = this.element.querySelector<HTMLElement>('[type="submit"]');
if (submitter) {
submitter.addEventListener('click', () => {
// TODO: Add submitter when https://github.com/material-components/material-web/issues/3941 is completed
this.element.requestSubmit();
});
}
}
onsubmit(e: FormSubmitEvent) {
e.preventDefault();
e.data = new FormData(e.target as HTMLFormElement);
if (this.onsubmitFunction) {
this.onsubmitFunction(e);
}
}
private getState(key: string) {
if (this.state instanceof Map) {
return this.state.get(key);
}
return this.state[key];
}
}

View File

@ -2,7 +2,7 @@ import '@material/web/icon/icon.js';
import type MaterialIcons from '@mdi/js';
import {Component} from './Component';
import {Component} from 'mithril-utilities';
export interface Attributes extends Partial<SVGElement> {
icon: typeof MaterialIcons | string;

View File

@ -6,14 +6,14 @@ import {
Vnode,
VnodeDOM
} from 'mithril';
import {
Attributes,
Component
} from 'mithril-utilities';
import {Footer} from '~/Components/layout/Footer';
import logoUrl from '../../images/logo_completo.png';
import {
Attributes,
Component
} from './Component';
import TopAppBar from './layout/TopAppBar';
export interface PageAttributes<A extends Record<string, any> & {external?: boolean} = Record<string, any>> extends Attributes, Required<ComponentAttributes<A>> {

View File

@ -10,12 +10,12 @@ import {
Children,
Vnode
} from 'mithril';
import Stream from 'mithril/stream';
import {
Attributes,
Component
} from '~/Components/Component';
} from 'mithril-utilities';
import Stream from 'mithril/stream';
import MdIcon from '~/Components/MdIcon';
import {VnodeCollectionItem} from '~/typings/jsx';
import {isMobile} from '~/utils/misc';

View File

@ -4,12 +4,12 @@ import {ListItemLink} from '@material/web/list/lib/listitemlink/list-item-link';
import '@material/web/list/list-item-link.js';
import type * as MaterialIcons from '@mdi/js';
import {Vnode} from 'mithril';
import {ValueOf} from 'type-fest';
import {
Attributes,
Component
} from '~/Components/Component';
} from 'mithril-utilities';
import {ValueOf} from 'type-fest';
import MdIcon from '~/Components/MdIcon';
type Icons = ValueOf<typeof MaterialIcons>;

View File

@ -1,4 +1,4 @@
import {Component} from '../Component';
import {Component} from 'mithril-utilities';
export class Footer extends Component {
view() {

View File

@ -1,8 +1,4 @@
import '@material/web/iconbutton/standard-icon-button.js';
import '~/WebComponents/TopAppBar';
import {IconButton} from '@material/web/iconbutton/lib/icon-button';
import {Menu} from '@material/web/menu/lib/menu';
import {
mdiMenu,
mdiMenuOpen
@ -12,13 +8,13 @@ import {
Vnode,
VnodeDOM
} from 'mithril';
import Stream from 'mithril/stream';
import logo from '~/../images/logo.png';
import {
Attributes,
Component
} from '~/Components/Component';
} from 'mithril-utilities';
import Stream from 'mithril/stream';
import logo from '~/../images/logo.png';
import Drawer from '~/Components/layout/Drawer';
import NotificationsAction from '~/Components/layout/topappbar_actions/NotificationsAction';
import PeriodSwitcherAction from '~/Components/layout/topappbar_actions/PeriodSwitcherAction';
@ -30,6 +26,7 @@ import {
isMobile,
mobileMediaQuery
} from '~/utils/misc';
import '~/WebComponents/TopAppBar';
export default class TopAppBar extends Component {
drawerOpenState = Stream(!isMobile());

View File

@ -3,7 +3,7 @@ import {
Vnode
} from 'mithril';
import {Component} from '~/Components/Component';
import {Component} from 'mithril-utilities';
import MdIcon, {Attributes as MdIconAttributes} from '~/Components/MdIcon';
export default abstract class TopAppBarAction extends Component {

View File

@ -1,18 +1,17 @@
import {router} from '@maicol07/inertia-mithril';
import '@material/web/button/outlined-button.js';
import '@material/web/button/text-button.js';
import {router} from '@maicol07/inertia-mithril';
import {
mdiAccountCircleOutline,
mdiAccountOutline,
mdiLogoutVariant
} from '@mdi/js';
import {Vnode} from 'mithril';
import {Request} from 'mithril-utilities';
import Stream from 'mithril/stream';
import Dialog from '~/Components/Dialogs/Dialog';
import MdIcon from '~/Components/MdIcon';
import Request from '~/utils/Request';
import TopAppBarAction from './TopAppBarAction';

View File

@ -1,9 +1,10 @@
import RequestHttpClientPromise from './RequestHttpClientPromise';
import Request from '~/utils/Request';
import type {
HttpClient,
HttpClientPromise
} from 'coloquent';
import {Request} from 'mithril-utilities';
import RequestHttpClientPromise from './RequestHttpClientPromise';
/**
* @class RequestHttpClient

View File

@ -3,7 +3,6 @@ import '@material/web/button/filled-button.js';
import '@material/web/button/text-button.js';
import '@material/web/checkbox/checkbox.js';
import '@material/web/dialog/dialog.js';
import '~/Components/m3/FilledTextField';
import {Dialog} from '@material/web/dialog/lib/dialog';
import {
@ -18,18 +17,19 @@ import type {
Vnode,
VnodeDOM
} from 'mithril';
import Stream from 'mithril/stream';
import Form, {FormSubmitEvent} from '~/Components/Form';
import {
Form,
FormSubmitEvent,
Request,
RequestError
} from 'mithril-utilities';
import Stream from 'mithril/stream';
import '~/Components/m3/FilledTextField';
import MdIcon from '~/Components/MdIcon';
import Page, {
PageAttributes
} from '~/Components/Page';
import Page, {PageAttributes} from '~/Components/Page';
import {VnodeCollectionItem} from '~/typings/jsx';
import {showSnackbar} from '~/utils/misc';
import Request, {
RequestError
} from '~/utils/Request';
export default class LoginPage extends Page {
form = {

View File

@ -1,30 +1,25 @@
import {router} from '@maicol07/inertia-mithril';
import '@maicol07/material-web-additions/card/elevated-card.js';
import '@material/web/button/filled-button.js';
import '~/Components/m3/FilledTextField';
import {router} from '@maicol07/inertia-mithril';
import {
mdiAccountOutline,
mdiLockCheckOutline,
mdiLockOutline
} from '@mdi/js';
import logoUrl from '~/../images/logo_completo.png';
import collect from 'collect.js';
import type {
Vnode,
VnodeDOM
} from 'mithril';
import type {Vnode} from 'mithril';
import {
Form,
FormSubmitEvent,
Request,
RequestError
} from 'mithril-utilities';
import Stream from 'mithril/stream';
import Form, {FormSubmitEvent} from '~/Components/Form';
import '~/Components/m3/FilledTextField';
import MdIcon from '~/Components/MdIcon';
import Page, {
PageAttributes
} from '~/Components/Page';
import Page, {PageAttributes} from '~/Components/Page';
import {VnodeCollectionItem} from '~/typings/jsx';
import {showSnackbar} from '~/utils/misc';
import Request, {
RequestError
} from '~/utils/Request';
export default class ResetPasswordPage extends Page {
form = {

View File

@ -1,18 +1,14 @@
import '@maicol07/material-web-additions/card/elevated-card.js';
import {router} from '@maicol07/inertia-mithril';
import type {
Vnode
} from 'mithril';
import '@maicol07/material-web-additions/card/elevated-card.js';
import type {Vnode} from 'mithril';
import {
Request,
RequestError
} from 'mithril-utilities';
import Stream from 'mithril/stream';
import Page, {
PageAttributes
} from '~/Components/Page';
import Page, {PageAttributes} from '~/Components/Page';
import {showSnackbar} from '~/utils/misc';
import Request, {
RequestError
} from '~/utils/Request';
import AdminUserStep from '~/Views/Setup/Steps/AdminUserStep';
import DatabaseStep from '~/Views/Setup/Steps/DatabaseStep';
import RegionalSettings from '~/Views/Setup/Steps/RegionalSettings';

View File

@ -9,9 +9,12 @@ import {
} from '@mdi/js';
import collect from 'collect.js';
import {Vnode} from 'mithril';
import Stream from 'mithril/stream';
import Form, {FormSubmitEvent} from '~/Components/Form';
import {
Form,
FormSubmitEvent
} from 'mithril-utilities';
import Stream from 'mithril/stream';
import MdIcon from '~/Components/MdIcon';
import {VnodeCollectionItem} from '~/typings/jsx';
import {

View File

@ -1,5 +1,4 @@
import '@maicol07/material-web-additions/layout-grid/layout-grid.js';
import '~/Components/m3/FilledTextField';
import {
mdiAccountOutline,
@ -14,15 +13,14 @@ import {
Children,
Vnode
} from 'mithril';
import Stream from 'mithril/stream';
import Form from '~/Components/Form';
import Form from 'mithril-utilities';
import Request, {RequestError} from 'mithril-utilities';
import Stream from 'mithril/stream';
import '~/Components/m3/FilledTextField';
import MdIcon from '~/Components/MdIcon';
import {VnodeCollectionItem} from '~/typings/jsx';
import {showSnackbar} from '~/utils/misc';
import Request, {
RequestError
} from '~/utils/Request';
import {
SetupStep,

View File

@ -5,9 +5,9 @@ import {
} from '@mdi/js';
import collect from 'collect.js';
import dayjs from 'dayjs';
import Stream from 'mithril/stream';
import Form from '~/Components/Form';
import {Form} from 'mithril-utilities';
import Stream from 'mithril/stream';
import MdIcon from '~/Components/MdIcon';
import {VnodeCollectionItem} from '~/typings/jsx';

View File

@ -12,7 +12,7 @@ import {
import {
Attributes,
Component
} from '~/Components/Component';
} from 'mithril-utilities';
import MdIcon from '~/Components/MdIcon';

View File

@ -1,7 +1,6 @@
import type {MdCheckbox} from '@material/web/checkbox/checkbox';
import '@material/web/checkbox/checkbox.js';
import '@material/web/field/outlined-field.js';
import type {MdCheckbox} from '@material/web/checkbox/checkbox';
import {mdiLicense} from '@mdi/js';
import {
Vnode,
@ -14,13 +13,7 @@ import {
getFlag,
getLocaleDisplayName
} from '~/utils/i18n';
import {
capitalize,
showSnackbar
} from '~/utils/misc';
import Request, {
RequestError
} from '~/utils/Request';
import {capitalize} from '~/utils/misc';
import {
SetupStep,

View File

@ -1,142 +0,0 @@
import {RequestOptions as MithrilRequestOptions} from 'mithril';
import {Cookies} from 'typescript-cookie';
export type RequestMethods = 'get' | 'head' | 'post' | 'put' | 'delete' | 'connect' | 'options' | 'trace' | 'patch' | string;
export interface RequestOptions<R = any> extends MithrilRequestOptions<R> {
url?: string,
renewCSRF?: boolean,
renewCSRFOnFailure?: boolean,
method?: RequestMethods
beforeRequest?: (options: RequestOptionsWithUrl) => Promise<void>
afterRequest?: (response: Promise<R>, options: RequestOptionsWithUrl) => void,
xsrfCookieName?: string,
xsrfHeaderName?: string
}
export interface RequestOptionsWithUrl<R = any> extends RequestOptions<R> {
url: string;
}
export interface RequestError<T = {message: string}> extends Error {
code: number;
response: T;
}
export default class Request<R> {
options: RequestOptionsWithUrl<R> = {
url: '',
headers: {},
renewCSRF: false,
renewCSRFOnFailure: false, // Renew CSRF token if request fails with 419 status code (CSRF token expired; risky since it can cause an infinite loop)
withCredentials: true,
xsrfCookieName: 'XSRF-TOKEN',
xsrfHeaderName: 'X-XSRF-TOKEN'
};
constructor(options: RequestOptions) {
this.options = {...this.options, ...options};
}
/**
* Sends the request
*
* @throws {RequestError} If request has an error
*/
public async send() {
await this.beforeSendingRequest();
const response = m.request<R>(this.options);
this.afterSendingRequest(response);
return response;
}
/**
* Actions to perform before sending the request
* @private
*/
private async beforeSendingRequest() {
this.phpWorkaround();
this.xsfrAutoHeader();
await this.options.beforeRequest?.(this.options);
}
/**
* Workaround for PHP issue with PUT/PATCH/DELETE requests and FormData
*
* @see https://bugs.php.net/bug.php?id=55815
* @private
*/
private phpWorkaround() {
if (this.options.method && !['get', 'post'].includes(this.options.method) && this.options.body instanceof FormData) {
this.options.body.append('_method', this.options.method);
this.options.method = 'post';
}
}
/**
* Automatically set the XSRF header if the cookie is set
*
* @private
*/
private xsfrAutoHeader() {
const token = Cookies.get(this.options.xsrfCookieName) as string | undefined;
if (token && this.options.xsrfHeaderName) {
this.options.headers![this.options.xsrfHeaderName] = decodeURIComponent(token);
}
}
/**
* Actions to perform after sending the request
*
* @private
*/
private afterSendingRequest(response: Promise<R>) {
this.options.afterRequest?.(response, this.options);
}
static get<R>(url: string, parameters?: RequestOptions['params'], options?: RequestOptions) {
return this.sendRequest<R>('get', url, parameters, options);
}
static post<R>(url: string, data?: RequestOptions['body'], options?: RequestOptions) {
return this.sendRequest<R>('post', url, data, options);
}
static put<R>(url: string, data?: RequestOptions['body'], options?: RequestOptions) {
return this.sendRequest<R>('put', url, data, options);
}
static patch<R>(url: string, data?: RequestOptions['body'], options?: RequestOptions) {
return this.sendRequest<R>('patch', url, data, options);
}
static delete<R>(url: string, options?: RequestOptions) {
return this.sendRequest<R>('delete', url, undefined, options);
}
/**
* Sends a request
* @param method The method of the request (get, post, put, patch, delete)
* @param url The URL of the request
* @param data The data to send
* @param options The options of the request
* @private
*/
static sendRequest<R>(
method: RequestMethods,
url: string,
data?: RequestOptions['params'] | RequestOptions['body'],
options: RequestOptions = {}
) {
if (method === 'get') {
options.params = data as RequestOptions['params'];
} else {
options.body = data;
}
options.url ??= url;
return (new Request<R>({method, ...options})).send();
}
}