import { Context } from "koa"; import { randomUUID } from "crypto"; import Router from "@koa/router"; import { tempstream } from "tempstream"; import { hasFieldOfType, hasShape, is, predicates, } from "@sealcode/ts-predicates"; import { FormData, FormDataValue, FormMessage, FormReaction, } from "./form-types.js"; import { Fields, MountableWithFields, PageErrorMessage, } from "../page/mountable-with-fields.js"; import { Readable } from "stream"; import { FormField } from "../index.js"; import { FieldMessages } from "./controls/form-control.js"; import { Errors as SealiousErrors } from "sealious"; export abstract class Form< F extends Fields, SubmitResult, > extends MountableWithFields { defaultSuccessMessage = "Done"; submitButtonText = "Wyślij"; action = "./"; useTurbo = true; form_id = randomUUID() as string; async canAccess( _: Context ): Promise<{ canAccess: boolean; message: string }> { return { canAccess: true, message: "" }; } async renderError( _: Context, error: PageErrorMessage ): Promise { return tempstream /* HTML */ `
${error.message}
`; } makeSubmitButton(): JSX.Element { return /* HTML */ ``; } async render( ctx: Context, data: FormData, show_field_errors: boolean ): Promise { return tempstream /* HTML */ `
${this.makeOpenFormTag(ctx)} ${!this.controls.some((control) => control.role == "messages") ? this.renderMessages(ctx, data) : ""} ${this.renderControls( this.makeFormControlContext(ctx, data, show_field_errors) )} ${this.controls.some((control) => control.role == "submit") ? "" : this.makeSubmitButton()} ${this.makeCloseFormTag()}
`; } public async makeFormClasses(_ctx: Context): Promise { return []; } public makeOpenFormTag(ctx: Context): JSX.Element { return tempstream`
`; } public makeCloseFormTag(): JSX.Element { return `
`; } public async onValuesInvalid( ctx: Context, form_messages: FormMessage[], field_errors: FieldMessages ): Promise { const messages = form_messages.length ? form_messages : ( [{ type: "error", text: "Some fields are invalid" }] ); return { action: "stay", content: await this.render( ctx, { raw_values: await this.extractRawValues(ctx), messages, field_messages: field_errors, }, true ), messages, }; } public async onError( ctx: Context, data: FormData, error: unknown ): Promise { let error_message = "Unknown error has occured"; let field_messages: FieldMessages = {}; if ( is(error, predicates.object) && hasShape({ message: predicates.string }, error) ) { error_message = error.message; } if (error instanceof SealiousErrors.FieldsError) { field_messages = Object.fromEntries( Object.entries(error.field_messages) .filter(([key]) => key in this.fields) .map(([key, value]) => { const message = value?.message || ""; return [key, { type: "error", message }]; }) ); for (const [key, value] of Object.entries(error.field_messages)) { if (!(key in this.fields)) { error_message += " · " + value?.message; } } } const messages = [{ type: "error", text: error_message }]; return { action: "stay", content: await this.render( ctx, { raw_values: data.raw_values, messages, field_messages, }, true ), }; } public abstract onSubmit( ctx: Context, data: FormData ): SubmitResult | Promise; public async onSuccess( ctx: Context, _data: FormData, _submitResult: SubmitResult ): Promise { const messages = [ { type: "success", text: this.defaultSuccessMessage }, ]; return { action: "stay", content: await this.render( ctx, { raw_values: await this.getInitialValues(ctx), messages, field_messages: {}, }, false ), messages, }; } async getRawValuesOnSuccess(ctx: Context) { return this.extractRawValues(ctx); } async handlePost(ctx: Context): Promise { const raw_values = await this.extractRawValues(ctx); const { valid, form_messages, field_errors } = await this.validate( ctx, raw_values ); if (!valid) { return this.onValuesInvalid(ctx, form_messages, field_errors); } try { ctx.status = 303; const result = await this.onSubmit(ctx, { raw_values, messages: [], field_messages: {}, }); return this.onSuccess( ctx, { raw_values: await this.getRawValuesOnSuccess(ctx), messages: [], field_messages: {}, }, result ); } catch (e: unknown) { // eslint-disable-next-line no-console console.dir(e, { depth: 5 }); const message = ( is(e, predicates.object) && hasFieldOfType(e, "message", predicates.string) ) ? e?.message : is(e, predicates.string) ? e : "Wystąpił błąd"; return this.onError( ctx, { raw_values, messages: [ { type: "error", text: message, }, ], field_messages: {}, }, e ); } } init(path: string, router: Router) { super.init(path, router); if (this.initialized) { return; } // for use with other subroutes or middlewares that individual controls // might register const subrouter = new Router(); for (const control of this.controls) { control.mount(subrouter, this); } router.use(path, subrouter.routes(), subrouter.allowedMethods()); } public mount(router: Router, path: string): void { 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, { raw_values: await this.extractRawValues(ctx), messages: [], field_messages: {}, }, false ); }); router.post(path, async (ctx) => { const reaction = (ctx.override_reaction as FormReaction | undefined) || (await this.handlePost(ctx)); if (reaction.action == "stay") { ctx.status = 422; ctx.body = reaction.content; } else if (reaction.action == "redirect") { ctx.status = 303; ctx.redirect(reaction.url); } }); } async extractRawValues( ctx: Context ): Promise> { return Object.keys(ctx.$body).length ? ctx.$body : this.getInitialValues(ctx); } static initFieldNames(fields: Record): void { for (const [field_name, field] of Object.entries(fields)) { field.setName(field_name); } } }