import { ShapeToType } from "@sealcode/ts-predicates"; import { Context } from "koa"; import qs from "qs"; import { FlatTemplatable, Templatable, tempstream } from "tempstream"; import { FormControl } from "../forms/controls/controls.js"; import { FormField } from "../forms/fields/field.js"; import { NumberField } from "../forms/fields/number.js"; import { TextBasedSimpleField } from "../forms/fields/simple-form-field.js"; import { FormDataValue } from "../forms/form-types.js"; import { makeHiddenInputs } from "../make-hidden-inputs.js"; import { naturalNumbers, UrlWithNewParams } from "../utils/utils.js"; import { Page } from "./page.js"; import Router from "@koa/router"; export const BasePagePropsShape = {}; export type BasePageProps = ShapeToType; export const DEFAULT_ITEMS_PER_PAGE = 12; export const BaseListPageFields = { page: new NumberField(false, 1), itemsPerPage: new NumberField(false, DEFAULT_ITEMS_PER_PAGE), sort: new TextBasedSimpleField(false), }; export type ListSort = { field: string; order: "asc" | "desc" }; const SORT_SEPARATOR = ":"; function decodeSort(s: unknown): ListSort | null { if (typeof s !== "string" || !s.includes(SORT_SEPARATOR)) { return null; } let order = s.split(SORT_SEPARATOR)[1]; const field = s.split(SORT_SEPARATOR)[0]; if (order !== "asc" && order !== "desc") { order = "asc"; return { field, order: order as "asc", }; } else { return { field, order }; } } function encodeSort(field: string, order: "asc" | "desc"): string { return [field, order].join(SORT_SEPARATOR); } export abstract class ListPage< ItemType, F extends typeof BaseListPageFields, > extends Page { abstract getItems( ctx: Context, page: number, itemsPerPage: number | null, values: Record ): Promise; abstract getTotalPages( ctx: Context, itemsPerPage: number, values: Record ): Promise; abstract renderItem( ctx: Context, item: ItemType, index: number ): Promise; filterFields: Record = {}; filterControls: FormControl[] = []; init(path: string, router: Router): void { if (this.initialized) { return; } super.init(path, router); for (const [, field] of Object.entries(this.filterFields)) { void field.init(); } } renderListContainer(_: Context, content: Templatable): FlatTemplatable { return tempstream`
${content}
`; } async getPaginationConfig(ctx: Context) { const values = await this.extractRawValues(ctx); let { parsed: page } = await this.fields.page.getParsedValue( ctx, values ); if (!page) { page = 1; } const { parsed: itemsPerPage } = await this.fields.itemsPerPage.getParsedValue(ctx, values); return { page, itemsPerPage }; } async renderItems( ctx: Context, values?: Record, items?: ItemType[] ): Promise { if (!values) { values = await this.extractRawValues(ctx); } const { itemsPerPage, page } = await this.getPaginationConfig(ctx); const items_promise = this.getItems(ctx, page, itemsPerPage, values); return tempstream`${(items ? Promise.resolve(items) : items_promise ).then((items) => items.map((item, index) => this.renderItem(ctx, item, index)) )}`; } async renderPagination( ctx: Context, values: Record ): Promise { const { itemsPerPage, page } = await this.getPaginationConfig(ctx); const totalIems = await this.getTotalPages( ctx, itemsPerPage || DEFAULT_ITEMS_PER_PAGE, values ); return tempstream /* HTML */ `
${page > 1 ? this.renderPageButton(ctx, 1, "Pierwsza strona") : ""} ${page > 1 ? this.renderPageButton(ctx, page - 1, "Poprzednia strona") : ""}
${page < totalIems ? this.renderPageButton(ctx, page + 1, "Następna strona") : ""} ${page < totalIems ? this.renderPageButton(ctx, totalIems, "Ostatnia strona") : ""}
`; } private renderPageButton(ctx: Context, page: number, text: string) { return /* HTML */ `${text}`; } async getFilterValues(ctx: Context): Promise> { const filter = {} as Record; const raw_values = await this.extractRawValues(ctx); for (const [fieldname, field] of Object.entries(this.filterFields)) { // eslint-disable-next-line no-await-in-loop const { parsed } = await field.getParsedValue(ctx, raw_values); filter[fieldname] = field.mapToFilter(parsed); } return filter; } async getSort(ctx: Context): Promise { const { sort } = await this.extractRawValues(ctx); const decoded = decodeSort(sort); if (decoded === null) { return this.getDefaultSort(ctx); } else { return decoded; } } makeSortLink( ctx: Context, field: string, order: "asc" | "desc" | null ): string { const url = new URL(ctx.url, "https://example.com"); const params = qs.parse(url.search.slice(1)); if (order === null) { delete params.sort; } else { params.sort = encodeSort(field, order); } url.search = qs.stringify(params); return url.pathname + url.search; } async renderFilters(ctx: Context): Promise { const values = await this.extractRawValues(ctx); return tempstream /* HTML */ `
${makeHiddenInputs(ctx, this.fields, values, [ "page", ...Object.values(this.filterFields).map((f) => f.name), ])} ${this.filterControls.map((control) => { return control.render( this.makeFormControlContext( ctx, { raw_values: values, messages: [], field_messages: {}, }, false ) ); })}
`; } async renderHeading( ctx: Context, field: string, label = field ): Promise { const current_sort = await this.getSort(ctx); const current_order = current_sort?.field == field ? current_sort.order : null; const order = ( current_order === "asc" // cycles the sort order: null → "asc" → "desc" → null → ... ) ? "desc" : current_order === null ? "asc" : null; return /* HTML */ ` ${label} ${(current_order && (current_order == "asc" ? "↑" : "↓")) || ""} `; } getDefaultSort(_: Context): ListSort | null { return null; } renderTableHead( ctx: Context, fields: { field: string; label?: string }[] ): FlatTemplatable { return tempstream /* HTML */ ` ${fields.map(({ label, field }) => this.renderHeading(ctx, field, label) )} `; } async render(ctx: Context): Promise { const values = await this.extractRawValues(ctx); return tempstream`${this.renderPagination(ctx, values)} ${this.renderFilters(ctx)} ${this.renderListContainer(ctx, this.renderItems(ctx, values))}`; } }