Files
intotheeast-com/docs/working/specs/2026-07-08-trip-publish-toggle-design.md
T
m038andClaude Opus 4.8 3e1ddd8132 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
2026-07-08 14:50:20 +02:00

12 KiB

Trip publish/unpublish toggle — design

Date: 2026-07-08 Status: 📋 Design — awaiting plan

Goal

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

A single rule, mirroring the post feed's owner logic:

{% set is_owner = grav.user.authenticated and grav.user.username == grav.config.site.owner_username %}

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, 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

Reuses the plugin's existing owner-gate, API-key scope cap, and the deleteAll() + Cache::invalidateCache() caching pattern.

Route

POST /api/v1/trip/{slug}/publish — registered in entry-actions.php onApiRegisterRoutes. Body: { "published": true | false }.

Controller: setTripPublished(ServerRequestInterface): ResponseInterface

In EntryActionsApiController, mirroring deleteEntry:

  1. $user = $this->getUser($request) — 401 for anonymous.
  2. $this->requirePermission($request, 'api.pages.write') — same scope cap as the stock media/page-write endpoints (owner already holds it).
  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): 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 nullNotFoundException. (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 render stale (the exact bug fixed in deleteEntry).
  9. Audit log: owner "%s" set trip "%s" published=%s.
  10. Return ApiResponse::noContent() (204).

Frontend

Shared toggle partial

partials/trip-publish-toggle.html.twig — renders a sliding on/off switch (a styled checkbox that moves left↔right) plus a Draft badge when unpublished. Params: trip (the trip Page), is_active (bool, whether this trip is site.active_trip). Emits data-trip-slug, data-trip-route, 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:
    {% set trips = (is_owner ? page.children : page.children.published())
                     |sort((a, b) => a.date < b.date ? 1 : -1) %}
    
    Owner sees unpublished trips too; anon unchanged.
  • The trip card is currently a single <a> wrapping the cover + title. The toggle must not be inside the anchor (a click would navigate). Restructure 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)

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 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 (listing cards only).
  • On change:
    • If turning off (unpublish) AND data-active is true → window.confirm( '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 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. 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 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.

Testing (Playwright, tests/ui/trip/)

Tests run as the owner (testrunner via owner_username override), mirroring the post specs. Use a throwaway fixture trip folder (create/cleanup on disk).

  1. TP1 — owner sees the toggle; anon does not. Owner load of /trips shows .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 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

  • Bulk publish/unpublish.
  • 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).