Move reference plugin to examples/scheduling-plugin; plugins/ ships empty as a drop-in mount point, e2e bind-mounts the example
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# Scheduling — the reference plugin
|
||||
|
||||
A worked example of the [plugin contract](../../README.md#building-plugins). Copy this folder, rename
|
||||
it (the folder name becomes the plugin id and mount path), and point it at your own backend.
|
||||
|
||||
What it demonstrates:
|
||||
|
||||
- **A list page that fetches upstream data** — `GET /scheduling/shifts` calls the upstream REST
|
||||
service and renders the rows with the core building blocks (`shifts.ejs` → app shell, filter-bar,
|
||||
data-table). Search round-trips the URL; zero-JS. (It fetches **all** rows for brevity — for a
|
||||
large list, parse `page`/`pageSize` from `parseListQuery`, forward them upstream as a `?limit`/
|
||||
`?offset`, and render `pagination.ejs` with `paginate()`, exactly as the built-in admin screens do.)
|
||||
- **A form that forwards a write upstream** — `GET /scheduling/shifts/new` renders the form,
|
||||
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
|
||||
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
|
||||
reusing the core `field` partial.
|
||||
- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
|
||||
`scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant.
|
||||
|
||||
The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and
|
||||
`fetch` is injectable, so they unit-test as pure functions (`shifts.test.ts`).
|
||||
|
||||
## Upstream
|
||||
|
||||
Set `SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory
|
||||
mock (`examples/shifts-upstream/`) so `docker compose up` shows the plugin working out of the box.
|
||||
A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook).
|
||||
|
||||
### Upstream contract
|
||||
|
||||
Your backend must expose two routes; the plugin treats any non-2xx as a recoverable failure
|
||||
(the list degrades to a "try again" alert, the create re-renders the form keeping the input).
|
||||
|
||||
| Route | Request | Success | Response body |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /shifts` | `Accept: application/json` | `200` | JSON array of `{ id, title, assignee, start, end }` (all strings; missing fields coerce to `""`) |
|
||||
| `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) |
|
||||
|
||||
Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the
|
||||
form re-renders. The plugin only validates that `title` and `assignee` are non-empty.
|
||||
|
||||
`start`/`end` come from the form's `datetime-local` inputs as `YYYY-MM-DDTHH:mm` and are stored and
|
||||
shown verbatim (the dev mock seeds a space-separated style, so created vs seeded rows differ only
|
||||
cosmetically) — normalise to your backend's format there if it matters.
|
||||
|
||||
## Granting access
|
||||
|
||||
A user sees Scheduling once they hold the `scheduling:read` role in Keto (and `scheduling:write`
|
||||
to create). The one-command bootstrap grants both to the demo admin, so the seeded
|
||||
`admin@plainpages.local` can use it immediately.
|
||||
@@ -0,0 +1,47 @@
|
||||
// Reference plugin: a worked example of the contract — a list page that fetches upstream
|
||||
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this
|
||||
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
|
||||
|
||||
import { definePlugin } from "../../src/plugin-host/plugin-api.ts";
|
||||
import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
||||
|
||||
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
|
||||
// stateless). Configure via env; the dev compose points it at a tiny mock (examples/shifts-upstream).
|
||||
const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstream:4000";
|
||||
const upstream = createUpstream(upstreamUrl);
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
|
||||
// onBoot runs after discovery, before the server listens: validate the plugin's own config so a
|
||||
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
|
||||
hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") },
|
||||
|
||||
// Merged into the global menu + filtered per user. "Overview" is `public`, so the "Scheduling"
|
||||
// header shows for everyone (even signed out); "Shifts" needs `scheduling:read`, so the gated data
|
||||
// stays hidden until a reader signs in (a plugin may make a page + its menu option public).
|
||||
nav: [{
|
||||
children: [
|
||||
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true },
|
||||
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ },
|
||||
],
|
||||
icon: "i-cal",
|
||||
id: "scheduling",
|
||||
label: "Scheduling",
|
||||
}],
|
||||
|
||||
// Tokens this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
|
||||
permissions: [
|
||||
{ description: "View shifts", token: READ },
|
||||
{ description: "Create and edit shifts", token: WRITE },
|
||||
],
|
||||
|
||||
// Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public`
|
||||
// (anyone may reach /scheduling, signed in or not); the rest need a role.
|
||||
routes: [
|
||||
{ handler: overview(), method: "GET", path: "/", public: true },
|
||||
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
|
||||
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
|
||||
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/* Scheduling plugin styles — served at /public/scheduling/scheduling.css (per-plugin static),
|
||||
linked via the shell `styles` slot. The core design system already styles every building block,
|
||||
so a plugin only adds what's domain-specific, scoped under its own page class (.scheduling-page). */
|
||||
.scheduling-page .table-wrap {
|
||||
margin-top: var(--space-3, 0.75rem);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import assert from "node:assert/strict";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
import test from "node:test";
|
||||
// Import only from the plugin-api barrel — the same contract boundary shifts.ts uses (the host may
|
||||
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
|
||||
import { GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "../../src/plugin-host/plugin-api.ts";
|
||||
import {
|
||||
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
||||
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
|
||||
} from "./shifts.ts";
|
||||
|
||||
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
|
||||
|
||||
function fakeCtx(opts: { body?: string; roles?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||
return {
|
||||
chrome: CHROME, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
||||
roles: opts.roles ?? [], url, user: null, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
};
|
||||
}
|
||||
|
||||
const SHIFTS: Shift[] = [
|
||||
{ assignee: "Avery Kline", end: "12:00", id: "1", start: "08:00", title: "Morning desk" },
|
||||
{ assignee: "Blair Mora", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" },
|
||||
];
|
||||
const fakeUpstream = (over: Partial<ShiftsUpstream> = {}): ShiftsUpstream => ({ create: async () => {}, list: async () => SHIFTS, ...over });
|
||||
|
||||
const asView = (r: RouteResult | void) => {
|
||||
assert.ok(r && "view" in r, "expected a view result");
|
||||
return r as { data: Record<string, unknown>; status?: number; view: string };
|
||||
};
|
||||
|
||||
// ---- upstream config validation (the onBoot hook) ----
|
||||
|
||||
test("assertHttpUrl accepts http(s) and fails loud on a malformed or non-http upstream URL", () => {
|
||||
assert.doesNotThrow(() => assertHttpUrl("http://shifts-upstream:4000", "SCHEDULING_UPSTREAM"));
|
||||
assert.doesNotThrow(() => assertHttpUrl("https://api.example.com/v1", "SCHEDULING_UPSTREAM"));
|
||||
assert.throws(() => assertHttpUrl("not a url", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*valid URL/); // unparseable
|
||||
assert.throws(() => assertHttpUrl("shifts-upstream:4000", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // missing // → parsed as a bogus scheme
|
||||
assert.throws(() => assertHttpUrl("ftp://host/x", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // wrong scheme
|
||||
});
|
||||
|
||||
test("the manifest's onBoot hook validates SCHEDULING_UPSTREAM (the binding, not just the helper)", async () => {
|
||||
const prev = process.env["SCHEDULING_UPSTREAM"];
|
||||
process.env["SCHEDULING_UPSTREAM"] = "nope://bad"; // read at import time below
|
||||
try {
|
||||
const manifest = (await import("./plugin.ts")).default;
|
||||
assert.equal(typeof manifest.hooks?.onBoot, "function");
|
||||
assert.throws(() => manifest.hooks!.onBoot!(), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"];
|
||||
else process.env["SCHEDULING_UPSTREAM"] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- upstream client (fetch injected) ----
|
||||
|
||||
test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", async () => {
|
||||
let seen = "";
|
||||
const http = (async (url, init) => {
|
||||
seen = String(url);
|
||||
assert.equal((init?.headers as Record<string, string>).accept, "application/json");
|
||||
return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
const shifts = await createUpstream("http://up:4000/", http).list(); // trailing slash trimmed
|
||||
assert.equal(seen, "http://up:4000/shifts");
|
||||
assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]);
|
||||
});
|
||||
|
||||
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
|
||||
const http = (async () => new Response("nope", { status: 503 })) as typeof fetch;
|
||||
await assert.rejects(createUpstream("http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503);
|
||||
});
|
||||
|
||||
test("createUpstream.create POSTs the input as JSON", async () => {
|
||||
let body: unknown, method = "";
|
||||
const http = (async (_url, init) => { method = init?.method ?? ""; body = JSON.parse(String(init?.body)); return new Response(null, { status: 201 }); }) as typeof fetch;
|
||||
const input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" };
|
||||
await createUpstream("http://up:4000", http).create(input);
|
||||
assert.equal(method, "POST");
|
||||
assert.deepEqual(body, input);
|
||||
});
|
||||
|
||||
// ---- input + validation ----
|
||||
|
||||
test("readInput trims; validate requires title + assignee", () => {
|
||||
assert.deepEqual(readInput(new URLSearchParams("title=%20Shift%20&assignee=Bo&start=1&end=2")), { assignee: "Bo", end: "2", start: "1", title: "Shift" });
|
||||
assert.equal(validate({ assignee: "Bo", end: "", start: "", title: "Shift" }), null);
|
||||
assert.deepEqual(Object.keys(validate({ assignee: "", end: "", start: "", title: "" }) ?? {}), ["title", "assignee"]);
|
||||
});
|
||||
|
||||
// ---- list handler ----
|
||||
|
||||
test("listShifts renders the upstream rows; q filters; canWrite reflects the role", async () => {
|
||||
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ roles: ["scheduling:write"] })));
|
||||
assert.equal(r.view, "shifts");
|
||||
const table = r.data["table"] as { rows: { name: string }[] };
|
||||
assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]);
|
||||
assert.equal(r.data["canWrite"], true);
|
||||
assert.equal(r.data["chrome"], CHROME);
|
||||
|
||||
const filtered = asView(await listShifts(fakeUpstream())(fakeCtx({ url: "http://localhost/scheduling/shifts?q=afternoon" })));
|
||||
assert.deepEqual((filtered.data["table"] as { rows: { name: string }[] }).rows.map((x) => x.name), ["Afternoon support"]);
|
||||
assert.equal(filtered.data["canWrite"], false); // no scheduling:write
|
||||
});
|
||||
|
||||
test("listShifts degrades to a recoverable error page when the upstream is down (no throw)", async () => {
|
||||
const r = asView(await listShifts(fakeUpstream({ list: async () => { throw new UpstreamError("down", 503); } }))(fakeCtx()));
|
||||
assert.match(String(r.data["error"]), /scheduling service/i);
|
||||
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
|
||||
});
|
||||
|
||||
// ---- public overview handler (a page anyone can reach, gated data stays behind the role) ----
|
||||
|
||||
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
|
||||
const anon = asView(await overview()(fakeCtx())); // user null, no roles
|
||||
assert.equal(anon.view, "overview");
|
||||
assert.equal(anon.data["chrome"], CHROME);
|
||||
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
|
||||
|
||||
const reader = asView(await overview()(fakeCtx({ roles: ["scheduling:read"] })));
|
||||
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
|
||||
});
|
||||
|
||||
// ---- create handler ----
|
||||
|
||||
test("newShiftForm renders the empty form", async () => {
|
||||
const r = asView(await newShiftForm()(fakeCtx()));
|
||||
assert.equal(r.view, "shift-new");
|
||||
assert.equal((r.data["form"] as { csrfToken: string }).csrfToken, "tok");
|
||||
});
|
||||
|
||||
test("createShift rejects a bad CSRF token with a 403 GuardError", async () => {
|
||||
await assert.rejects(
|
||||
async () => { await createShift(fakeUpstream())(fakeCtx({ body: "title=T&assignee=A", verifyCsrf: () => false })); },
|
||||
(e: unknown) => e instanceof GuardError && e.status === 403,
|
||||
);
|
||||
});
|
||||
|
||||
test("createShift re-renders the form (400) on a validation error, never touching the upstream", async () => {
|
||||
let created = false;
|
||||
const r = asView(await createShift(fakeUpstream({ create: async () => { created = true; } }))(fakeCtx({ body: "title=&assignee=" })));
|
||||
assert.equal(r.status, 400);
|
||||
assert.equal(r.view, "shift-new");
|
||||
assert.equal(created, false);
|
||||
});
|
||||
|
||||
test("createShift forwards a valid write upstream then POST-redirect-GETs", async () => {
|
||||
let got: ShiftInput | undefined;
|
||||
const r = await createShift(fakeUpstream({ create: async (i) => { got = i; } }))(fakeCtx({ body: "title=Night&assignee=Casey&start=22%3A00&end=06%3A00" }));
|
||||
assert.deepEqual(got, { assignee: "Casey", end: "06:00", start: "22:00", title: "Night" });
|
||||
assert.deepEqual(r, { redirect: SHIFTS_PATH });
|
||||
});
|
||||
|
||||
test("createShift surfaces an upstream failure as a recoverable 502 form, keeping the input", async () => {
|
||||
const r = asView(await createShift(fakeUpstream({ create: async () => { throw new UpstreamError("boom", 500); } }))(fakeCtx({ body: "title=Night&assignee=Casey" })));
|
||||
assert.equal(r.status, 502);
|
||||
assert.match(String(r.data["formError"]), /unavailable/i);
|
||||
const fields = (r.data["form"] as { fields: { name: string; value: string }[] }).fields;
|
||||
assert.equal(fields.find((f) => f.name === "title")?.value, "Night"); // input preserved for retry
|
||||
});
|
||||
|
||||
test("buildFormModel marks title/assignee required and attaches field errors", () => {
|
||||
const model = buildFormModel({ chrome: CHROME, errors: { title: "needed" }, values: { title: "x" } });
|
||||
const fields = model.form.fields as { error?: string; name: string; required?: boolean; value: string }[];
|
||||
const title = fields.find((f) => f.name === "title")!;
|
||||
assert.equal(title.required, true);
|
||||
assert.equal(title.error, "needed");
|
||||
assert.equal(fields.find((f) => f.name === "start")!.required, undefined);
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
// Reference plugin — Scheduling/Shifts handlers + the upstream client. Shows the blessed
|
||||
// shape: a thin handler parses ctx, calls an upstream REST service, and returns a RouteResult the
|
||||
// host renders. The plugin holds no state of its own (README "Stateless") — data lives upstream.
|
||||
//
|
||||
// Handlers are factories bound to a ShiftsUpstream, and `fetch` is injectable, so they unit-test as
|
||||
// pure functions against a mock upstream with no network (README.md → Local dev & test story).
|
||||
|
||||
// One import from the host's plugin-api barrel — the stable author surface (see README.md → Building plugins).
|
||||
import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, tracedFetch } from "../../src/plugin-host/plugin-api.ts";
|
||||
|
||||
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
|
||||
export const SHIFTS_PATH = "/scheduling/shifts";
|
||||
export const READ = "scheduling:read"; // permission token gating the list + nav
|
||||
export const WRITE = "scheduling:write"; // permission token gating create
|
||||
|
||||
export interface Shift {
|
||||
id: string;
|
||||
assignee: string;
|
||||
end: string;
|
||||
start: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface ShiftInput {
|
||||
assignee: string;
|
||||
end: string;
|
||||
start: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
// Thrown when the upstream errors; the handler degrades to a recoverable page, never a host 500.
|
||||
export class UpstreamError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "UpstreamError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ShiftsUpstream {
|
||||
create(input: ShiftInput): Promise<void>;
|
||||
list(): Promise<Shift[]>;
|
||||
}
|
||||
|
||||
// Fail loud at boot (the plugin's onBoot hook) on a malformed/non-http upstream URL — a config
|
||||
// typo surfaces at startup, not as a degraded page later. Reachability stays a runtime concern.
|
||||
export function assertHttpUrl(value: string, name: string): void {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`${name} is not a valid URL: ${JSON.stringify(value)}`);
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${name} must be an http(s) URL: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch`
|
||||
// defaults to the host's tracedFetch, so each upstream call joins the request's trace (a client
|
||||
// span + a propagated traceparent); it's injectable so handlers unit-test against a mock, no network.
|
||||
export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
|
||||
const base = baseUrl.replace(/\/+$/, "");
|
||||
return {
|
||||
async create(input) {
|
||||
const res = await fetchImpl(`${base}/shifts`, {
|
||||
body: JSON.stringify(input),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
|
||||
},
|
||||
async list() {
|
||||
const res = await fetchImpl(`${base}/shifts`, { headers: { accept: "application/json" } });
|
||||
if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status);
|
||||
const data: unknown = await res.json();
|
||||
return Array.isArray(data) ? data.map(toShift) : [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
|
||||
|
||||
function toShift(raw: unknown): Shift {
|
||||
const r = (raw ?? {}) as Record<string, unknown>;
|
||||
return { assignee: str(r["assignee"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) };
|
||||
}
|
||||
|
||||
// ---- view models (pure; the EJS views read these) -----------------------------------
|
||||
|
||||
export function buildListModel(opts: { canWrite: boolean; chrome: PageChrome; error?: string; q: string; shifts: Shift[] }) {
|
||||
return {
|
||||
breadcrumbs: [{ label: "Shifts" }], // SHIFTS_PATH is the list itself; the form links back to it as "Shifts"
|
||||
canWrite: opts.canWrite,
|
||||
chrome: opts.chrome,
|
||||
...(opts.error ? { error: opts.error } : {}),
|
||||
filterBar: {
|
||||
applyLabel: "Search",
|
||||
clearHref: SHIFTS_PATH,
|
||||
label: "Filter shifts",
|
||||
pills: opts.q ? [{ label: "Search", remove: SHIFTS_PATH, value: opts.q }] : [],
|
||||
rows: [[
|
||||
{ label: "Search shifts", name: "q", placeholder: "Search title or assignee…", type: "search", value: opts.q },
|
||||
{ type: "spacer" },
|
||||
]],
|
||||
},
|
||||
newHref: `${SHIFTS_PATH}/new`,
|
||||
table: {
|
||||
caption: "Shifts",
|
||||
columns: [{ label: "Shift" }, { label: "Assignee" }, { label: "Start" }, { label: "End" }],
|
||||
rows: opts.shifts.map((s) => ({
|
||||
cells: [{ rowHeader: { text: s.title } }, s.assignee, s.start, s.end],
|
||||
name: s.title,
|
||||
})),
|
||||
},
|
||||
title: "Shifts",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFormModel(opts: { chrome: PageChrome; errors?: Record<string, string>; formError?: string; values?: Partial<ShiftInput> }) {
|
||||
const v = opts.values ?? {};
|
||||
const e = opts.errors ?? {};
|
||||
const field = (cfg: { icon?: string; id: string; label: string; type?: string; value: string }) => ({
|
||||
...cfg, name: cfg.id, ...(e[cfg.id] ? { error: e[cfg.id] } : {}), ...(cfg.id === "title" || cfg.id === "assignee" ? { required: true } : {}),
|
||||
});
|
||||
return {
|
||||
breadcrumbs: [{ href: SHIFTS_PATH, label: "Shifts" }, { label: "New shift" }],
|
||||
chrome: opts.chrome,
|
||||
...(opts.formError ? { formError: opts.formError } : {}),
|
||||
form: {
|
||||
action: SHIFTS_PATH,
|
||||
cancelHref: SHIFTS_PATH,
|
||||
csrfToken: opts.chrome.csrfToken,
|
||||
fields: [
|
||||
field({ icon: "i-cal", id: "title", label: "Shift title", value: v.title ?? "" }),
|
||||
field({ icon: "i-user", id: "assignee", label: "Assignee", value: v.assignee ?? "" }),
|
||||
field({ id: "start", label: "Start", type: "datetime-local", value: v.start ?? "" }),
|
||||
field({ id: "end", label: "End", type: "datetime-local", value: v.end ?? "" }),
|
||||
],
|
||||
submitLabel: "Create shift",
|
||||
},
|
||||
title: "New shift",
|
||||
};
|
||||
}
|
||||
|
||||
// ---- input + validation -------------------------------------------------------------
|
||||
|
||||
export function readInput(form: URLSearchParams): ShiftInput {
|
||||
return {
|
||||
assignee: (form.get("assignee") ?? "").trim(),
|
||||
end: (form.get("end") ?? "").trim(),
|
||||
start: (form.get("start") ?? "").trim(),
|
||||
title: (form.get("title") ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Required-field validation → { field: message } or null. Kept deliberately small; the upstream
|
||||
// owns the real domain rules (overlap, capacity, …) and rejects with a 4xx the handler surfaces.
|
||||
export function validate(input: ShiftInput): Record<string, string> | null {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!input.title) errors["title"] = "A shift needs a title.";
|
||||
if (!input.assignee) errors["assignee"] = "Assign the shift to someone.";
|
||||
return Object.keys(errors).length ? errors : null;
|
||||
}
|
||||
|
||||
// ---- handlers (factories bound to the upstream) -------------------------------------
|
||||
|
||||
export function listShifts(upstream: ShiftsUpstream): RouteHandler {
|
||||
return async (ctx) => {
|
||||
const q = parseListQuery(ctx.url).q;
|
||||
let shifts: Shift[] = [];
|
||||
let error: string | undefined;
|
||||
try {
|
||||
shifts = await upstream.list();
|
||||
} catch (err) {
|
||||
ctx.log.warn("scheduling upstream unreachable", { error: String(err) }); // plugin logging via ctx.log
|
||||
error = "Couldn't reach the scheduling service — try again shortly.";
|
||||
}
|
||||
const needle = q.toLowerCase();
|
||||
const rows = needle ? shifts.filter((s) => s.title.toLowerCase().includes(needle) || s.assignee.toLowerCase().includes(needle)) : shifts;
|
||||
return { data: buildListModel({ canWrite: can(ctx, WRITE), chrome: ctx.chrome, ...(error ? { error } : {}), q, shifts: rows }), view: "shifts" };
|
||||
};
|
||||
}
|
||||
|
||||
export function newShiftForm(): RouteHandler {
|
||||
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome }), view: "shift-new" });
|
||||
}
|
||||
|
||||
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
|
||||
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
|
||||
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
|
||||
// else a prompt to sign in. ctx.user may be null here, so read the role via can() (zero I/O).
|
||||
export function overview(): RouteHandler {
|
||||
return (ctx) => ({
|
||||
data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" },
|
||||
view: "overview",
|
||||
});
|
||||
}
|
||||
|
||||
export function createShift(upstream: ShiftsUpstream): RouteHandler {
|
||||
return async (ctx) => {
|
||||
const form = await readFormBody(ctx.req);
|
||||
// A write is a first-party form, so guard it with the host's double-submit token (ctx.verifyCsrf).
|
||||
if (!ctx.verifyCsrf(form.get(CSRF_FIELD))) throw new GuardError(403, "invalid CSRF token");
|
||||
const input = readInput(form);
|
||||
const errors = validate(input);
|
||||
if (errors) return { data: buildFormModel({ chrome: ctx.chrome, errors, values: input }), status: 400, view: "shift-new" };
|
||||
try {
|
||||
await upstream.create(input);
|
||||
} catch (err) {
|
||||
ctx.log.warn("scheduling shift create failed (upstream)", { error: String(err) });
|
||||
return { data: buildFormModel({ chrome: ctx.chrome, formError: "Couldn't save the shift — the scheduling service is unavailable.", values: input }), status: 502, view: "shift-new" };
|
||||
}
|
||||
ctx.log.info("scheduling shift created", { assignee: input.assignee, title: input.title });
|
||||
return { redirect: SHIFTS_PATH }; // POST-redirect-GET
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<%#
|
||||
Scheduling · public overview (reference plugin). A page ANYONE may reach — the route and its
|
||||
nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for
|
||||
everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to
|
||||
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
|
||||
Data: chrome, title, breadcrumbs, canRead, shiftsHref
|
||||
%><%
|
||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
const cta = canRead
|
||||
? '<a class="btn btn-primary" href="' + shiftsHref + '">View shifts</a>'
|
||||
: '<a class="btn btn-primary" href="/login?return_to=' + encodeURIComponent(shiftsHref) + '">Sign in to view shifts</a>';
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
actions: "",
|
||||
body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> role.</p>' + cta + '</div>',
|
||||
brand: chrome.brand,
|
||||
breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
nav: navHtml,
|
||||
signInHref: chrome.signInHref,
|
||||
styles: ["/public/scheduling/scheduling.css"],
|
||||
theme: chrome.theme,
|
||||
title,
|
||||
user: chrome.user,
|
||||
}) %>
|
||||
@@ -0,0 +1,22 @@
|
||||
<%#
|
||||
A plugin's own partial (resolved before the core ones). The new-shift form body, reusing the core
|
||||
`partials/field` + `partials/alert`. Config: form { action, csrfToken, submitLabel, cancelHref,
|
||||
fields: field.ejs config[] }, formError?
|
||||
%><%
|
||||
const form = locals.form;
|
||||
-%>
|
||||
<div class="form-page">
|
||||
<% if (locals.formError) { -%>
|
||||
<%- include("partials/alert", { text: locals.formError, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<form class="form-card" method="post" action="<%= form.action %>">
|
||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||
<% form.fields.forEach((field) => { -%>
|
||||
<%- include("partials/field", field) %>
|
||||
<% }) -%>
|
||||
<div class="form-actions">
|
||||
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
|
||||
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<%#
|
||||
Scheduling · New shift form (reference plugin). The form POSTs to /scheduling/shifts; the handler
|
||||
CSRF-verifies and forwards the write upstream. Body comes from this plugin's OWN partial
|
||||
(partials/shift-form — resolved plugin-first), which reuses the core field partial.
|
||||
Data: chrome, title, breadcrumbs, form, formError?
|
||||
%><%
|
||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
const body = '<div class="scheduling-page">' + include("partials/shift-form", { form, formError: locals.formError }) + '</div>';
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
body,
|
||||
brand: chrome.brand,
|
||||
breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
nav: navHtml,
|
||||
styles: ["/public/scheduling/scheduling.css"],
|
||||
theme: chrome.theme,
|
||||
title,
|
||||
user: chrome.user,
|
||||
}) %>
|
||||
@@ -0,0 +1,27 @@
|
||||
<%#
|
||||
Scheduling · Shifts list (reference plugin). The handler fetched the rows from the upstream
|
||||
service; this view renders them with the core building blocks inside the native app shell
|
||||
(ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table,
|
||||
alert) — see docs/plugin-contract.md. Zero-JS: search round-trips the URL.
|
||||
Data: chrome, title, breadcrumbs, filterBar, table, canWrite, newHref, error?
|
||||
%><%
|
||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
const filtersHtml = include("partials/filter-bar", filterBar);
|
||||
const tableHtml = include("partials/data-table", table);
|
||||
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
|
||||
const actions = canWrite
|
||||
? '<a class="btn btn-primary" href="' + newHref + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>New shift</a>'
|
||||
: "";
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
actions,
|
||||
body: '<div class="scheduling-page">' + alertHtml + filtersHtml + tableHtml + '</div>',
|
||||
brand: chrome.brand,
|
||||
breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
nav: navHtml,
|
||||
styles: ["/public/scheduling/scheduling.css"],
|
||||
theme: chrome.theme,
|
||||
title,
|
||||
user: chrome.user,
|
||||
}) %>
|
||||
Reference in New Issue
Block a user