/* eslint-disable @typescript-eslint/no-explicit-any */ import Router from "@koa/router"; import { predicates, hasShape, is } from "@sealcode/ts-predicates"; import deepmerge, { ArrayMergeOptions } from "deepmerge"; import { Context } from "koa"; import { Templatable, tempstream } from "tempstream"; import { from_base64, to_base64 } from "../utils/base64.js"; import { Mountable } from "./mountable.js"; import { isPlainObject } from "is-what"; export type StatefulPageActionDescription = | { action: ActionName; label?: string; content?: string; disabled?: boolean; } | ActionName; export type StateAndMetadata = | { state: State; inputs: Record; action: keyof Actions; action_args: string; } | { state: State; inputs: Record; action: null; action_args: null; $: Record; }; export type StatefulPageActionArgument< State extends Record, Args extends unknown[] = unknown[], > = { ctx: Context; state: State; inputs: Record; args: Args; page: StatefulPage; }; export type StatefulPageAction< State extends Record, Args extends unknown[] = unknown[], > = (obj: StatefulPageActionArgument) => State | Promise; export type ExtractStatefulPageActionArgs = X extends StatefulPageAction ? Args : never; export abstract class StatefulPage< State extends Record, Actions extends Record>, > extends Mountable { abstract actions: Actions; abstract getInitialState(ctx: Context): State | Promise; abstract render( ctx: Context, state: State, inputs: Record ): Templatable | Promise; async canAccess( _ctx: Context ): Promise<{ canAccess: boolean; message: string }> { return { canAccess: true, message: "" }; } constructor() { super(); const original_render = this.render.bind(this) as typeof this.render; this.render = async ( ctx: Context, state: State, inputs: Record ) => { return this.wrapInLayout( ctx, await this.wrapInForm( ctx, state, await original_render(ctx, state, inputs) ), state ); }; } abstract wrapInLayout( ctx: Context, content: Templatable, state: State ): Templatable; async wrapInForm( context: Context, state: State, content: Templatable ): Promise { return tempstream /* HTML */ `
${content}
`; } extractActionAndLabel( action_description: StatefulPageActionDescription ): { action: string; label: string; content: string; disabled: boolean } { let label, action, content: string; let disabled: boolean; if (is(action_description, predicates.object)) { action = action_description.action.toString(); label = action_description.label || action; content = action_description.content || label; disabled = action_description.disabled || false; } else { action = action_description.toString(); label = action; content = label; disabled = false; } return { action, label, content, disabled }; } makeActionURL( action_description: StatefulPageActionDescription, ...args: ExtractStatefulPageActionArgs ) { const { action } = this.extractActionAndLabel(action_description); return `./?action=${action}&action_args=${encodeURIComponent( // encoding as URI Component because sometimes it can contain a "+" which is treated as a space to_base64(JSON.stringify(args)) )}`; } makeActionButton( _state: State, action_description: StatefulPageActionDescription, ...args: ExtractStatefulPageActionArgs ) { const { label, content, disabled } = this.extractActionAndLabel(action_description); return /* HTML */ ` `; } makeActionCallback( action_description: | { action: ActionName; label?: string; } | ActionName, ...args: ExtractStatefulPageActionArgs ) { return `(()=>{const form = this.closest('form'); form.action='${this.makeActionURL( action_description, ...args )}'; form.requestSubmit()})()`; } rerender() { return "this.closest('form').requestSubmit()"; } async preprocessState(values: State): Promise { return values; } async preprocessOverrides( _context: Context, _state: State, values: Record ): Promise> { return values; } async serializeState(_context: Context, state: State): Promise { return JSON.stringify(state); } async deserializeState(_context: Context, s: string): Promise { const deserialized = JSON.parse(s) as unknown; return deserialized as State; } async extractState( ctx: Context ): Promise> { if ( !hasShape( { action: predicates.maybe(predicates.string), state: predicates.string, action_args: predicates.maybe(predicates.string), $: predicates.maybe(predicates.object), }, ctx.$body ) ) { console.error("Wrong data: ", ctx.$body); throw new Error("wrong formdata shape"); } const inputs = Object.fromEntries( Object.entries(ctx.$body).filter( ([key]) => !["action", "state", "args", "$"].includes(key) ) ) as Record; // the "$" key is parsed as dot notation and overrides the state const original_state_string = ctx.$body.state; const original_state = await this.deserializeState( ctx, typeof original_state_string == "string" ? from_base64(original_state_string) : "{}" ); const $body = ctx.$body; let state_overrides = $body.$ || {}; state_overrides = await this.preprocessOverrides( ctx, original_state, state_overrides ); let modified_state = deepmerge(original_state, state_overrides, { isMergeableObject: (v) => isPlainObject(v) || Array.isArray(v), arrayMerge: ( target: any[], source: any[], options: ArrayMergeOptions ) => { // https://github.com/TehShrike/deepmerge?tab=readme-ov-file#arraymerge-example-combine-arrays const destination = target.slice(); /* eslint-disable @typescript-eslint/no-unsafe-argument */ source.forEach((item, index) => { if (typeof destination[index] === "undefined") { destination[index] = options.cloneUnlessOtherwiseSpecified( item, options ); } else if (options.isMergeableObject(item)) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment destination[index] = deepmerge( target[index], item, options ); } else if (target.indexOf(item) === -1) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment destination[index] = item; } }); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return destination; }, }) as State; // giving extending classes a change to modify the state before furhter processing modified_state = await this.preprocessState(modified_state); if (ctx.$body.action && ctx.$body.action_args) { return { state: modified_state, action: ctx.$body.action, inputs, action_args: ctx.$body.action_args, }; } else { return { state: modified_state, action: null, inputs, action_args: null, $: ctx.$body.$ || {}, }; } } mount(router: Router, path: string) { router.get(path, async (ctx) => { ctx.body = this.render(ctx, await this.getInitialState(ctx), {}); }); router.post(path, async (ctx) => { const { action, state, inputs, action_args } = await this.extractState(ctx); if (action) { const new_state = await this.actions[action]({ ctx, state, inputs, args: JSON.parse( from_base64(action_args as string) ) as unknown[], page: this, }); ctx.body = this.render(ctx, new_state, inputs); } else { ctx.body = this.render(ctx, state, inputs); } ctx.status = 422; }); } }