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.

201 lines
6.1 KiB
TypeScript

import { BaseContext } from "koa";
import { Collection, CollectionItem } from "sealious";
import { Templatable, tempstream } from "tempstream";
import { peopleWhoCan } from "./access-control";
import { naturalNumbers, UrlWithNewParams } from "../util";
import { Page } from "./page";
import { predicates, ShapeToType } from "@sealcode/ts-predicates";
import { PagePropsParser } from "./props-parser";
import { FormFieldControl } from "../forms/controls";
import { FormField } from "../forms/field";
import { FormData } from "../forms/form";
import { FormFieldsList } from "../forms/form-fields-list";
export const BasePagePropsShape = <const>{};
export type BasePageProps = ShapeToType<typeof BasePagePropsShape>;
export const BaseListPagePropsShape = <const>{
page: predicates.number,
itemsPerPage: predicates.number,
};
export type BaseListPageProps = ShapeToType<typeof BaseListPagePropsShape>;
export const BaseListPageDefaultProps = { page: 1, itemsPerPage: 25 };
export type PropsErrors<PropsType> = Partial<Record<keyof PropsType, string>>;
export abstract class ListPage<
ItemType,
PropsType extends BaseListPageProps = BaseListPageProps
> extends Page {
abstract getItems(ctx: BaseContext, props: PropsType): Promise<{ items: ItemType[] }>;
abstract getTotalPages(ctx: BaseContext, props: PropsType): Promise<number>;
abstract renderItem(ctx: BaseContext, item: ItemType): Promise<Templatable>;
abstract propsParser: PagePropsParser<PropsType>;
filterFields: FormField<keyof PropsType>[] = [];
filterControls: FormFieldControl[] = [];
renderListContainer(_: BaseContext, content: Templatable): Templatable {
return tempstream`<div>${content}</div>`;
}
async validateProps(
ctx: BaseContext,
props: PropsType
): Promise<{ valid: boolean; errors: PropsErrors<PropsType> }> {
const errors: PropsErrors<PropsType> = {};
let has_errors = false;
const promises = [];
for (const [key, value] of Object.entries(props)) {
const field = FormFieldsList.getField(this.filterFields, key);
if (field) {
promises.push(
field._validate(ctx, value).then(({ valid, message }) => {
if (!valid) {
has_errors = true;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
errors[key as keyof PropsType] = message;
}
})
);
}
}
await Promise.all(promises);
return { valid: has_errors, errors };
}
async getProps(ctx: BaseContext): Promise<{
parsed_props: PropsType;
errors: PropsErrors<PropsType>;
raw_props: PropsType;
}> {
const raw_props = this.propsParser.decode(ctx);
const parsed_props = { ...raw_props };
const { errors } = await this.validateProps(ctx, parsed_props);
for (const prop_name in errors) {
const default_value = this.propsParser.getDefaultValue(prop_name);
if (default_value !== undefined) {
parsed_props[prop_name] = default_value;
} else {
delete parsed_props[prop_name];
}
}
return { parsed_props, errors, raw_props };
}
async render(ctx: BaseContext) {
const { parsed_props, errors, raw_props } = await this.getProps(ctx);
return tempstream`${this.renderPagination(ctx, parsed_props)}
${this.renderFilters(ctx, parsed_props, raw_props, errors)}
${this.getItems(ctx, parsed_props).then(({ items }) =>
this.renderListContainer(
ctx,
items.map((item) => this.renderItem(ctx, item))
)
)}`;
}
async renderPagination(ctx: BaseContext, props: PropsType) {
const totalIems = await this.getTotalPages(ctx, props);
const currentPage = props.page;
return tempstream/* HTML */ `<center>
${currentPage > 1 ? this.renderPageButton(ctx, 1, "Pierwsza strona") : ""}
${currentPage > 1
? this.renderPageButton(ctx, currentPage - 1, "Poprzednia strona")
: ""}
<select onchange="if (this.value) Turbo.visit(this.value)">
${Array.from(naturalNumbers(1, await this.getTotalPages(ctx, props))).map(
(n) => /* HTML */ `<option
value="${UrlWithNewParams(
ctx,
//eslint-disable-next-line @typescript-eslint/consistent-type-assertions
this.propsParser.overwriteProp(ctx, {
page: n,
} as Partial<PropsType>)
)}"
${currentPage === n ? "selected" : ""}
>
${n}
</option>`
)}
</select>
${currentPage < totalIems
? this.renderPageButton(ctx, currentPage + 1, "Następna strona")
: ""}
${currentPage < totalIems
? this.renderPageButton(ctx, totalIems, "Ostatnia strona")
: ""}
</center>`;
}
private renderPageButton(ctx: BaseContext, page: number, text: string) {
return /* HTML */ `<a
href="${UrlWithNewParams(
ctx,
//eslint-disable-next-line @typescript-eslint/consistent-type-assertions
this.propsParser.overwriteProp(ctx, {
page,
} as Partial<PropsType>)
)}"
>${text}</a
>`;
}
renderFilters(
ctx: BaseContext,
parsed_props: PropsType, // parsed props don't include wrong values
raw_props: PropsType,
errors: PropsErrors<PropsType>
) {
return tempstream/* HTML */ `<form method="GET">
${this.propsParser.makeHiddenInputs(parsed_props, [
"page",
...this.filterFields.map((f) => f.name),
])}
${this.filterControls.map((control) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
control.render(ctx, this.filterFields, {
values: parsed_props,
raw_values: raw_props,
errors,
messages: [],
} as FormData)
)}
</form>`;
}
}
export abstract class SealiousItemListPage<
C extends Collection,
PageProps extends BaseListPageProps = BaseListPageProps
> extends ListPage<CollectionItem<C>, PageProps> {
constructor(public collection: C) {
super();
}
async getTotalPages(ctx: BaseContext, props: PageProps) {
const { items } = await this.collection.list(ctx.$context).fetch();
return Math.ceil(items.length / props.itemsPerPage);
}
async getItems(ctx: BaseContext, props: PageProps) {
return {
items: (
await this.collection
.list(ctx.$context)
.paginate({ items: props.itemsPerPage, page: props.page })
.fetch()
).items,
};
}
async renderItem(_: BaseContext, item: CollectionItem<C>): Promise<Templatable> {
return `<div>${item.id}</div>`;
}
canAccess = peopleWhoCan("list", this.collection);
}