Files

Scheduling — the reference plugin

A worked example of the plugin contract. Copy this folder into plugins/ (it keeps the id and mount path scheduling) and point it at your own backend — the folder name is the plugin id and mount path, so rename it only if you want a different one.

What it demonstrates:

  • A list page that fetches upstream dataGET /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 admin example plugin does.)
  • A form that forwards a write upstreamGET /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.