feat: checkpoint
This commit is contained in:
234
apps/react/src/libraries/controller.ts
Normal file
234
apps/react/src/libraries/controller.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Minimal Controller for managing component state and lifecycle.
|
||||
*/
|
||||
export class Controller<TState extends Record<string, unknown> = {}, TProps extends Record<string, unknown> = {}> {
|
||||
state: TState = {} as TState;
|
||||
props: TProps = {} as TProps;
|
||||
|
||||
#resolved = false;
|
||||
#destroyed = false;
|
||||
|
||||
#setState: (state: Partial<TState>) => void;
|
||||
#setLoading: (state: boolean) => void;
|
||||
|
||||
constructor(setState: (state: Partial<TState>) => void, setLoading: (state: boolean) => void) {
|
||||
this.#setState = setState;
|
||||
this.#setLoading = setLoading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new controller instance.
|
||||
*/
|
||||
static make<TController extends typeof Controller>(
|
||||
this: TController,
|
||||
setState: any,
|
||||
setLoading: any,
|
||||
): InstanceType<TController> {
|
||||
return new this(setState, setLoading) as InstanceType<TController>;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Lifecycle
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolves the controller with given props.
|
||||
* - First time: Runs onInit() then onResolve()
|
||||
* - Subsequent times: Runs only onResolve()
|
||||
*/
|
||||
async $resolve(props: TProps): Promise<void> {
|
||||
if (this.#destroyed === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props = props;
|
||||
let state: Partial<TState> = {};
|
||||
|
||||
try {
|
||||
if (this.#resolved === false) {
|
||||
const initState = await this.onInit();
|
||||
if (initState) {
|
||||
state = { ...state, ...initState };
|
||||
}
|
||||
}
|
||||
const resolveState = await this.onResolve();
|
||||
if (resolveState) {
|
||||
state = { ...state, ...resolveState };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Controller resolve error:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.#resolved = true;
|
||||
|
||||
if (this.#destroyed === false) {
|
||||
this.setState(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the controller and cleans up resources.
|
||||
*/
|
||||
async $destroy(): Promise<void> {
|
||||
this.#destroyed = true;
|
||||
await this.onDestroy();
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Lifecycle Hooks
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Called once when the controller is first initialized.
|
||||
*/
|
||||
async onInit(): Promise<Partial<TState> | void> {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Called every time props change (including first mount).
|
||||
*/
|
||||
async onResolve(): Promise<Partial<TState> | void> {
|
||||
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);
|
||||
this.#setLoading(false);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| 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("#");
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Hook
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* React hook for using a controller.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function LoginView() {
|
||||
* const [state, actions] = useController(LoginController);
|
||||
*
|
||||
* return (
|
||||
* <button onClick={actions.login}>
|
||||
* {state.authenticated ? "Logout" : "Login"}
|
||||
* </button>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useController<TController extends new (...args: any[]) => Controller<any, any>>(
|
||||
ControllerClass: TController,
|
||||
props?: InstanceType<TController>["props"],
|
||||
): [InstanceType<TController>["state"], boolean, ControllerActions<InstanceType<TController>>] {
|
||||
const [state, setState] = useState<any>({});
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
const controllerRef = useRef<InstanceType<TController> | null>(null);
|
||||
const actionsRef = useRef<ControllerActions<InstanceType<TController>> | null>(null);
|
||||
const propsRef = useRef(props);
|
||||
|
||||
// Resolve only once after creation
|
||||
useMemo(() => {
|
||||
const instance = (ControllerClass as any).make(setState, setLoading);
|
||||
controllerRef.current = instance;
|
||||
actionsRef.current = instance.toActions();
|
||||
|
||||
instance.$resolve(props || {});
|
||||
|
||||
return () => {
|
||||
instance.$destroy();
|
||||
};
|
||||
}, [controllerRef]);
|
||||
|
||||
// Resolve on props change
|
||||
useEffect(() => {
|
||||
if (propsRef.current !== props) {
|
||||
propsRef.current = props;
|
||||
controllerRef.current?.$resolve(props || {});
|
||||
}
|
||||
}, [props]);
|
||||
|
||||
return [state, loading, actionsRef.current!];
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type ControllerActions<T> = {
|
||||
[K in keyof T]: T[K] extends (...args: any[]) => any
|
||||
? K extends `$${string}` | `_${string}` | `#${string}` | "constructor"
|
||||
? never
|
||||
: T[K]
|
||||
: never;
|
||||
};
|
||||
269
apps/react/src/libraries/form.ts
Normal file
269
apps/react/src/libraries/form.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import z, { type ZodObject, type ZodRawShape } from "zod";
|
||||
|
||||
export class Form<TSchema extends ZodRawShape, TInputs = z.infer<ZodObject<TSchema>>> {
|
||||
readonly schema: ZodObject<TSchema>;
|
||||
|
||||
readonly inputs: Partial<TInputs> = {};
|
||||
|
||||
#debounce: FormDebounce<TInputs> = {
|
||||
validate: {},
|
||||
};
|
||||
|
||||
#defaults: Partial<TInputs>;
|
||||
#errors: FormErrors<TInputs> = {};
|
||||
#elements: Record<string, HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> = {};
|
||||
|
||||
#onChange?: OnChangeCallback<TInputs>;
|
||||
#onProcessing?: OnProcessingCallback;
|
||||
#onError?: OnErrorCallback<TInputs>;
|
||||
#onSubmit?: OnSubmitCallback<TInputs>;
|
||||
#onResponse?: OnResponseCallback<any, any>;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Constructor
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
constructor(schema: TSchema, defaults: Partial<TInputs> = {}) {
|
||||
this.schema = z.object(schema);
|
||||
this.#defaults = defaults;
|
||||
this.#bindMethods();
|
||||
this.#setDefaults();
|
||||
this.#setSubmit();
|
||||
}
|
||||
|
||||
#bindMethods() {
|
||||
this.register = this.register.bind(this);
|
||||
this.set = this.set.bind(this);
|
||||
this.get = this.get.bind(this);
|
||||
this.validate = this.validate.bind(this);
|
||||
this.submit = this.submit.bind(this);
|
||||
}
|
||||
|
||||
#setDefaults() {
|
||||
for (const key in this.#defaults) {
|
||||
this.inputs[key] = this.#defaults[key] ?? ("" as any);
|
||||
}
|
||||
}
|
||||
|
||||
#setSubmit() {
|
||||
if ((this.constructor as any).submit !== undefined) {
|
||||
this.onSubmit((this.constructor as any).submit);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Accessors
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
get isValid(): boolean {
|
||||
return Object.keys(this.#getFormErrors()).length === 0;
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return Object.keys(this.errors).length !== 0;
|
||||
}
|
||||
|
||||
get errors(): FormErrors<TInputs> {
|
||||
return this.#errors;
|
||||
}
|
||||
|
||||
set errors(value: FormErrors<TInputs>) {
|
||||
this.#errors = value;
|
||||
this.#onError?.(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a input element with the form. This registers form related methods and a
|
||||
* reference to the element itself that can be utilized by the form.
|
||||
*
|
||||
* @param name - Name of the input field.
|
||||
*/
|
||||
register<TKey extends keyof TInputs>(name: TKey) {
|
||||
return {
|
||||
name,
|
||||
ref: (element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null) => {
|
||||
if (element !== null) {
|
||||
this.#elements[name as string] = element;
|
||||
}
|
||||
},
|
||||
defaultValue: this.get(name),
|
||||
onChange: ({ target: { value } }: any) => {
|
||||
this.set(name, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Registrars
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
onChange(callback: OnChangeCallback<TInputs>): this {
|
||||
this.#onChange = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
onProcessing(callback: OnProcessingCallback): this {
|
||||
this.#onProcessing = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
onError(callback: OnErrorCallback<TInputs>): this {
|
||||
this.#onError = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
onSubmit(callback: OnSubmitCallback<TInputs>): this {
|
||||
this.#onSubmit = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
onResponse<E, R>(callback: OnResponseCallback<E, R>): this {
|
||||
this.#onResponse = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Data
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set the value of an input field.
|
||||
*
|
||||
* @param name - Name of the input field.
|
||||
* @param value - Value to set.
|
||||
*/
|
||||
set<TKey extends keyof TInputs>(name: TKey, value: TInputs[TKey]): void {
|
||||
this.inputs[name] = value;
|
||||
this.#onChange?.(name, value);
|
||||
clearTimeout(this.#debounce.validate[name]);
|
||||
this.#debounce.validate[name] = setTimeout(() => {
|
||||
this.validate(name);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current input values or a specific input value.
|
||||
*
|
||||
* @param name - Name of the input field. _(Optional)_
|
||||
*/
|
||||
get(): Partial<TInputs>;
|
||||
get<TKey extends keyof TInputs>(name: TKey): TInputs[TKey] | undefined;
|
||||
get<TKey extends keyof TInputs>(name?: TKey): Partial<TInputs> | TInputs[TKey] | undefined {
|
||||
if (name === undefined) {
|
||||
return { ...this.inputs };
|
||||
}
|
||||
return this.inputs[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset form back to its default values.
|
||||
*/
|
||||
reset() {
|
||||
for (const key in this.inputs) {
|
||||
const value = this.#defaults[key] ?? "";
|
||||
(this.inputs as any)[key] = value;
|
||||
if (this.#elements[key] !== undefined) {
|
||||
(this.#elements as any)[key].value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Submission
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async submit(event: any) {
|
||||
event.preventDefault?.();
|
||||
this.#onProcessing?.(true);
|
||||
this.validate();
|
||||
if (this.hasError === false) {
|
||||
try {
|
||||
const response = await this.#onSubmit?.(this.schema.parse(this.inputs) as TInputs);
|
||||
this.#onResponse?.(undefined, response);
|
||||
} catch (error) {
|
||||
this.#onResponse?.(error, undefined as any);
|
||||
}
|
||||
}
|
||||
this.#onProcessing?.(false);
|
||||
this.reset();
|
||||
}
|
||||
|
||||
validate(name?: keyof TInputs) {
|
||||
if (name !== undefined) {
|
||||
this.#validateInput(name);
|
||||
} else {
|
||||
this.#validateForm();
|
||||
}
|
||||
}
|
||||
|
||||
#validateForm(): void {
|
||||
this.errors = this.#getFormErrors();
|
||||
}
|
||||
|
||||
#validateInput(name: keyof TInputs): void {
|
||||
const errors = this.#getFormErrors();
|
||||
let hasChanges = false;
|
||||
if (errors[name] === undefined && this.errors[name] !== undefined) {
|
||||
delete this.errors[name];
|
||||
hasChanges = true;
|
||||
}
|
||||
if (errors[name] !== undefined && this.errors[name] !== errors[name]) {
|
||||
this.errors[name] = errors[name];
|
||||
hasChanges = true;
|
||||
}
|
||||
if (hasChanges === true) {
|
||||
this.#onError?.({ ...this.errors });
|
||||
}
|
||||
}
|
||||
|
||||
#getFormErrors(): FormErrors<TInputs> {
|
||||
const result = this.schema.safeParse(this.inputs);
|
||||
if (result.success === false) {
|
||||
throw result.error.flatten;
|
||||
// return result.error.details.reduce<Partial<TInputs>>(
|
||||
// (error, next) => ({
|
||||
// ...error,
|
||||
// [next.path[0]]: next.message,
|
||||
// }),
|
||||
// {},
|
||||
// );
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type OnChangeCallback<TInputs, TKey extends keyof TInputs = keyof TInputs> = (name: TKey, value: TInputs[TKey]) => void;
|
||||
|
||||
type OnProcessingCallback = (value: boolean) => void;
|
||||
|
||||
type OnErrorCallback<TInputs> = (errors: FormErrors<TInputs>) => void;
|
||||
|
||||
type OnSubmitCallback<TInputs> = (inputs: TInputs) => Promise<any>;
|
||||
|
||||
type OnResponseCallback<Error, Response> = (err: Error, res: Response) => void;
|
||||
|
||||
type FormDebounce<TInputs> = {
|
||||
validate: {
|
||||
[TKey in keyof TInputs]?: any;
|
||||
};
|
||||
};
|
||||
|
||||
type FormErrors<TInputs> = {
|
||||
[TKey in keyof TInputs]?: string;
|
||||
};
|
||||
6
apps/react/src/libraries/utils.ts
Normal file
6
apps/react/src/libraries/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user