You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
232 lines
6.6 KiB
TypeScript
232 lines
6.6 KiB
TypeScript
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<Fieldnames extends string = string> = {
|
|
values: Record<Fieldnames, string | string[] | number>;
|
|
raw_values: Record<Fieldnames, string | string[] | number>;
|
|
errors: Partial<Record<Fieldnames, string>>;
|
|
messages: { type: "info" | "success" | "error"; text: string }[];
|
|
};
|
|
|
|
export default abstract class Form<Fieldnames extends string = string>
|
|
implements Mountable
|
|
{
|
|
abstract fields: FormField<Fieldnames>[];
|
|
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 */ `<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, unknown>
|
|
): Promise<{
|
|
valid: boolean;
|
|
errors: Record<Fieldnames | "form", string>;
|
|
}> {
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
const errors = {} as Record<Fieldnames | "form", string>;
|
|
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<Templatable> {
|
|
return tempstream/* HTML */ `${this.makeFormTag(`${ctx.URL.pathname}/`)} ${
|
|
!this.controls.some((control) => control.role == "messages")
|
|
? this.renderMessages(ctx, data)
|
|
: ""
|
|
} ${
|
|
data.errors.form !== undefined
|
|
? `<div class="form__error">${data.errors.form}</div>`
|
|
: ""
|
|
} ${this.renderControls(ctx, data)}<input type="submit" value="${
|
|
this.submitButtonText
|
|
}"/></form>`;
|
|
}
|
|
|
|
public renderMessages(_: BaseContext, data: FormData): 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): Templatable {
|
|
return tempstream/* HTML */ `${this.controls.map((control) =>
|
|
control.render(ctx, this.fields, data)
|
|
)}`;
|
|
}
|
|
|
|
public makeFormTag(path: string) {
|
|
return `<form method="POST" action="${path}">`;
|
|
}
|
|
|
|
private generateData(rawData: Record<string, unknown> = {}): 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<Fieldnames, string>;
|
|
// 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<string, string>,
|
|
};
|
|
}
|
|
|
|
public async onValuesInvalid(
|
|
ctx: BaseContext,
|
|
errors: Record<Fieldnames, string>,
|
|
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<Fieldnames, string | string[] | number>
|
|
): void | Promise<void>;
|
|
|
|
public async onSuccess(ctx: BaseContext, form_path: string): Promise<void> {
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
}
|