config/ becomes an empty drop-in mount; examples/ mirrors the mount dirs; plugins & config import the host via #plugin-api/#menu-config subpath imports
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
# Scheduling — the reference plugin
|
||||
|
||||
A worked example of the [plugin contract](../../../README.md#building-plugins). 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 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.
|
||||
Reference in New Issue
Block a user