import { BaseContext } from "koa"; import Router from "@koa/router"; import { Templatable, tempstream } from "tempstream"; import { FormControl } from "./controls"; import { hasShape, is, predicates } from "@sealcode/ts-predicates"; import { Mountable, PageErrorMessage } from "../page/page"; import { FormField } from "./field"; export type FormData = { values: Record; raw_values: Record; errors: Partial>; messages: { type: "info" | "success" | "error"; text: string }[]; }; export default abstract class Form implements Mountable { abstract fields: FormField[]; abstract controls: FormControl[]; defaultSuccessMessage = "Done"; submitButtonText = "Wyslij"; // eslint-disable-next-line @typescript-eslint/no-unused-vars async canAccess(_: BaseContext) { return { canAccess: true, message: "" }; } async renderError(_: BaseContext, error: PageErrorMessage) { return tempstream/* HTML */ `
${error.message}
`; } async validate( // eslint-disable-next-line @typescript-eslint/no-unused-vars _: BaseContext, // eslint-disable-next-line @typescript-eslint/no-unused-vars __: Record ): Promise<{ valid: boolean; error: string }> { return { valid: true, error: "", }; } private async _validate( ctx: BaseContext, values: Record ): Promise<{ valid: boolean; errors: Record; }> { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const errors = {} as Record; let valid = true; await Promise.all( this.fields.map(async (field) => { const { valid: fieldvalid, message: fieldmessage } = await field._validate(ctx, values[field.name]); if (!fieldvalid) { valid = false; errors[field.name] = fieldmessage; } }) ); const formValidationResult = await this.validate(ctx, values); if (!formValidationResult.valid) { valid = false; errors.form = formValidationResult.error; } return { valid, errors }; } async render( ctx: BaseContext, data: FormData, // eslint-disable-next-line @typescript-eslint/no-unused-vars form_path: string ): Promise { return tempstream/* HTML */ `${this.makeFormTag(`${ctx.URL.pathname}/`)} ${ !this.controls.some((control) => control.role == "messages") ? this.renderMessages(ctx, data) : "" } ${ data.errors.form !== undefined ? `
${data.errors.form}
` : "" } ${this.renderControls(ctx, data)}`; } public renderMessages(_: BaseContext, data: FormData): Templatable { return tempstream/* HTML */ `
${data.messages.map( (message) => `
${message.text}
` )}
`; } public renderControls(ctx: BaseContext, data: FormData): Templatable { return tempstream/* HTML */ `${this.controls.map((control) => control.render(ctx, this.fields, data) )}`; } public makeFormTag(path: string) { return `
`; } private generateData(rawData: Record = {}): FormData { // generates a FormData object that has the correct shape to be passed to // render(), so for example it makes sure that all fields either have values or // are empty string (the aren't undefined, for example). If no argument is passed, // creates an object that represents an empty state of the form. If some data // object is passed in the first argument, then the values in that data object are // incorporated into the generated object // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const values = Object.fromEntries( this.fields.map((f) => [f.name, rawData[f.name] || f.getEmptyValue()]) ) as Record; // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const errors = Object.fromEntries(this.fields.map((f) => [f.name, ""])) as Record< Fieldnames, string >; return { values, errors, messages: [], // eslint-disable-next-line @typescript-eslint/consistent-type-assertions raw_values: rawData as Record, }; } public async onValuesInvalid( ctx: BaseContext, errors: Record, form_path: string ) { ctx.status = 422; const { values, raw_values } = this.generateData(ctx.$body); ctx.body = await this.render( ctx, { values, raw_values, errors, messages: [{ type: "error", text: "Some fields are invalid" }], }, form_path ); } public async onError(ctx: BaseContext, error: unknown, form_path: string) { ctx.status = 422; let error_message = "Unknown error has occured"; if ( is(error, predicates.object) && hasShape({ message: predicates.string }, error) ) { error_message = error.message; } const { values, raw_values } = this.generateData(ctx.$body); ctx.body = await this.render( ctx, { values, raw_values, errors: {}, messages: [{ type: "error", text: error_message }], }, form_path ); } public abstract onSubmit( ctx: BaseContext, values: Record ): void | Promise; public async onSuccess(ctx: BaseContext, form_path: string): Promise { const { values, raw_values } = this.generateData(ctx.$body); ctx.body = await this.render( ctx, { values, raw_values, errors: {}, messages: [{ type: "success", text: this.defaultSuccessMessage }], }, form_path ); ctx.status = 422; } public mount(router: Router, path: string) { router.use(path, async (ctx, next) => { const result = await this.canAccess(ctx); if (!result.canAccess) { ctx.body = this.renderError(ctx, { type: "access", message: result.message, }); ctx.status = 403; return; } await next(); }); router.get(path, async (ctx) => { ctx.type = "html"; ctx.body = await this.render(ctx, this.generateData(), path); }); router.post(path, async (ctx) => { const { valid, errors } = await this._validate(ctx, ctx.$body); if (!valid) { await this.onValuesInvalid(ctx, errors, path); return; } try { await this.onSubmit(ctx, this.generateData(ctx.$body).values); await this.onSuccess(ctx, path); } catch (e) { // eslint-disable-next-line no-console console.dir(e, { depth: 5 }); await this.onError(ctx, e, path); } }); } }