UNPKG

@etsoo/materialui

Version:

TypeScript Material-UI Implementation

548 lines (489 loc) 12.5 kB
import { AppTryLoginParams, BridgeUtils, CoreApp, FormatResultCustomCallback, IApp, IAppSettings, ICoreApp, IUser } from "@etsoo/appscript"; import { INotifier, NotificationMessageType, NotificationRenderProps, NotificationReturn } from "@etsoo/notificationbase"; import { DataTypes, IActionResult, WindowStorage } from "@etsoo/shared"; import React from "react"; import { NotifierMU } from "../NotifierMU"; import { ProgressCount } from "../ProgressCount"; import { Labels } from "./Labels"; import { CultureAction, CultureState, INotificationReact, InputDialogProps, IStateProps, NotificationReactCallProps, UserAction, UserActionType, useRequiredContext, UserState } from "@etsoo/react"; import { NavigateFunction, NavigateOptions } from "react-router"; /** * React Application Type */ export type ReactAppType = IApp & IReactAppBase; /** * React application context */ export const ReactAppContext = React.createContext<ReactAppType | null>(null); /** * Get React application context hook * @returns React application */ export function useAppContext() { return React.useContext(ReactAppContext); } /** * Get React application context hook * @returns React application */ export function useRequiredAppContext() { return useRequiredContext(ReactAppContext); } /** * React implemented base */ export interface IReactAppBase { /** * Override Notifier as React specific */ readonly notifier: INotifier<React.ReactNode, NotificationReactCallProps>; /** * Is screen size down 'sm' */ smDown?: boolean; /** * Is screen size up 'md' */ mdUp?: boolean; /** * Get date format props * @returns Props */ getDateFormatProps(): object; /** * Get money format props * @param currency Currency, if undefined, default currency applied * @returns Props */ getMoneyFormatProps(currency?: string): object; /** * Show input dialog * @param props Props */ showInputDialog({ title, message, callback, ...rest }: InputDialogProps): INotificationReact; /** * State detector component * @param props Props */ stateDetector(props: IStateProps): React.ReactNode; } /** * Core application interface */ export interface IReactApp<S extends IAppSettings, D extends IUser> extends ICoreApp<D, S, React.ReactNode, NotificationReactCallProps>, Omit<IReactAppBase, "userState"> { /** * User state */ readonly userState: UserState<D>; } /** * React application */ export class ReactApp<S extends IAppSettings, D extends IUser> extends CoreApp<D, S, React.ReactNode, NotificationReactCallProps> implements IReactApp<S, D> { private static _notifierProvider: React.FunctionComponent<NotificationRenderProps>; /** * Get notifier provider */ static get notifierProvider() { return this._notifierProvider; } private static createNotifier(debug: boolean) { // Notifier ReactApp._notifierProvider = NotifierMU.setup(undefined, debug); return NotifierMU.instance; } /** * Culture state */ readonly cultureState: CultureState; /** * User state */ readonly userState = new UserState<D>(); /** * Is screen size down 'sm' */ smDown?: boolean; /** * Is screen size up 'md' */ mdUp?: boolean; /** * Navigate function */ navigateFunction?: NavigateFunction; /** * User state dispatch */ userStateDispatch?: React.Dispatch<UserAction<D>>; /** * Constructor * @param settings Settings * @param name Application name * @param debug Debug mode */ constructor(settings: S, name: string, debug: boolean = false) { super( settings, null, ReactApp.createNotifier(debug), new WindowStorage(), name, debug ); if (BridgeUtils.host) { BridgeUtils.host.onUpdate((app, version) => { this.notifier.message( NotificationMessageType.Success, this.get("updateTip") + `(${[app, version].join(", ")})`, this.get("updateReady") ); }); } this.cultureState = new CultureState(this.settings.currentCulture); } /** * Override alert action result * @param result Action result * @param callback Callback * @param forceToLocal Force to local labels */ override alertResult( result: IActionResult | string, callback?: NotificationReturn<void>, forceToLocal?: FormatResultCustomCallback ) { const message = typeof result === "string" ? result : this.formatResult(result, forceToLocal); if (message.endsWith(")")) { const startPos = message.lastIndexOf("("); if (startPos > 0) { const main = message.substring(0, startPos).trim(); const tip = message.substring(startPos); const titleNode = React.createElement( React.Fragment, null, main, React.createElement("br"), React.createElement("span", { style: { fontSize: "9px" } }, tip) ); this.notifier.alert(titleNode, callback); return; } } super.alertResult(message, callback); } /** * Change culture * @param culture New culture definition */ override async changeCulture(culture: DataTypes.CultureDefinition) { // Super call to update cultrue const resources = await super.changeCulture(culture); // Update component labels Labels.setLabels(resources, { notificationMU: { alertTitle: "warning", alertOK: "ok", confirmTitle: "confirm", confirmYes: "ok", confirmNo: "cancel", promptTitle: "prompt", promptCancel: "cancel", promptOK: "ok" } }); // Document title // Default is servier name's label or appName label const title = this.get(this.name) ?? this.get("appName") ?? this.name; const host = BridgeUtils.host; if (host) { // Notify host host.changeCulture(culture.name); host.setTitle(title); } else { document.title = title; } return resources; } /** * Change culture extended * @param dispatch Dispatch method * @param culture New culture definition */ changeCultureEx( dispatch: React.Dispatch<CultureAction>, culture: DataTypes.CultureDefinition ): void { // Same? if (culture.name === this.culture) return; // Super call this.changeCulture(culture).then(() => { // Dispatch action dispatch(culture); }); } /** * Get date format props * @returns Props */ getDateFormatProps() { return { culture: this.culture, timeZone: this.getTimeZone() }; } /** * Get money format props * @param currency Currency, if undefined, default currency applied * @returns Props */ getMoneyFormatProps(currency?: string) { return { culture: this.culture, currency: currency ?? this.currency }; } /** * Fresh countdown UI * @param callback Callback */ freshCountdownUI(callback?: () => PromiseLike<unknown>) { // Labels const labels = this.getLabels("cancel", "tokenExpiry"); // Progress const progress = React.createElement(ProgressCount, { seconds: 30, valueUnit: "s", onComplete: () => { // Stop the progress return false; } }); // Popup this.notifier.alert( labels.tokenExpiry, async () => { if (callback) await callback(); else await this.tryLogin(); }, undefined, { okLabel: labels.cancel, primaryButtonProps: { fullWidth: true, autoFocus: false }, inputs: progress } ); } /** * Try login * @param data Try login parameters * @returns Result */ override async tryLogin(data?: AppTryLoginParams) { // Destruct const { onFailure = (type: string) => { console.log(`Try login failed: ${type}.`); if ( globalThis.navigator.onLine && !type.includes('"title":"Failed to fetch"') ) { this.clearCacheToken(); } this.toLoginPage(rest); }, onSuccess, ...rest } = data ?? {}; // Check status const result = await super.tryLogin(data); if (!result) { onFailure("ReactAppSuperTryLoginFailed"); return false; } // Refresh token await this.refreshToken( { showLoading: data?.showLoading }, (result) => { if (result === true) { onSuccess?.(); } else if (result === false) { onFailure("ReactAppRefreshTokenFailed"); } else if (result != null && !this.tryLoginIgnoreResult(result)) { onFailure("ReactAppRefreshTokenFailed: " + JSON.stringify(result)); } else { // Ignore other results onFailure( "ReactAppRefreshTokenIgnoredFailure: " + JSON.stringify(result) ); return true; } } ); return true; } /** * Check if the action result should be ignored during try login * @param result Action result * @returns Result */ protected tryLoginIgnoreResult(result: IActionResult) { // Ignore no token warning if (result.type === "noData" && result.field === "token") return true; else return false; } /** * Navigate to Url or delta * @param url Url or delta * @param options Options */ override navigate<T extends number | string | URL>( to: T, options?: T extends number ? never : NavigateOptions ) { if (this.navigateFunction == null) super.navigate(to, options); else if (typeof to === "number") this.navigateFunction(to); else this.navigateFunction(to, options); } /** * Show input dialog * @param props Props */ showInputDialog({ title, message, callback, ...rest }: InputDialogProps): INotificationReact { return this.notifier.prompt<HTMLFormElement | undefined>( message, callback, title, rest ); } stateDetector(props: IStateProps) { // Destruct const { targetFields, update } = props; // Context const { state } = React.useContext(this.userState.context); // Ready React.useEffect(() => { // Match fields const changedFields = state.lastChangedFields; let matchedFields: string[] | undefined; if (targetFields == null || changedFields == null) { matchedFields = changedFields; } else { matchedFields = []; targetFields.forEach((targetField) => { if (changedFields.includes(targetField)) matchedFields?.push(targetField); }); } // Callback update(state.authorized, matchedFields); }, [state]); // return return React.createElement(React.Fragment); } /** * User login extended * @param user New user * @param refreshToken Refresh token * @param dispatch User state dispatch */ override userLogin(user: D, refreshToken: string, dispatch?: boolean): void { // Super call, set token super.userLogin(user, refreshToken); // Dispatch action if (dispatch !== false) { this.doLoginDispatch(user); } } /** * User login callback * @param user New user */ protected onUserLogin(user: D) { return Promise.resolve(); } /** * User login dispatch * @param user New user */ protected doLoginDispatch(user: D) { this.onUserLogin(user).then(() => { if (this.userStateDispatch != null) this.userStateDispatch({ type: UserActionType.Login, user }); }); } /** * User logout * @param clearToken Clear refresh token or not * @param noTrigger No trigger for state change */ override userLogout( clearToken: boolean = true, noTrigger: boolean = false ): void { // Super call super.userLogout(clearToken); // Dispatch action if (!noTrigger && this.userStateDispatch != null) this.userStateDispatch({ type: UserActionType.Logout }); } /** * User unauthorized */ override userUnauthorized() { // Super call super.userUnauthorized(); if (this.userStateDispatch != null) { // There is delay during state update // Not a good idea to try login multiple times with API calls this.userStateDispatch({ type: UserActionType.Unauthorized }); } } }