From cf165cd9de5070f1ad2f7cc403c227f77134021e Mon Sep 17 00:00:00 2001 From: Kuba Orlik Date: Mon, 3 Mar 2025 19:08:20 +0100 Subject: [PATCH] Misc UX fixes --- package-lock.json | 10 +- package.json | 2 +- src/back/routes/admin.page.tsx | 7 +- .../routes/admin/navbar/[id]/delete.page.tsx | 28 +++ .../routes/admin/navbar/[id]/edit.form.ts | 105 ++++++++++++ src/back/routes/admin/navbar/create.form.ts | 63 +++++++ src/back/routes/admin/navbar/index.list.tsx | 159 ++++++++++++++++++ src/back/routes/admin/navbar/index.page.ts | 66 -------- src/back/routes/admin/navbar/shared.ts | 18 ++ src/back/routes/admin/pages/[id]/edit.form.ts | 5 +- src/back/routes/admin/pages/create.form.ts | 5 +- src/back/routes/admin/pages/shared.ts | 5 +- src/back/routes/common/navbar.ts | 3 +- 13 files changed, 388 insertions(+), 88 deletions(-) create mode 100644 src/back/routes/admin/navbar/[id]/delete.page.tsx create mode 100644 src/back/routes/admin/navbar/[id]/edit.form.ts create mode 100644 src/back/routes/admin/navbar/create.form.ts create mode 100644 src/back/routes/admin/navbar/index.list.tsx delete mode 100644 src/back/routes/admin/navbar/index.page.ts create mode 100644 src/back/routes/admin/navbar/shared.ts diff --git a/package-lock.json b/package-lock.json index b4b05b0..de55747 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@sealcode/file-manager": "^1.0.2", "@sealcode/jdd": "^0.7.1", "@sealcode/jdd-editor": "^0.1.19", - "@sealcode/sealgen": "^0.17.24", + "@sealcode/sealgen": "^0.17.26", "@sealcode/simplemde": "^1.12.1", "@sealcode/ts-predicates": "^0.6.2", "@types/kill-port": "^2.0.0", @@ -1004,9 +1004,9 @@ "integrity": "sha512-pDsGlk2KokQkwzsJDBUWJFDRpEoxxth6TMQGDJyCTmWnd1Vn+cQb5moXDKaf7cXnWb9Y6QtdNX/fPzM/3RH2Cg==" }, "node_modules/@sealcode/sealgen": { - "version": "0.17.24", - "resolved": "https://registry.npmjs.org/@sealcode/sealgen/-/sealgen-0.17.24.tgz", - "integrity": "sha512-xj3BTX2b3uFaO0Fj71inNY282Y96JiiCDFJF4dCmgWQu+yOLlzaVKF7p4bSi1CoNN9eHsvwXUEYKoRfvz8Q9GQ==", + "version": "0.17.26", + "resolved": "https://registry.npmjs.org/@sealcode/sealgen/-/sealgen-0.17.26.tgz", + "integrity": "sha512-eehUUh+d3XW8F41d18q9tBuYEg9P21IfvovxFiM/p609OaplcZ4pUD+pma7zu/cMr+Xh85aw/gQUox5C9Obp4A==", "dependencies": { "@koa/router": "^12.0.1", "@sealcode/file-manager": "^1.0.2", @@ -1017,6 +1017,7 @@ "esbuild": "^0.20.0", "escape-goat": "^4.0.0", "google-fonts-helper": "^3.4.1", + "husky": "^9.1.7", "is-what": "^4.1.16", "js-convert-case": "^4.2.0", "json5": "^2.2.3", @@ -5131,7 +5132,6 @@ "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", - "dev": true, "license": "MIT", "bin": { "husky": "bin.js" diff --git a/package.json b/package.json index b22bde3..e9c51fa 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "@sealcode/file-manager": "^1.0.2", "@sealcode/jdd": "^0.7.1", "@sealcode/jdd-editor": "^0.1.19", - "@sealcode/sealgen": "^0.17.24", + "@sealcode/sealgen": "^0.17.26", "@sealcode/simplemde": "^1.12.1", "@sealcode/ts-predicates": "^0.6.2", "@types/kill-port": "^2.0.0", diff --git a/src/back/routes/admin.page.tsx b/src/back/routes/admin.page.tsx index 4c0c6a1..c328c9c 100644 --- a/src/back/routes/admin.page.tsx +++ b/src/back/routes/admin.page.tsx @@ -3,7 +3,7 @@ import { TempstreamJSX } from "tempstream"; import { Page } from "@sealcode/sealgen"; import html from "src/back/html.js"; import { button } from "../elements/button.js"; -import { LoginURL, NavbarLinksCRUDURL, PagesCRUDListURL } from "./urls.js"; +import { LoginURL, NavbarLinksCRUDListURL, PagesCRUDListURL } from "./urls.js"; export const actionName = "Admin"; @@ -24,9 +24,10 @@ export default new (class AdminPage extends Page { title: "Admin", body: (
+

