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.

345 lines
9.3 KiB
TypeScript

import { is, predicates } from "@sealcode/ts-predicates";
import { BaseContext } from "koa";
import { Templatable, tempstream } from "tempstream";
import { ChekboxedListField, FormField, PickFromListField } from "./field";
import Form, { FormData } from "./form";
import { FormFieldsList } from "./form-fields-list";
export abstract class FormControl {
abstract render(
ctx: BaseContext,
formFields: FormField[],
data: FormData
): Templatable | Promise<Templatable>;
abstract role: "input" | "decoration" | "messages" | "submit";
}
export class FormHeader extends FormControl {
role = <const>"decoration";
constructor(
public text: string,
public isVisible: (ctx: BaseContext) => Promise<boolean> = async () => true
) {
super();
}
async render(ctx: BaseContext) {
const isVsbl = await this.isVisible(ctx);
return isVsbl ? `<h2>${this.text}</h2>` : "";
}
}
export class FormParagraph extends FormControl {
role = <const>"decoration";
constructor(public text: string) {
super();
}
render() {
return `<p>${this.text}</p>`;
}
}
export abstract class FormFieldControl extends FormControl {
role = <const>"input";
constructor(public fieldnames: string[]) {
super();
}
areFieldNamesValid(fields: FormField[]) {
return this.fieldnames.every((fieldname) =>
fields.some((f) => f.name == fieldname)
);
}
abstract _render(
ctx: BaseContext,
fields: FormField[],
data: FormData
): Templatable | Promise<Templatable>;
render(
ctx: BaseContext,
fields: FormField[],
data: FormData
): Templatable | Promise<Templatable> {
if (!this.areFieldNamesValid(fields)) {
throw new Error(
`Invalid field names given to form control: "${this.fieldnames.join(
", "
)}". Allowed fields are: ${fields.map((f) => f.name).join(", ")}`
);
}
return this._render(ctx, fields, data);
}
}
export class SimpleInput extends FormFieldControl {
constructor(
public fieldname: string,
public options: {
id?: string;
label?: string;
autocomplete?: boolean;
type?:
| "color"
| "date"
| "email"
| "file"
| "month"
| "number"
| "password"
| "search"
| "tel"
| "text"
| "time"
| "url"
| "week";
value?: string;
placeholder?: string;
readonly?: boolean;
step?: number;
} = {}
) {
super([fieldname]);
}
_render(_: BaseContext, fields: FormField[], data: FormData) {
const field = FormFieldsList.getField(fields, this.fieldname);
if (!field) {
throw new Error("wrong field name");
}
const id = this.options.id || field.name;
const label = this.options.label || field.name;
const type = this.options.type || "text";
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const value = data.values[field.name] as string;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const raw_value = data.raw_values[field.name] as string;
const placeholder = this.options.placeholder || type;
const readonly = this.options.readonly || false;
const required = field.required;
const error = data.errors[field.name];
return /* HTML */ `<div class="input">
<label for="${id}">${label}</label>
<input
id="${id}"
type="${type}"
name="${field.name}"
value="${value === undefined
? raw_value == undefined
? ""
: raw_value
: value}"
placeholder="${placeholder}"
${readonly ? "readonly" : ""}
${required ? "required" : ""}
${!this.options.autocomplete ? `autocomplete="off"` : ""}
${this.options.step ? `step="${this.options.step}"` : ""}
/>
${error ? `<div class="input__error">${error}</div>` : ""}
</div>`;
}
}
export class Dropdown extends FormFieldControl {
constructor(
public fieldname: string,
public options: {
label: string;
autosubmit?: boolean;
autocomplete?: boolean;
} = {
label: fieldname,
autosubmit: false,
autocomplete: true,
}
) {
super([fieldname]);
}
areFieldNamesValid(fields: FormField[]) {
return (
super.areFieldNamesValid(fields) &&
FormFieldsList.getField(fields, this.fieldname) instanceof PickFromListField
);
}
_render(ctx: BaseContext, fields: FormField[], data: FormData) {
// safe to disable this as isValidFieldName takes care of checking if the field is of this type
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const field = FormFieldsList.getField(
fields,
this.fieldname
) as PickFromListField;
const picked_value = data.values[field.name] || "";
const id = field.name;
return tempstream/* HTML */ `<label for="${id}">${this.options.label}</label
><select
name="${this.fieldnames}"
id="${id}"
${this.options.autosubmit ? `onchange='this.form.submit()'` : ""}
${!this.options.autocomplete ? `autocomplete="off"` : ""}
>
${Promise.resolve(field.generateOptions(ctx)).then((options) =>
Object.entries(options).map(
([value, text]) =>
`<option value="${value}" ${
(value || "") == picked_value ? "selected" : ""
}>${text}</option>`
)
)}
</select>`;
}
}
export class CheboxedListInput extends FormFieldControl {
constructor(
public fieldname: string,
public options: { label: string } = { label: fieldname }
) {
super([fieldname]);
}
isValidFieldName(form: Form) {
return form.fields.some((f) => f.name == this.fieldname);
}
async _render(ctx: BaseContext, fields: FormField[], data: FormData) {
// safe to disable this as isValidFieldName takes care of checking if the field is of this type
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const field = FormFieldsList.getField(
fields,
this.fieldname
) as ChekboxedListField;
const pickedValues = data.values[field.name] || "";
if (!is(pickedValues, predicates.array(predicates.string))) {
throw new Error("picked values is not an array of strings");
}
const [options, isVisible] = await Promise.all([
field.generateOptions(ctx),
field.isVisible(ctx),
]);
return tempstream/* HTML */ `${isVisible
? Object.entries(options).map(
([value, text]) => /* HTML */ `<div>
<input
type="checkbox"
id="${field.name}.${value}"
name="${field.name}.${value}"
${pickedValues.includes(value) ? "checked" : ""}
/>
<label for="${field.name}.${value}">${text}</label>
</div>`
)
: ""}`;
}
}
/**
* This class will render `turbo-frame` tag so that u can
* embed other route inside your form. This will require
* to add value to `data: FormData` (inside your master form
* render function) with key `Frame.FRAME_PATH_KEY`. Value
* needs to be url to route that you want to embed. If you
* this value wont be provided frame will redner empty string.
* See `src/back/routes/profile/[id].form.ts` for an example.
*/
export class Frame extends FormControl {
constructor(public src: string) {
super();
}
render(): Templatable | Promise<Templatable> {
return /* HTML */ `<turbo-frame
id="contrahents"
loading="lazy"
src="${this.src}"
></turbo-frame>`;
}
role = <const>"decoration";
}
/**
* This control has own forms in it so if you want to use it you
* probably shouldn't use `await super.render(ctx, data, path)` in
* render method of you from implementation and you should write
* your own implementation of this method. See forms that uses
* this control for reference.
*/
export class EditableCollectionSubset extends FormFieldControl {
constructor(
public fieldname: string,
public actionname: string,
public listLabel?: string,
public selectLabel?: string
) {
super([fieldname]);
}
_render(
ctx: BaseContext,
fields: FormField[],
data: FormData<string>
): Templatable | Promise<Templatable> {
// safe to disable this as isValidFieldName takes care of checking if the field is of this type
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const field = FormFieldsList.getField(
fields,
this.fieldname
) as PickFromListField;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const values = data.values[this.fieldname] as string[];
return tempstream/* HTML */ `<div>
<ul>
${Promise.resolve(field.generateOptions(ctx)).then((options) =>
Object.entries(options)
.filter(([value]) => values.includes(value))
.map(
([value, text]) => /* HTML */ `
<li>
<form method="POST" action="${ctx.path}">
<span>${text}</span>
<input
type="hidden"
name="${this.fieldname}"
value="${value}"
/>
<input
type="hidden"
name="${this.actionname}"
value="list"
/>
<input
type="submit"
${this.listLabel
? `value="${this.listLabel}"`
: ""}
/>
</form>
</li>
`
)
)}
</ul>
<form method="POST" action="${ctx.path}">
<select name="${this.fieldname}">
${Promise.resolve(field.generateOptions(ctx)).then((options) =>
Object.entries(options)
.filter(([value]) => !values.includes(value))
.map(
([value, text]) =>
`<option value="${value}">${text}</option>`
)
)}
</select>
<input type="hidden" name="${this.actionname}" value="select" />
<input
type="submit"
${this.selectLabel ? `value="${this.selectLabel}"` : ""}
/>
</form>
</div>`;
}
role = <const>"input";
}