Admin improvements, editable navbar

This commit is contained in:
Kuba Orlik 2025-03-02 19:33:50 +01:00
parent 0bf41032ad
commit 8cb11bc2ff
13 changed files with 157 additions and 39 deletions

View File

@ -4,6 +4,7 @@ import { App } from "sealious";
import _Globals from "./globals.js";
import _GroupsToUsers from "./groups-to-users.js";
import _Groups from "./groups.js";
import _NavbarLinks from "./navbar-links.js";
import _Pages from "./pages.js";
import _PasswordResetIntents from "./password-reset-intents.js";
import _Redirects from "./redirects.js";
@ -13,6 +14,7 @@ import _Users from "./users.js";
export const Globals = new _Globals();
export const GroupsToUsers = new _GroupsToUsers();
export const Groups = new _Groups();
export const NavbarLinks = new _NavbarLinks();
export const Pages = new _Pages();
export const PasswordResetIntents = new _PasswordResetIntents();
export const Redirects = new _Redirects();
@ -24,6 +26,7 @@ export const collections = {
globals: Globals,
"groups-to-users": GroupsToUsers,
groups: Groups,
"navbar-links": NavbarLinks,
pages: Pages,
"password-reset-intents": PasswordResetIntents,
redirects: Redirects,

View File

@ -0,0 +1,9 @@
import { Collection, FieldTypes, Policies } from "sealious";
export default class NavbarLinks extends Collection {
fields = {
label: new FieldTypes.Text(),
href: new FieldTypes.Text(),
};
defaultPolicy = new Policies.Public();
}

View File

@ -1,5 +1,7 @@
import { Collection, FieldTypes, Policies } from "sealious";
import { App, Collection, FieldTypes, Policies } from "sealious";
import { CRUDRoles } from "../policy-types/roles.js";
import assert from "assert";
import TheApp from "../app.js";
export default class Pages extends Collection {
fields = {
@ -9,8 +11,23 @@ export default class Pages extends Collection {
title: new FieldTypes.Text(),
heading: new FieldTypes.Text(),
description: new FieldTypes.Text(),
imageForMetadata: new FieldTypes.Image().setRequired(true),
imageForMetadata: new FieldTypes.Image(),
hideNavigation: new FieldTypes.Boolean(),
};
policies = CRUDRoles(["pages"], { show: new Policies.Public() });
async init(app: App, name: string) {
assert(app instanceof TheApp);
await super.init(app, name);
app.on("started", async () => {
const all_pages = await app.collections.pages.suList().fetch();
if (all_pages.empty) {
await app.collections.pages.suCreate({
url: "/",
content: [],
title: "Homepage",
});
}
});
}
}

View File

@ -0,0 +1,36 @@
import type { Context } from "koa";
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";
export const actionName = "Admin";
export default new (class AdminPage extends Page {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async canAccess(_: Context) {
return { canAccess: true, message: "" };
}
async render(ctx: Context) {
if (!ctx.$context.user_id) {
ctx.redirect(LoginURL);
ctx.status = 303;
return "";
}
return html({
ctx: ctx,
title: "Admin",
body: (
<div>
{[
{ text: "Pages", href: PagesCRUDListURL },
{ text: "Navbar", href: NavbarLinksCRUDURL },
].map((e) => button({ ...e, variant: "accent1" }))}
</div>
),
description: "",
});
}
})();

View File

@ -1,23 +1,23 @@
import { withProdApp } from "../test_utils/with-prod-app.js";
import { VERY_LONG_TEST_TIMEOUT, webhintURL } from "../test_utils/webhint.js";
import { HomeURL } from "./urls.js";
import { AdminURL } from "./urls.js";
import { getBrowser } from "../test_utils/browser-creator.js";
import type { Browser, BrowserContext, Page } from "@playwright/test";
describe("Home webhint", () => {
describe("Admin webhint", () => {
it("doesn't crash", async function () {
return withProdApp(async ({ base_url, rest_api }) => {
await rest_api.get(HomeURL);
await webhintURL(base_url + HomeURL);
await rest_api.get(AdminURL);
await webhintURL(base_url + AdminURL);
// alternatively you can use webhintHTML for faster but less precise scans
// or for scanning responses of requests that use some form of authorization:
// const response = await rest_api.get(HomeURL);
// const response = await rest_api.get(AdminURL);
// await webhintHTML(response);
});
}).timeout(VERY_LONG_TEST_TIMEOUT);
});
describe("Home", () => {
describe("Admin", () => {
let page: Page;
let browser: Browser;
let context: BrowserContext;
@ -34,7 +34,7 @@ describe("Home", () => {
it("works as expected", async function () {
return withProdApp(async ({ base_url }) => {
await page.goto(base_url + HomeURL);
await page.goto(base_url + AdminURL);
});
}).timeout(VERY_LONG_TEST_TIMEOUT);
});

View File

@ -0,0 +1,66 @@
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 = <const>{
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<typeof NavbarLinks>
) => string | Promise<string>;
}[];
// 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: "NavbarLinks list",
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 () => {},
});

View File

@ -7,6 +7,7 @@ import html from "src/back/html.js";
import { registry } from "src/back/jdd-components/registry.js";
import { makeJDDContext } from "src/back/jdd-context.js";
import { defaultHead } from "src/back/defaultHead.js";
import { PagesCRUDListURL } from "src/back/routes/urls.js";
export const actionName = "EditPageContent";
@ -23,7 +24,8 @@ export default new (class JDDCreatePreviewPage extends EditJDDField<Pages> {
const item = await this.getItem(ctx);
return (
<div>
<h1>Edit pages: {item.id}</h1>{" "}
<a href={PagesCRUDListURL}> back to pages </a>
<h1>Edit page: {item.get("title")}</h1>{" "}
</div>
);
}

View File

@ -48,13 +48,14 @@ export default new (class PagesCRUDEditForm extends Form<typeof fields, void> {
throw new Error("Item with given id not found: " + id);
}
const imageForMetadata = item.get("imageForMetadata");
return {
url: item.get("url"),
domain: withFallback(item.get("domain"), ""),
title: withFallback(item.get("title"), ""),
heading: withFallback(item.get("heading"), ""),
description: withFallback(item.get("description"), ""),
imageForMetadata: { old: item.get("imageForMetadata") },
imageForMetadata: imageForMetadata ? { old: imageForMetadata } : "",
hideNavigation: withFallback(
item.get("hideNavigation"),
String(item.get("hideNavigation")),
@ -79,7 +80,7 @@ export default new (class PagesCRUDEditForm extends Form<typeof fields, void> {
}
const preparedImageForMetadata =
data.imageForMetadata.new || data.imageForMetadata.old;
data.imageForMetadata?.new || data.imageForMetadata?.old;
if (!preparedImageForMetadata) {
throw new Error("Missing field: imageForMetadata");
}

View File

@ -23,7 +23,7 @@ export default new (class JDDCreatePreviewPage extends EditJDDField<Pages> {
const item = await this.getItem(ctx);
return (
<div>
<h1>Edit pages: {item.id}</h1>{" "}
<h1>Edit page: {item.get("title")}</h1>{" "}
</div>
);
}

View File

@ -32,7 +32,7 @@ export default new (class PagesCRUDCreateForm extends Form<typeof fields, void>
}
const preparedImageForMetadata =
data.imageForMetadata.new || data.imageForMetadata.old;
data.imageForMetadata?.new || data.imageForMetadata?.old;
if (!preparedImageForMetadata) {
throw new Error("Missing field: imageForMetadata");
}

View File

@ -3,14 +3,13 @@ import type { FlatTemplatable } from "tempstream";
import { SignInURL, LogoutURL, PagesCRUDListURL } from "../urls.js";
export async function default_navbar(ctx: BaseContext): Promise<FlatTemplatable> {
const isLoggedIn = !!ctx.$context.session_id;
const linkData = isLoggedIn
? [
{ text: "Pages", url: PagesCRUDListURL },
{ text: "Logout", url: LogoutURL },
]
: [{ text: "Sign in", url: SignInURL }];
const { items: navbar_items } = await ctx.$app.collections["navbar-links"]
.list(ctx.$context)
.fetch();
const linkData = navbar_items.map((e) => ({
text: e.get("label"),
url: e.get("href"),
}));
const linksHTML = linkData
.map((link) =>

View File

@ -1,17 +0,0 @@
import type { Context } from "koa";
import { TempstreamJSX } from "tempstream";
import { Page } from "@sealcode/sealgen";
import html from "../html.js";
export const actionName = "Home";
export default new (class HomePage extends Page {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async canAccess(_: Context) {
return { canAccess: true, message: "" };
}
async render(ctx: Context) {
return html({ ctx: ctx, title: "Home", body: <div>Hello</div>, description: "" });
}
})();

View File

@ -20,7 +20,9 @@ interface ContextState {
export const customUrlView =
(app: TheApp) => async (ctx: Context & ContextState, next: Next) => {
try {
console.log("CUSTOM URL VIEW");
if (ctx.body) return;
console.log("doesn't have body");
const main_domain = new URL(BASE_URL).hostname;
const {
items: [page],