test(trip): publish-toggle Playwright specs + plan/spec docs; bump user pin
TP1/TP1b/TP2–TP6 cover the owner gate, coverless drafts, cache-correct hide/restore, the active-trip confirm, backend authz (401/403/400), and the home fallback. The suite pins site.owner_username to the authenticated test user (restore on teardown) and runs serially — it mutates global config and clears the shared cache, so it collides with parallel readers. Bumps the user/ pin to the finished trip-publish-toggle content (064f0f0) and marks the plan Complete. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
This commit is contained in:
@@ -5,11 +5,14 @@
|
||||
|
||||
## Goal
|
||||
|
||||
Let the logged-in **owner** publish/unpublish any trip directly from the UI, on
|
||||
two surfaces: the **Past Trips listing** (`/trips`) and each **trip detail page**
|
||||
(`/trips/<slug>`). Anonymous/non-owner visitors see no change. Toggling must
|
||||
correctly invalidate Grav's page-tree cache so the change is reflected
|
||||
everywhere on the next load.
|
||||
Let the logged-in **owner** publish/unpublish any trip directly from the UI. The
|
||||
**write control lives on one surface — the Past Trips listing** (`/trips`), which
|
||||
already shows drafts and toggles both directions reversibly. The **trip detail
|
||||
page** (`/trips/<slug>`) carries **no publish UI**: an unpublished trip's detail
|
||||
page 404s (for everyone, owner included), so a control there could only strand the
|
||||
owner and a `Draft` indicator there would be unreachable — see Surface 2.
|
||||
Anonymous/non-owner visitors see no change. Toggling must correctly invalidate
|
||||
Grav's page-tree cache so the change is reflected everywhere on the next load.
|
||||
|
||||
## Owner gate
|
||||
|
||||
@@ -21,9 +24,9 @@ A single rule, mirroring the post feed's owner logic:
|
||||
|
||||
This is **broader** than `owner_can_edit` in `trip.html.twig` (which also
|
||||
requires the page to be the active trip). Publishing must work on *any* trip, so
|
||||
it gets its own `is_owner` flag. `is_owner` is computed in both `trips.html.twig`
|
||||
and `trip.html.twig`. The backend enforces the same owner check independently
|
||||
(defense in depth) — the UI gate is not the security boundary.
|
||||
it gets its own `is_owner` flag, computed in `trips.html.twig` (the listing — the
|
||||
only surface with the write control). The backend enforces the same owner check
|
||||
independently (defense in depth) — the UI gate is not the security boundary.
|
||||
|
||||
## Backend — extend the `entry-actions` plugin
|
||||
|
||||
@@ -45,15 +48,27 @@ In `EntryActionsApiController`, mirroring `deleteEntry`:
|
||||
3. `EntryScopeGuard::isOwnerUser($this->grav, $user)` — else `ForbiddenException`.
|
||||
4. Validate `slug` via `EntryScopeGuard::isSafeSegment` — else 400.
|
||||
5. Resolve the page via a **new** guard `EntryScopeGuard::resolveTripChild($grav, $slug)`:
|
||||
`$pages->find('/trips/' ~ slug)`, assert the resolved page's parent route is
|
||||
call `$pages->enablePages()` first (guarded by `method_exists` — the API request
|
||||
context lazily disables the page tree, exactly as `resolveActiveDailyChild` does),
|
||||
then `$pages->find('/trips/' ~ slug)`, assert the resolved page's parent route is
|
||||
exactly `/trips` (no raw path concatenation — same style as
|
||||
`resolveActiveDailyChild`). Return `null` → `NotFoundException`.
|
||||
6. Read desired state: `$published = (bool) ($body['published'] ?? …)`; reject a
|
||||
missing/non-bool value with 400.
|
||||
7. Set published + persist frontmatter. Use Grav's page API (verify exact call
|
||||
against `add-page-by-form` / `cache-on-save` savers before coding — likely
|
||||
`$page->published($published); $page->save();`). The write must land in
|
||||
`trip.md` frontmatter as `published: true|false`.
|
||||
`resolveActiveDailyChild`). Return `null` → `NotFoundException`. (`find()` returns
|
||||
unpublished trips too — verified against `Pages.php:966`/`1986` — so the owner can
|
||||
republish a draft from the listing.)
|
||||
6. Read desired state: reject a missing or non-boolean value with 400 —
|
||||
`if (!array_key_exists('published', $body) || !is_bool($body['published'])) → 400`
|
||||
— then assign the raw boolean (`$published = $body['published']`). Do **not**
|
||||
`(bool)`-cast the value: a cast silently coerces anything (`"false"`, `0`, `""`,
|
||||
a missing key) into a valid boolean and never rejects, contradicting the 400.
|
||||
7. Set published + persist frontmatter by mutating the page **header** before
|
||||
saving: `$header = $page->header(); $header->published = $published; $page->save();`
|
||||
— mirroring `cache-on-save`'s `setOverwriteMode()` header-mutation pattern. Do
|
||||
**not** rely on `$page->published($published)` alone: in Grav 2.0 that only sets
|
||||
the in-memory property (`Page.php:1714`), while `save()` serializes from the
|
||||
header object (`Page.php:1256`) and the flag is read one-way *from* the header at
|
||||
init (`Page.php:541`) — so the on-disk `trip.md` would be unchanged and the
|
||||
toggle would silently no-op. The write must land in `trip.md` frontmatter as
|
||||
`published: true|false`.
|
||||
8. **Caching:** `$this->grav['cache']->deleteAll(); Cache::invalidateCache();` —
|
||||
publish state feeds `.published()` collections and routability, both keyed
|
||||
through the page-tree index; without `invalidateCache()` the listing/nav/home
|
||||
@@ -72,6 +87,11 @@ Params: `trip` (the trip Page), `is_active` (bool, whether this trip is
|
||||
`data-published`, and `data-active` for the JS to read. Rendered only when
|
||||
`is_owner`.
|
||||
|
||||
The switch carries `role="switch"` + `aria-checked` and a per-instance accessible
|
||||
name — `aria-label="Published — {{ trip.title }}"` — so a screen-reader user on
|
||||
the listing (where every card's switch is otherwise identical) can tell which trip
|
||||
a toggle controls before triggering a destructive unpublish.
|
||||
|
||||
### Surface 1 — `/trips` listing (`trips.html.twig`)
|
||||
|
||||
- Make the collection owner-aware:
|
||||
@@ -85,39 +105,80 @@ Params: `trip` (the trip Page), `is_active` (bool, whether this trip is
|
||||
the card so the cover image is in a positioned wrapper and the toggle sits as
|
||||
an overlay sibling. Toggle placement: **absolutely positioned over the cover
|
||||
image, top-right corner.** `Draft` badge on unpublished cards.
|
||||
- **Legibility over arbitrary covers:** give the overlay toggle a solid pill/chip
|
||||
background reusing the `Draft`-badge styling (Field Notes paper/teal) so it stays
|
||||
legible on any cover photo, and a ≥44px touch target kept clear of the card `<a>`
|
||||
hit area.
|
||||
|
||||
### Surface 2 — trip detail page (`trip.html.twig`)
|
||||
|
||||
- Compute `is_owner` (separate from `owner_can_edit`).
|
||||
- Render the shared toggle in the header area, **top-right corner near the trip
|
||||
header**, with the `Draft` badge when unpublished.
|
||||
**No publish UI in v1 — management is listing-only.** The detail page gets neither
|
||||
a write toggle nor a `Draft` indicator, for a concrete reason: an unpublished trip
|
||||
is not routable, and Grav's frontend serves a 404 for unpublished pages to
|
||||
*everyone including the owner* (`Page::routable()` = `routable && published`, with
|
||||
no published routable child to redirect to since `dailies`/`stories` are
|
||||
`routable:false` — verified in `Pages::dispatch` / `Page.php:1772`). So a trip's
|
||||
detail page only ever renders while it is **published** — which means a `Draft`
|
||||
indicator there would be unreachable, and a write toggle could only *unpublish*,
|
||||
immediately stranding the owner on a page that 404s on the next load with no in-UI
|
||||
path back. All publish/unpublish therefore happens on the `/trips` listing
|
||||
(Surface 1), which shows drafts and is fully reversible. `trip.html.twig` needs no
|
||||
`is_owner` computation for this feature.
|
||||
|
||||
### JS — `js/src/trip-publish.js` → built to `js/trip-publish.js`
|
||||
|
||||
Loaded in the `bottom` group **only when `is_owner`** (like `feed-actions.js`).
|
||||
Loaded on `/trips` in the `bottom` group **only when `is_owner`** (gated like
|
||||
`feed-actions.js` — but note the request shape below differs from it).
|
||||
|
||||
- Binds each `.trip-publish-toggle` control.
|
||||
- Binds each `.trip-publish-toggle` control (listing cards only).
|
||||
- On change:
|
||||
- If turning **off** (unpublish) AND `data-active` is true → `window.confirm(
|
||||
'This is your active trip — unpublish it anyway?')`; if cancelled, revert the
|
||||
switch and stop.
|
||||
- `POST /api/v1/trip/<slug>/publish` with `{ published }`,
|
||||
`credentials: 'include'`.
|
||||
'This is your active trip — unpublishing it also removes it from the home page.
|
||||
Unpublish anyway?')`; if cancelled, revert the switch and stop. (Home falls back
|
||||
to its pre-departure state when the active trip is unpublished — see Edge cases.)
|
||||
- **Pending:** disable the switch and set `aria-busy` for the duration of the
|
||||
request, ignoring further toggles — guards against a double-tap, or a toggle
|
||||
during the active-trip `confirm()`, firing a second contradictory POST and
|
||||
racing the revert paths. Show it dimmed with a wait cursor while pending;
|
||||
re-enable on success or after the failure revert.
|
||||
- `POST /api/v1/trip/<slug>/publish` sending **`headers: { 'Content-Type':
|
||||
'application/json', Accept: 'application/json' }` and `body: JSON.stringify({
|
||||
published })`**, `credentials: 'include'`. Model this on `post-form.js`'s
|
||||
`apiSend`, **not** `feed-actions.js` (which is a body-less DELETE with no
|
||||
`Content-Type`). The `Content-Type: application/json` is load-bearing: the API's
|
||||
`JsonBodyParserMiddleware` only parses the body when that header is present
|
||||
(`JsonBodyParserMiddleware.php:16`); without it the body decodes to `[]`, the
|
||||
strict `is_bool` guard (backend step 6) sees no `published` key, and **every
|
||||
toggle 400s**.
|
||||
- **Success:** optimistic UI — flip `data-published`, toggle the `Draft` badge,
|
||||
update the switch position/label. No full reload needed (server state is
|
||||
persisted + cache invalidated for other surfaces).
|
||||
- **Failure:** revert the switch to its prior state and show an inline,
|
||||
`aria-live` error (reuse the copy style from `feed-actions.js`:
|
||||
401/403 → "sign in again"; other → "Couldn't update — try again.").
|
||||
update the switch position/label in place on the card. No full reload needed
|
||||
(server state is persisted + cache invalidated for other surfaces). The card
|
||||
stays visible to the owner either way (the owner-aware collection includes
|
||||
drafts).
|
||||
- **Failure:** revert the switch to its prior state and surface an error via one
|
||||
shared page-level `aria-live` toast region (the listing's corner overlay has no
|
||||
room for an inline message). Reuse the copy style from `feed-actions.js`:
|
||||
401/403 → "sign in again"; other → "Couldn't update — try again."
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Active trip unpublish** → JS `confirm()` (above). Allowed on confirm.
|
||||
- **Active trip unpublish** → JS `confirm()` (above), allowed on confirm. **Home
|
||||
then treats it as no active trip:** gate `home.html.twig`'s active-trip branch on
|
||||
the resolved active trip being **published** as well as `config.site.travelling`
|
||||
(`{% if config.site.travelling and trip.published %}` — `trip` is already resolved
|
||||
at `home.html.twig:10`). When the active trip is unpublished, home falls through to
|
||||
its between-trips / pre-departure state instead of rendering a draft trip. No need
|
||||
to touch `site.active_trip`.
|
||||
- **Anon / non-owner** → no toggle rendered; listing shows `.published()` only;
|
||||
backend rejects with 401/403.
|
||||
- **Unpublished trip visibility** → drops from the public `/trips` listing; its
|
||||
detail page 404s for anon (Grav default for unpublished/unroutable). Owner
|
||||
still sees it in the listing (Draft badge) and can re-publish.
|
||||
detail page 404s for **everyone including the owner** (Grav default for
|
||||
unpublished/unroutable — there is no owner-preview bypass). The owner still sees
|
||||
the trip in the `/trips` listing (Draft badge) and re-publishes from there.
|
||||
(Scope note: this toggle governs only whether the trip appears in the `/trips`
|
||||
listing — it is not a content-privacy control. Child dailies are aggregated inline
|
||||
by the trip page and are not individually linked; a story reachable by a direct
|
||||
link stays reachable, which is acceptable.)
|
||||
- **Child dailies/stories cascade** → out of scope for v1; unpublishing a trip
|
||||
does not change its children's published state.
|
||||
|
||||
@@ -130,13 +191,20 @@ the post specs. Use a throwaway fixture trip folder (create/cleanup on disk).
|
||||
`.trip-publish-toggle`; an anon (cleared storageState) load does not, and an
|
||||
unpublished fixture trip is absent for anon.
|
||||
2. **TP2 — unpublish hides it (caching).** Owner toggles a published fixture trip
|
||||
off → **reload** `/trips` as anon → the trip is absent; owner reload → Draft
|
||||
badge present. This is the page-tree-index assertion (mirrors DEL4).
|
||||
3. **TP3 — republish restores it.** Toggle back on → anon reload sees it again.
|
||||
off → **reload** `/trips` as anon → the trip is absent; owner reload of the
|
||||
`/trips` listing → Draft badge present (asserted on the listing, since the detail
|
||||
page 404s for the owner too). This is the page-tree-index assertion (mirrors DEL4).
|
||||
3. **TP3 — republish restores it (from the listing).** As owner on `/trips`, toggle
|
||||
a Draft fixture trip back on → anon reload sees it again. Republish is asserted on
|
||||
the listing surface, not the detail page (which 404s while unpublished).
|
||||
4. **TP4 — active-trip confirm.** Unpublishing the active trip prompts a confirm;
|
||||
dismissing leaves it published.
|
||||
5. **TP5 — authz.** `POST /api/v1/trip/<slug>/publish` as anon → 401; as a
|
||||
non-owner authenticated user → 403; frontmatter unchanged on disk.
|
||||
6. **TP6 — active trip unpublished → home falls back.** With the fixture trip set as
|
||||
`site.active_trip` and `travelling: true`, unpublish it → reload `/` → home renders
|
||||
its between-trips / pre-departure state, not the draft trip's active-trip view.
|
||||
(Needs the `active_trip` override on the fixture; mirrors the home-suite setup.)
|
||||
|
||||
## Out of scope
|
||||
|
||||
@@ -144,3 +212,6 @@ the post specs. Use a throwaway fixture trip folder (create/cleanup on disk).
|
||||
- Scheduling / publish dates.
|
||||
- Cascading child publish state.
|
||||
- Reordering trips by publish state (order stays by date desc).
|
||||
- A publish/unpublish write control on the trip detail page. Management is
|
||||
listing-only by design (an unpublished trip's detail page 404s, so a detail-page
|
||||
toggle could only strand the owner — see Surface 2).
|
||||
|
||||
Reference in New Issue
Block a user