243 lines
6.6 KiB
TypeScript
243 lines
6.6 KiB
TypeScript
import { type FunctionComponent, memo, type PropsWithChildren, useEffect, useRef, useState } from "react";
|
|
|
|
/**
|
|
* Minimal Controller for managing component state and lifecycle.
|
|
*/
|
|
export abstract class Controller<
|
|
TState extends Record<string, unknown> = {},
|
|
TProps extends Record<string, unknown> = {},
|
|
> {
|
|
state: TState = {} as TState;
|
|
props: TProps = {} as TProps;
|
|
|
|
#initiated = false;
|
|
#destroyed = false;
|
|
|
|
#setState: (state: Partial<TState>) => void;
|
|
|
|
declare readonly $state: TState;
|
|
declare readonly $props: TProps;
|
|
|
|
constructor(setState: (state: Partial<TState>) => void) {
|
|
this.#setState = setState;
|
|
}
|
|
|
|
/**
|
|
* Factory method to create a new controller instance.
|
|
*/
|
|
static make<TController extends typeof Controller>(
|
|
this: TController,
|
|
setState: any,
|
|
setLoading: any,
|
|
setError: any,
|
|
): InstanceType<TController> {
|
|
// biome-ignore lint/complexity/noThisInStatic: should return new instance of child class
|
|
return new (this as any)(setState, setLoading, setError) as InstanceType<TController>;
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------------
|
|
| Lifecycle
|
|
|--------------------------------------------------------------------------------
|
|
*/
|
|
|
|
async $init(props: TProps): Promise<void> {
|
|
if (this.#destroyed === true) {
|
|
return;
|
|
}
|
|
this.props = props;
|
|
if (this.onInit === undefined) {
|
|
return;
|
|
}
|
|
const state = await this.onInit();
|
|
if (this.#destroyed === false) {
|
|
this.setState(state);
|
|
}
|
|
this.#initiated = true;
|
|
}
|
|
|
|
async $resolve(props: TProps): Promise<void> {
|
|
if (this.#initiated === false || this.#destroyed === true) {
|
|
return;
|
|
}
|
|
this.props = props;
|
|
if (this.onResolve === undefined) {
|
|
return;
|
|
}
|
|
const state: Partial<TState> = await this.onResolve();
|
|
if (this.#destroyed === false) {
|
|
this.setState({ ...this.state, ...state });
|
|
}
|
|
}
|
|
|
|
async $destroy(): Promise<void> {
|
|
this.#destroyed = true;
|
|
await this.onDestroy();
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------------
|
|
| Lifecycle Hooks
|
|
|--------------------------------------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* Called every time props change (including first mount).
|
|
*/
|
|
async onInit(): Promise<Partial<TState>> {
|
|
return {};
|
|
}
|
|
|
|
/**
|
|
* Called every time props change (including first mount).
|
|
*/
|
|
async onResolve(): Promise<Partial<TState>> {
|
|
return {};
|
|
}
|
|
|
|
/**
|
|
* Called when the controller is destroyed.
|
|
*/
|
|
async onDestroy(): Promise<void> {}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------------
|
|
| State Management
|
|
|--------------------------------------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* Updates the controller state.
|
|
*/
|
|
setState(state: Partial<TState>): void;
|
|
setState<K extends keyof TState>(key: K, value: TState[K]): void;
|
|
setState<K extends keyof TState>(...args: [K | Partial<TState>, TState[K]?]): void {
|
|
if (this.#destroyed) {
|
|
return;
|
|
}
|
|
|
|
const [target, value] = args;
|
|
|
|
if (typeof target === "string") {
|
|
this.state = { ...this.state, [target]: value };
|
|
} else {
|
|
this.state = { ...this.state, ...(target as Partial<TState>) };
|
|
}
|
|
|
|
this.#setState(this.state);
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------------
|
|
| Actions
|
|
|--------------------------------------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* Returns all public methods as bound actions.
|
|
*/
|
|
toActions(): ControllerActions<this> {
|
|
const actions: any = {};
|
|
const prototype = Object.getPrototypeOf(this);
|
|
|
|
for (const name of Object.getOwnPropertyNames(prototype)) {
|
|
if (this.#isAction(name)) {
|
|
const method = (this as any)[name];
|
|
if (typeof method === "function") {
|
|
actions[name] = method.bind(this);
|
|
}
|
|
}
|
|
}
|
|
|
|
return actions;
|
|
}
|
|
|
|
#isAction(name: string): boolean {
|
|
return name !== "constructor" && !name.startsWith("$") && !name.startsWith("_") && !name.startsWith("#");
|
|
}
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------------
|
|
| Component
|
|
|--------------------------------------------------------------------------------
|
|
*/
|
|
|
|
export function makeControllerComponent<TController extends new (...args: any[]) => Controller<any, any>>(
|
|
ControllerClass: TController,
|
|
Component: FunctionComponent<
|
|
PropsWithChildren<
|
|
InstanceType<TController>["$props"] &
|
|
InstanceType<TController>["$state"] &
|
|
ControllerActions<InstanceType<TController>>
|
|
>
|
|
>,
|
|
LoadingComponent?: FunctionComponent<PropsWithChildren>,
|
|
ErrorComponent?: FunctionComponent<PropsWithChildren<{ error: Error }>>,
|
|
) {
|
|
const container: FunctionComponent<PropsWithChildren> = (props: any) => {
|
|
const [loading, setLoading] = useState<boolean>(true);
|
|
const [error, setError] = useState<any>();
|
|
const [state, setState] = useState<any>();
|
|
|
|
const controller = useRef<InstanceType<TController> | null>(null);
|
|
const actions = useRef<ControllerActions<InstanceType<TController>> | null>(null);
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: should only execute once
|
|
useEffect(() => {
|
|
const instance = (ControllerClass as any).make(setState);
|
|
|
|
controller.current = instance;
|
|
actions.current = instance.toActions();
|
|
|
|
instance
|
|
.$init(props || {})
|
|
.then(() => {
|
|
setLoading(false);
|
|
})
|
|
.catch((error: unknown) => {
|
|
setError(error);
|
|
setLoading(false);
|
|
});
|
|
|
|
return () => {
|
|
instance.$destroy();
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
controller.current?.$resolve(props || {}).catch((error) => {
|
|
setError(error);
|
|
});
|
|
}, [props]);
|
|
|
|
if (loading === true || state === undefined) {
|
|
return LoadingComponent ? <LoadingComponent {...props} /> : null;
|
|
}
|
|
|
|
if (error !== undefined) {
|
|
return ErrorComponent ? <ErrorComponent {...props} error={error} /> : null;
|
|
}
|
|
|
|
return <Component {...props} {...state} {...actions.current} />;
|
|
};
|
|
|
|
container.displayName = `${ControllerClass.name}Component`;
|
|
|
|
return memo(container);
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------------
|
|
| Types
|
|
|--------------------------------------------------------------------------------
|
|
*/
|
|
|
|
type ControllerActions<T> = {
|
|
[K in keyof T]: T[K] extends (...args: any[]) => any
|
|
? K extends `$${string}` | `_${string}` | `#${string}` | "constructor"
|
|
? never
|
|
: T[K]
|
|
: never;
|
|
};
|