import { ShapeToType } from "@sealcode/ts-predicates"; import { Context } from "koa"; import { FlatTemplatable, tempstream } from "tempstream"; import { FieldMessages, FormControl, FormControlContext, } from "../forms/controls/form-control.js"; import type { FieldsToShape, FormField } from "../forms/fields/field.js"; import type { FormDataValue, FormMessage, FormData, } from "../forms/form-types.js"; import { Mountable } from "./mountable.js"; import Router from "@koa/router"; export type PageErrorMessage = { type: "access" | "internal"; message: string }; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Fields = Record>; type Resolved = T extends Promise ? X : never; type ParsedValue = Resolved< ReturnType >["parsed"]; export abstract class MountableWithFields< F extends Fields = Fields, > extends Mountable { getFields: () => F; fields: F; initialized = false; field_names_prefix = ""; // useful for multiform, where many forms are merged into one and field assignment is made using the prefix form_id = ""; // all fields within this mountable will be tied to form of this id abstract getControls: (fields: F) => FormControl[]; controls: FormControl[]; constructor() { super(); if (!this.fields) this.fields = {} as F; } /** most routes should be setup using .mount(), but mount runs before initialization so we don't yet have controls, app, etc ready for dynamic routes **/ init(path: string, router: Router): void { // we setup fields not on construction, but sometime after the app is // initialized, as some field constructors require app data to fulle // construct themselves super.init(path, router); if (this.initialized) { return; } if (this.getFields) { this.fields = this.getFields(); } else if (!this.fields) { throw new Error("Fields not defined and getter not provided"); } for (const [field_name, field] of Object.entries(this.fields)) { void field.init(); field.setName(field_name); } this.controls = this.getControls(this.fields); this.initialized = true; } makeFormControlContext( ctx: Context, data: FormData, validate: boolean, field_name_prefix = this.field_names_prefix, form_id = this.form_id ) { return new FormControlContext( ctx, data, data.messages, field_name_prefix, form_id, validate ); } // this one is meant to be overwritten async validateValues( _ctx: Context, _data: Record ): Promise<{ valid: boolean; error: string }> { return { valid: true, error: "", }; } async getInitialValues( _ctx: Context ): Promise> { return {}; } async validate( ctx: Context, values: Record ): Promise<{ valid: boolean; field_errors: FieldMessages; form_messages: FormMessage[]; }> { const field_errors = {} as FieldMessages; let valid = true; const form_messages = [] as FormMessage[]; await Promise.all( Object.keys(this.fields).map(async (key: keyof F) => { const field = this.fields[key]; const { valid: fieldvalid, message: fieldmessage } = await field.getParsedValue(ctx, values, true); if (!fieldvalid) { valid = false; field_errors[field.name] = { type: "error", message: fieldmessage, }; } }) ); const formValidationResult = await this.validateValues(ctx, values); if (!formValidationResult.valid) { form_messages.push({ type: "error", text: formValidationResult.error, }); valid = false; } return { valid, field_errors, form_messages }; } public renderControls(fctx: FormControlContext): FlatTemplatable { return tempstream /* HTML */ `${this.controls.map((control) => control.render(fctx) )}`; } async renderError( _: Context, error: PageErrorMessage ): Promise { return error.message; } public renderMessages( _: Context, data: FormData ): FlatTemplatable { return tempstream /* HTML */ `
${data.messages.map( (message) => `
${message.text}
` )}
`; } abstract extractRawValues( ctx: Context ): Promise>; async getParsedValues(ctx: Context): Promise<{ [field in keyof F]: F[field] extends FormField ? Exclude, null> : ParsedValue; }> { const raw_values = await this.extractRawValues(ctx); const result: Record = {}; const promises = Object.entries(this.fields).map( async ([key, field]) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const { parsed } = await field.getParsedValue( ctx, raw_values, false ); result[key] = parsed; } ); await Promise.all(promises); // TODO: remove this any. I don't have the strenght to deal with it now. // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return return result as any; } async getDatabaseValues( ctx: Context ): Promise>> { const raw_values = await this.extractRawValues(ctx); const result: Record = {}; const promises = Object.entries(this.fields).map( async ([key, field]) => { const db_value = await field.getDatabaseValue(ctx, raw_values); if (db_value !== undefined) { result[key] = db_value; } } ); await Promise.all(promises); return result as ShapeToType>; } }