Page MenuHomeSealhub

form.ts
No OneTemporary

import { BaseContext } from "koa";
import Router from "@koa/router";
import { Templatable, tempstream } from "tempstream";
import { FormControl } from "./controls/controls";
import {
hasFieldOfType,
hasShape,
is,
predicates,
} from "@sealcode/ts-predicates";
import { Mountable, PageErrorMessage } from "../page/page";
import { FormField } from "./field";
export type FormDataPrimitive = string | string[] | undefined;
export type FormDataValue =
| FormDataPrimitive
| Record<string, FormDataPrimitive>;
export type FieldValueType<F extends FormField> = F extends FormField<infer R>
? R
: never;
export type FormFieldsToValues<F extends Record<string, FormField<keyof F>>> = {
[Property in keyof F]: FieldValueType<F[Property]>;
};
export type FormMessage = { type: "info" | "success" | "error"; text: string };
export type FormData<Fieldnames extends string = string> = {
raw_values: Record<Fieldnames, FormDataValue>;
messages: FormMessage[];
};
export abstract class Form<
Fields extends Record<string, FormField<unknown>> = Record<
string,
FormField<unknown>
>
> implements Mountable
{
abstract fields: Fields;
abstract controls: FormControl[];
defaultSuccessMessage = "Done";
submitButtonText = "Wyślij";
constructor() {
this.init();
}
init(): void {
for (const [name, field] of Object.entries(this.fields)) {
field.init(name);
}
}
async canAccess(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_: BaseContext
): Promise<{ canAccess: boolean; message: string }> {
return { canAccess: true, message: "" };
}
async renderError(
_: BaseContext,
error: PageErrorMessage
): Promise<Templatable> {
return tempstream/* HTML */ `<div>${error.message}</div>`;
}
async validate(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_: BaseContext,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
__: Record<string, unknown>
): Promise<{ valid: boolean; error: string }> {
return {
valid: true,
error: "",
};
}
private async _validate(
ctx: BaseContext,
values: Record<string, FormDataValue>
): Promise<{
valid: boolean;
errors: Record<keyof Fields | "form", string>;
}> {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const errors = {} as Record<keyof Fields | "form", string>;
let valid = true;
await Promise.all(
Object.values(this.fields).map(async (field) => {
const parsed_value = await field.parse(values[field.name]);
const { valid: fieldvalid, message: fieldmessage } =
await field._validate(ctx, parsed_value);
if (!fieldvalid) {
valid = false;
errors[field.name as keyof Fields | "form"] = fieldmessage;
}
})
);
const formValidationResult = await this.validate(ctx, values);
if (!formValidationResult.valid) {
valid = false;
errors["form" as keyof Fields | "form"] =
formValidationResult.error;
}
return { valid, errors };
}
async render(
ctx: BaseContext,
data: FormData,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_: string
): Promise<Templatable> {
return tempstream/* HTML */ `${this.makeFormTag(
`${ctx.URL.pathname}/`
)} ${
!this.controls.some((control) => control.role == "messages")
? this.renderMessages(ctx, data)
: ""
} ${this.renderControls(ctx, data)}<input type="submit" value="${
this.submitButtonText
}"/></form>`;
}
public renderMessages(
_: BaseContext,
data: FormData<keyof Fields>
): Templatable {
return tempstream/* HTML */ `<div class="form-messages">
${data.messages.map(
(message) =>
`<div class="form-message form-message--${message.type}">${message.text}</div>`
)}
</div>`;
}
public renderControls(
ctx: BaseContext,
data: FormData<keyof Fields>
): Templatable {
return tempstream/* HTML */ `${this.controls.map((control) =>
control.render(ctx, data.raw_values, data.messages)
)}`;
}
public makeFormTag(path: string) {
return `<form method="POST" action="${path}">`;
}
public async onValuesInvalid(ctx: BaseContext, form_path: string) {
ctx.status = 422;
ctx.body = await this.render(
ctx,
{
raw_values: ctx.$body,
messages: [{ type: "error", text: "Some fields are invalid" }],
},
form_path
);
}
public async onError(
ctx: BaseContext,
data: FormData,
form_path: string,
error: unknown
): Promise<void> {
ctx.status = 422;
let error_message = "Unknown error has occured";
if (
is(error, predicates.object) &&
hasShape({ message: predicates.string }, error)
) {
error_message = error.message;
}
ctx.body = await this.render(
ctx,
{
raw_values: data.raw_values,
messages: [{ type: "error", text: error_message }],
},
form_path
);
}
public abstract onSubmit(
ctx: BaseContext,
data: FormData
): void | Promise<void>;
public async onSuccess(ctx: BaseContext, form_path: string): Promise<void> {
ctx.body = await this.render(
ctx,
{
raw_values: ctx.$body,
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,
{ raw_values: {}, messages: [] },
path
);
});
router.post(path, async (ctx) => {
const { valid } = await this._validate(ctx, ctx.$body);
if (!valid) {
await this.onValuesInvalid(ctx, path);
return;
}
try {
await this.onSubmit(ctx, {
raw_values: ctx.$body,
messages: [],
});
await this.onSuccess(ctx, path);
} 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";
await this.onError(
ctx,
{
raw_values: ctx.$body,
messages: [
{
type: "error",
text: message,
},
],
},
path,
e
);
}
});
}
}

File Metadata

Mime Type
text/x-java
Expires
Sun, Nov 2, 16:30 (1 h, 13 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1030436
Default Alt Text
form.ts (6 KB)

Event Timeline