Admin panel

{[ - { text: "Pages", href: PagesCRUDListURL }, - { text: "Navbar", href: NavbarLinksCRUDURL }, + { text: "Edit Pages", href: PagesCRUDListURL }, + { text: "Edit Navbar", href: NavbarLinksCRUDListURL }, ].map((e) => button({ ...e, variant: "accent1" }))}
), diff --git a/src/back/routes/admin/navbar/[id]/delete.page.tsx b/src/back/routes/admin/navbar/[id]/delete.page.tsx new file mode 100644 index 0000000..1567002 --- /dev/null +++ b/src/back/routes/admin/navbar/[id]/delete.page.tsx @@ -0,0 +1,28 @@ +import type { Context } from "koa"; +import { Mountable } from "@sealcode/sealgen"; +import type Router from "@koa/router"; + +import { NavbarLinks } from "../../../../collections/collections.js"; + +import { NavbarLinksCRUDListURL } from "../../../urls.js"; + +export const actionName = "NavbarLinksCRUDDelete"; + +export default new (class NavbarLinksCRUDDeleteRedirect extends Mountable { + canAccess = async (ctx: Context) => { + const policy = NavbarLinks.getPolicy("edit"); + const response = await policy.check(ctx.$context); + return { canAccess: response?.allowed || false, message: response?.reason || "" }; + }; + + mount(router: Router, path: string) { + router.post(path, async (ctx) => { + await ctx.$app.collections["navbar-links"].removeByID( + ctx.$context, + ctx.params.id! + ); + ctx.status = 302; + ctx.redirect(NavbarLinksCRUDListURL); + }); + } +})(); diff --git a/src/back/routes/admin/navbar/[id]/edit.form.ts b/src/back/routes/admin/navbar/[id]/edit.form.ts new file mode 100644 index 0000000..b3f49c8 --- /dev/null +++ b/src/back/routes/admin/navbar/[id]/edit.form.ts @@ -0,0 +1,105 @@ +import type { Context } from "koa"; +import type { FormData } from "@sealcode/sealgen"; +import { Form, Controls, fieldsToShape } from "@sealcode/sealgen"; +import html from "../../../../html.js"; + +import { NavbarLinksFormFields, NavbarLinksFormControls } from "../shared.js"; +import { NavbarLinks } from "../../../../collections/collections.js"; +import { NavbarLinksCRUDListURL } from "../../../urls.js"; +import { tempstream } from "tempstream"; + +import { withFallback } from "@sealcode/sealgen"; + +export const actionName = "NavbarLinksCRUDEdit"; + +const fields = { + ...NavbarLinksFormFields, +}; + +export const NavbarLinksCRUDEditShape = fieldsToShape(fields); + +export default new (class NavbarLinksCRUDEditForm extends Form { + defaultSuccessMessage = "Formularz wypełniony poprawnie"; + fields = fields; + + controls = [new Controls.FormHeader("Edit NavbarLinks"), ...NavbarLinksFormControls]; + + async getID(ctx: Context): Promise { + // trying to automatically guess which param is the one that represents the ID + // of the resource to edit. + // If sealgen got it wrong, just return the param name that should be used here instead + const param_name = "id"; + const id = ctx.params[param_name]; + if (!id) { + throw new Error("Missing URL parameter: " + param_name); + } + return id; + } + + async getInitialValues(ctx: Context) { + const id = await this.getID(ctx); + + const { + items: [item], + } = await ctx.$app.collections["navbar-links"] + .list(ctx.$context) + .ids([id]) + .attach({}) + .fetch(); + + if (!item) { + throw new Error("Item with given id not found: " + id); + } + + return { + label: withFallback(item.get("label"), ""), + href: withFallback(item.get("href"), ""), + }; + } + + async onSubmit(ctx: Context) { + const data = await this.getParsedValues(ctx); + const id = await this.getID(ctx); + const { + items: [item], + } = await ctx.$app.collections["navbar-links"] + .list(ctx.$context) + .ids([id]) + .fetch(); + + if (!item) { + throw new Error("Unknown id: " + id); + } + + if (!data) { + throw new Error("Error when parsing the form values"); + } + + item.setMultiple({ + label: data["label"] != null ? data["label"] : "", + href: data["href"] != null ? data["href"] : "", + }); + await item.save(ctx.$context); + } + + canAccess = async (ctx: Context) => { + const policy = NavbarLinks.getPolicy("edit"); + const response = await policy.check(ctx.$context); + return { canAccess: response?.allowed || false, message: response?.reason || "" }; + }; + + async render(ctx: Context, data: FormData, show_field_errors: boolean) { + return html({ + ctx, + title: "Edit NavbarLinks", + body: tempstream/* HTML */ `
+ ← Back to navbar-links list + ${await super.render(ctx, data, show_field_errors)} +
`, + description: "", + css_clumps: ["admin-forms"], + }); + } +})(); diff --git a/src/back/routes/admin/navbar/create.form.ts b/src/back/routes/admin/navbar/create.form.ts new file mode 100644 index 0000000..7154a92 --- /dev/null +++ b/src/back/routes/admin/navbar/create.form.ts @@ -0,0 +1,63 @@ +import type { Context } from "koa"; +import type { FormData } from "@sealcode/sealgen"; +import { Form, Controls, fieldsToShape } from "@sealcode/sealgen"; +import html from "../../../html.js"; + +import { NavbarLinksFormFields, NavbarLinksFormControls } from "./shared.js"; + +import { NavbarLinks } from "../../../collections/collections.js"; + +import { NavbarLinksCRUDListURL } from "../../urls.js"; + +import { tempstream } from "tempstream"; + +export const actionName = "NavbarLinksCRUDCreate"; + +const fields = { + ...NavbarLinksFormFields, +}; + +export const NavbarLinksCRUDCreateShape = fieldsToShape(fields); + +export default new (class NavbarLinksCRUDCreateForm extends Form { + defaultSuccessMessage = "Formularz wypełniony poprawnie"; + fields = fields; + + controls = [ + new Controls.FormHeader("Create NavbarLinks"), + ...NavbarLinksFormControls, + ]; + + async onSubmit(ctx: Context) { + const data = await this.getParsedValues(ctx); + if (!data) { + throw new Error("Error when parsing the form values"); + } + + await ctx.$app.collections["navbar-links"].create(ctx.$context, { + label: data["label"] != null ? data["label"] : "", + href: data["href"] != null ? data["href"] : "", + }); + } + + canAccess = async (ctx: Context) => { + const policy = NavbarLinks.getPolicy("create"); + const response = await policy.check(ctx.$context); + return { canAccess: response?.allowed || false, message: response?.reason || "" }; + }; + + async render(ctx: Context, data: FormData, show_field_errors: boolean) { + return html({ + ctx, + title: "Create navbar-links", + body: tempstream/* HTML */ `
+ ← Back to navbar-links list + ${await super.render(ctx, data, show_field_errors)} +
`, + description: "", + css_clumps: ["admin-forms"], + }); + } +})(); diff --git a/src/back/routes/admin/navbar/index.list.tsx b/src/back/routes/admin/navbar/index.list.tsx new file mode 100644 index 0000000..a92dc2a --- /dev/null +++ b/src/back/routes/admin/navbar/index.list.tsx @@ -0,0 +1,159 @@ +import type { Context } from "koa"; +import type { CollectionItem } from "sealious"; +import type { FlatTemplatable, Templatable } from "tempstream"; +import { TempstreamJSX, tempstream } from "tempstream"; +import { NavbarLinks } from "src/back/collections/collections.js"; +import html from "src/back/html.js"; +import type { ListFilterRender } from "@sealcode/sealgen"; +import { + SealiousItemListPage, + BaseListPageFields, + DefaultListFilters, +} from "@sealcode/sealgen"; +import qs from "qs"; + +import { + NavbarLinksCRUDCreateURL, + NavbarLinksCRUDEditURL, + NavbarLinksCRUDDeleteURL, + AdminURL, +} from "../../urls.js"; + +export const actionName = "NavbarLinksCRUDList"; + +// eslint-disable-next-line @typescript-eslint/consistent-type-assertions +const filterFields = [ + { field: "label", ...DefaultListFilters["text"] }, + { field: "href", ...DefaultListFilters["text"] }, +] as { + field: keyof (typeof NavbarLinks)["fields"]; + render?: ListFilterRender; + prepareValue?: (filter_value: unknown) => unknown; // set this function to change what filter value is passed to Sealious +}[]; + +// eslint-disable-next-line @typescript-eslint/consistent-type-assertions +const displayFields = [ + { field: "label", label: "label" }, + { field: "href", label: "href" }, +] as { + field: string; + label: string; + format?: ( + value: unknown, + item: CollectionItem + ) => FlatTemplatable; +}[]; + +export default new (class NavbarLinksCRUDListPage extends SealiousItemListPage< + typeof NavbarLinks, + typeof BaseListPageFields +> { + fields = BaseListPageFields; + + async renderFilters(ctx: Context): Promise { + const query_params = qs.parse(ctx.search.slice(1)); + query_params.page = "1"; + const filter_values = await super.getFilterValues(ctx); + return ( +
+ {Object.entries(query_params).map(([key, value]) => { + if (key == "filter") { + return ""; + } + // this is necessary to not lose any query params when the user changes the filter values + return ; + })} + {filterFields.map(({ field, render }) => { + if (!render) { + render = DefaultListFilters.fallback.render; + } + return ( + render( + filter_values[field] || "", + this.collection.fields[field] + ) || "" + ); + })} + +
+ ); + } + + async getFilterValues(ctx: Context) { + // adding opportunity to adjust the values for a given field filter before it's sent to Sealious + const values = await super.getFilterValues(ctx); + for (const filterField of filterFields) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const key = filterField.field as keyof typeof values; + if (key in values) { + const prepare_fn = filterField.prepareValue; + if (prepare_fn) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + values[key] = prepare_fn(values[key]) as any; + } + } + } + return values; + } + + async renderItem(ctx: Context, item: CollectionItem) { + return ( + + {displayFields.map(({ field, format }) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any + const value = item.get(field as any); + return {format ? format(value, item) : value}; + })} + + + + + ); + } + + renderListContainer(ctx: Context, content: Templatable): FlatTemplatable { + return ( + + {this.renderTableHead(ctx, displayFields)} + {content} + + ); + } + + renderTableHead( + ctx: Context, + fields: { field: string; label?: string }[] + ): FlatTemplatable { + return tempstream/* HTML */ ` + + ${fields.map(({ label, field }) => this.renderHeading(ctx, field, label))} + Actions + + `; + } + + async render(ctx: Context) { + return html({ + ctx, + title: "Navbar", + description: "", + body: ( + + ), + }); + } +})(NavbarLinks); diff --git a/src/back/routes/admin/navbar/index.page.ts b/src/back/routes/admin/navbar/index.page.ts deleted file mode 100644 index 1b7b38a..0000000 --- a/src/back/routes/admin/navbar/index.page.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { CRUD } from "@sealcode/crud-ui"; -import type { CollectionItem } from "sealious"; -import type { ListFilterRender } from "@sealcode/sealgen"; -import { Controls, DefaultListFilters, Fields } from "@sealcode/sealgen"; -import { NavbarLinks } from "src/back/collections/collections.js"; -import html from "src/back/html.js"; - -export const actionName = "NavbarLinksCRUD"; - -const edit_fields = { - label: new Fields.CollectionField( - NavbarLinks.fields.label.required, - NavbarLinks.fields.label - ), - href: new Fields.CollectionField( - NavbarLinks.fields.href.required, - NavbarLinks.fields.href - ), -}; - -const edit_controls = [ - new Controls.SimpleInput(edit_fields.label, { label: "label" }), - new Controls.SimpleInput(edit_fields.href, { label: "href" }), -]; - -// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -const fields_for_display = [ - { field: "label", label: "label" }, - { field: "href", label: "href" }, -] as { - field: keyof (typeof NavbarLinks)["fields"]; - label: string; - format?: ( - value: unknown, - item: CollectionItem - ) => string | Promise; -}[]; - -// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -const fields_for_filters = [ - { field: "label", ...DefaultListFilters["text"] }, - { field: "href", ...DefaultListFilters["text"] }, -] as { - field: keyof (typeof NavbarLinks)["fields"]; - render?: ListFilterRender; - prepareValue?: (filter_value: unknown) => unknown; // set this function to change what filter value is passed to Sealious -}[]; - -export default new CRUD({ - collection: NavbarLinks, - nice_collection_name: "NavbarLinks", - fields_for_display, - fields_for_filters, - html, - list_title: "Navbar Links", - edit_title: "Edit", - edit_button_text: "Edit", - delete_button_text: "Delete", - back_to_list_button_text: "← Back to NavbarLinks list", - edit_fields, - edit_controls, - form_value_to_sealious_value: {}, - sealious_value_to_form_value: {}, - post_edit: async () => {}, - post_create: async () => {}, -}); diff --git a/src/back/routes/admin/navbar/shared.ts b/src/back/routes/admin/navbar/shared.ts new file mode 100644 index 0000000..a8d6acf --- /dev/null +++ b/src/back/routes/admin/navbar/shared.ts @@ -0,0 +1,18 @@ +import { Controls, Fields } from "@sealcode/sealgen"; +import { NavbarLinks } from "../../../collections/collections.js"; + +export const NavbarLinksFormFields = { + label: new Fields.CollectionField( + NavbarLinks.fields.label.required, + NavbarLinks.fields.label + ), + href: new Fields.CollectionField( + NavbarLinks.fields.href.required, + NavbarLinks.fields.href + ), +}; + +export const NavbarLinksFormControls = [ + new Controls.SimpleInput(NavbarLinksFormFields.label, { label: "label" }), + new Controls.SimpleInput(NavbarLinksFormFields.href, { label: "href" }), +]; diff --git a/src/back/routes/admin/pages/[id]/edit.form.ts b/src/back/routes/admin/pages/[id]/edit.form.ts index b07061a..385f8b3 100644 --- a/src/back/routes/admin/pages/[id]/edit.form.ts +++ b/src/back/routes/admin/pages/[id]/edit.form.ts @@ -81,9 +81,6 @@ export default new (class PagesCRUDEditForm extends Form { const preparedImageForMetadata = data.imageForMetadata?.new || data.imageForMetadata?.old; - if (!preparedImageForMetadata) { - throw new Error("Missing field: imageForMetadata"); - } item.setMultiple({ url: data["url"], @@ -92,7 +89,7 @@ export default new (class PagesCRUDEditForm extends Form { title: data["title"] != null ? data["title"] : "", heading: data["heading"] != null ? data["heading"] : "", description: data["description"] != null ? data["description"] : "", - imageForMetadata: preparedImageForMetadata, + imageForMetadata: preparedImageForMetadata || undefined, hideNavigation: !!data.hideNavigation != null ? !!data.hideNavigation : false, }); await item.save(ctx.$context); diff --git a/src/back/routes/admin/pages/create.form.ts b/src/back/routes/admin/pages/create.form.ts index 6fed1e3..c0b6825 100644 --- a/src/back/routes/admin/pages/create.form.ts +++ b/src/back/routes/admin/pages/create.form.ts @@ -33,9 +33,6 @@ export default new (class PagesCRUDCreateForm extends Form const preparedImageForMetadata = data.imageForMetadata?.new || data.imageForMetadata?.old; - if (!preparedImageForMetadata) { - throw new Error("Missing field: imageForMetadata"); - } await ctx.$app.collections["pages"].create(ctx.$context, { url: data["url"], @@ -44,7 +41,7 @@ export default new (class PagesCRUDCreateForm extends Form title: data["title"] != null ? data["title"] : "", heading: data["heading"] != null ? data["heading"] : "", description: data["description"] != null ? data["description"] : "", - imageForMetadata: preparedImageForMetadata, + imageForMetadata: preparedImageForMetadata || undefined, hideNavigation: !!data.hideNavigation != null ? !!data.hideNavigation : false, }); } diff --git a/src/back/routes/admin/pages/shared.ts b/src/back/routes/admin/pages/shared.ts index 8c799f5..0ef10bf 100644 --- a/src/back/routes/admin/pages/shared.ts +++ b/src/back/routes/admin/pages/shared.ts @@ -16,10 +16,7 @@ export const PagesFormFields = { Pages.fields.description.required, Pages.fields.description ), - imageForMetadata: new Fields.File( - Pages.fields.imageForMetadata.required, - TheFileManager - ), + imageForMetadata: new Fields.File(false, TheFileManager), hideNavigation: new Fields.Boolean(Pages.fields.hideNavigation.required), }; diff --git a/src/back/routes/common/navbar.ts b/src/back/routes/common/navbar.ts index 29797aa..853ce3f 100644 --- a/src/back/routes/common/navbar.ts +++ b/src/back/routes/common/navbar.ts @@ -1,6 +1,7 @@ import type { BaseContext } from "koa"; import { button } from "src/back/elements/button.js"; -import { tempstream, type FlatTemplatable } from "tempstream"; +import { tempstream } from "tempstream"; +import type { FlatTemplatable } from "tempstream"; export async function default_navbar(ctx: BaseContext): Promise { const { items: navbar_items } = await ctx.$app.collections["navbar-links"]