Page MenuHomeSealhub

table.ts
No OneTemporary

table.ts

/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-explicit-any */
import Router from "@koa/router";
import { hasShape, is, predicates } from "@sealcode/ts-predicates";
import { Context } from "koa";
import qs from "qs";
import { FlatTemplatable, tempstream } from "tempstream";
import { FormField } from "../fields/field.js";
import {
ExtractFormFieldParsed,
Table as TableField,
TableFieldParsed,
} from "../fields/table.js";
import { FormReaction } from "../form-types.js";
import { Form } from "../form.js";
import { FormControl, FormControlContext } from "./form-control.js";
export type TableControlOptions<F extends Record<string, FormField>> = {
render_fields: {
[field_name in keyof F]: (
fctx: FormControlContext,
name: string,
value: string
) => FlatTemplatable | Promise<FlatTemplatable>;
};
allow_removing?: boolean;
label_add?: string;
label: string;
label_remove?: string;
} & (
| { allow_adding: false }
| {
allow_adding: true;
make_new_row: (
ctx: Context
) => Promise<{ [field_name in keyof F]: unknown }>;
}
);
const TO_REPLACE = "____$$$$";
export class Table<F extends Record<string, FormField>> extends FormControl {
role = <const>"input";
constructor(
public table_field: TableField<F>,
public options: TableControlOptions<F>
) {
super();
}
async render(fctx: FormControlContext): Promise<FlatTemplatable> {
let { parsed: rows } = await this.table_field.getValue(
fctx.ctx,
fctx.data
);
if (!rows) {
rows = [];
}
const make_row = (
row:
| {
[field_name in keyof F]: ExtractFormFieldParsed<
F[field_name]
>;
}
| null,
row_index: number | string
) => {
return /* HTML */ tempstream`<tr>
${Object.entries(this.table_field.columns).map(([key]) => {
return tempstream`<td>${this.options.render_fields[key](
fctx,
`${this.table_field.name}[${row_index}][${key}]`,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(row?.[key] as string) || ""
)}</td>`;
})}
${
this.options.allow_removing
? /* HTML */ `<td>
<button
onclick="this.closest('tr').remove()"
>
remove
</button>
<noscript>
<input
type="submit"
data-turbo-frame="${this.getFrameID()}"
value="${this.options
.label_remove || "remove"}"
form="${fctx.form_id}"
formnovalidate
formaction="${this.getActionURL(
this.table_field.name,
{
remove: {
index: row_index,
},
}
)}"
/>
</noscript>
</td>`
: ""
}
</tr>`;
};
return tempstream/* HTML */ `<turbo-frame
id="${this.getFrameID()}"
class="${[
"form-input__wrapper",
"form-input__wrapper--type--table",
"form-input__wrapper--options-count--" + rows.length.toString(),
...(rows.length >= 5
? ["form-input__wrapper--options-count--5-or-more"]
: []),
...(rows.length >= 10
? ["form-input__wrapper--options-count--10-or-more"]
: []),
...(rows.length >= 15
? ["form-input__wrapper--options-count--15-or-more"]
: []),
...(rows.length >= 20
? ["form-input__wrapper--options-count--20-or-more"]
: []),
].join(" ")}"
>
<label>${this.options.label}</label>
<div class="table__wrapper">
<table>
<tbody>
${rows?.map((row, index) => make_row(row, index))}
</tbody>
</table>
<template> ${make_row(null, TO_REPLACE)} </template>
${
/* because of https://github.com/ljharb/qs/issues/252 we have to
put the indexes in the field names */
this.options.allow_adding
? /* HTML */ `<button
onclick="const template = this.closest('turbo-frame').querySelector('template');
const tbody = this.closest('turbo-frame').querySelector('tbody');
const orig_template = template.innerHTML;
template.innerHTML = orig_template.replaceAll('${TO_REPLACE}', tbody.querySelectorAll('tr').length);
tbody.appendChild(template.cloneNode(true).content);
template.innerHTML = orig_template;"
>
add
</button>
<noscript>
<input
type="submit"
value="${this.options.label_add ||
"add"}"
form="${fctx.form_id}"
formnovalidate
formaction="${this.getActionURL(
this.table_field.name,
{
insert: {
index: rows.length,
value: {},
},
}
)}"
/>
</noscript>`
: ""
}
</div>
</turbo-frame>`;
}
getFrameID() {
return `array-frame-${this.table_field.name}`;
}
getActionURL(field_name: string, action: Record<string, unknown>) {
return `./?${qs.stringify({
action,
field_name,
})}`;
}
mount(router: Router, form: Form<any, any>) {
router.post("/", async (ctx, next) => {
const action = ctx.$body.action;
const field_name = ctx.$body.field_name;
if (
!is(action, predicates.object) ||
!is(field_name, predicates.string)
) {
await next();
return;
}
if (
this.options.allow_adding &&
hasShape(
{
insert: predicates.shape({ index: predicates.string }),
},
action
)
) {
if (field_name !== this.table_field.name) {
await next();
return;
}
if (!ctx.$body[this.table_field.name]) {
ctx.$body[this.table_field.name] = {};
}
(ctx.$body[this.table_field.name] as any)[action.insert.index] =
await this.options.make_new_row(ctx);
}
if (
hasShape(
{
remove: predicates.shape({ index: predicates.string }),
},
action
)
) {
if (field_name !== this.table_field.name) {
await next();
return;
}
if (!ctx.$body[this.table_field.name]) {
ctx.$body[this.table_field.name] = {};
}
(ctx.$body[this.table_field.name] as any) = Object.fromEntries(
Object.entries(
Object.values(
ctx.$body[this.table_field.name] as any
).filter(
(_, index) => index != parseInt(action.remove.index)
)
)
);
}
ctx.override_reaction = {
action: "stay",
content: await form.render(
ctx,
{
raw_values: await form.extractRawValues(ctx),
messages: [],
},
false
),
} as FormReaction;
await next();
});
}
}

File Metadata

Mime Type
text/html
Expires
Sat, Sep 20, 14:17 (1 d, 22 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
928098
Default Alt Text
table.ts (6 KB)

Event Timeline