Page MenuHomeSealhub

form.ts
No OneTemporary

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<F> {
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<JSX.Element | Readable> {
return tempstream /* HTML */ `<div>${error.message}</div>`;
}
makeSubmitButton(): JSX.Element {
return /* HTML */ `<input
type="submit"
value="${this.submitButtonText}"
formaction="${this.action}"
${this.form_id ? `form="${this.form_id}"` : ""}
/>`;
}
async render(
ctx: Context,
data: FormData,
show_field_errors: boolean
): Promise<JSX.Element | Readable> {
return tempstream /* HTML */ `<div class="form-container">
${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()}
</div>`;
}
public async makeFormClasses(_ctx: Context): Promise<string[]> {
return [];
}
public makeOpenFormTag(ctx: Context): JSX.Element {
return tempstream`<form enctype="multipart/form-data" method="POST" id="${
this.form_id
}" action="${this.action}" class="${this.makeFormClasses(ctx).then(
(classes) => classes.join(" ")
)}" ${
this.useTurbo ? "" : `data-turbo="false"`
}></form><div class="form">`;
}
public makeCloseFormTag(): JSX.Element {
return `</div>`;
}
public async onValuesInvalid(
ctx: Context,
form_messages: FormMessage[],
field_errors: FieldMessages<keyof Fields>
): Promise<FormReaction> {
const messages =
form_messages.length ? form_messages : (
[<const>{ 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<FormReaction> {
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 = [<const>{ 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<SubmitResult>;
public async onSuccess(
ctx: Context,
_data: FormData,
_submitResult: SubmitResult
): Promise<FormReaction> {
const messages = [
<const>{ 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<FormReaction> {
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<Record<string, FormDataValue>> {
return Object.keys(ctx.$body).length ?
ctx.$body
: this.getInitialValues(ctx);
}
static initFieldNames(fields: Record<string, FormField>): void {
for (const [field_name, field] of Object.entries(fields)) {
field.setName(field_name);
}
}
}

File Metadata

Mime Type
text/x-java
Expires
Fri, Nov 28, 15:18 (53 m, 23 s)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1086280
Default Alt Text
form.ts (7 KB)

Event Timeline