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
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:
$user = $this->getUser($request)— 401 for anonymous.$this->requirePermission($request, 'api.pages.write')— same scope cap as the stock media/page-write endpoints (owner already holds it).EntryScopeGuard::isOwnerUser($this->grav, $user)— elseForbiddenException.- Validate
slugviaEntryScopeGuard::isSafeSegment— else 400. - Resolve the page via a new guard
EntryScopeGuard::resolveTripChild($grav, $slug): call$pages->enablePages()first (guarded bymethod_exists— the API request context lazily disables the page tree, exactly asresolveActiveDailyChilddoes), then$pages->find('/trips/' ~ slug), assert the resolved page's parent route is exactly/trips(no raw path concatenation — same style asresolveActiveDailyChild). Returnnull→NotFoundException. (find()returns unpublished trips too — verified againstPages.php:966/1986— so the owner can republish a draft from the listing.) - 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. - Set published + persist frontmatter by mutating the page header before
saving:
$header = $page->header(); $header->published = $published; $page->save();— mirroringcache-on-save'ssetOverwriteMode()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), whilesave()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-disktrip.mdwould be unchanged and the toggle would silently no-op. The write must land intrip.mdfrontmatter aspublished: true|false. - Caching:
$this->grav['cache']->deleteAll(); Cache::invalidateCache();— publish state feeds.published()collections and routability, both keyed through the page-tree index; withoutinvalidateCache()the listing/nav/home render stale (the exact bug fixed indeleteEntry). - Audit log:
owner "%s" set trip "%s" published=%s. - 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:
Owner sees unpublished trips too; anon unchanged.
{% set trips = (is_owner ? page.children : page.children.published()) |sort((a, b) => a.date < b.date ? 1 : -1) %} - 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.Draftbadge 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-togglecontrol (listing cards only). - On change:
- If turning off (unpublish) AND
data-activeis 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-busyfor the duration of the request, ignoring further toggles — guards against a double-tap, or a toggle during the active-tripconfirm(), 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>/publishsendingheaders: { 'Content-Type': 'application/json', Accept: 'application/json' }andbody: JSON.stringify({ published }),credentials: 'include'. Model this onpost-form.js'sapiSend, notfeed-actions.js(which is a body-less DELETE with noContent-Type). TheContent-Type: application/jsonis load-bearing: the API'sJsonBodyParserMiddlewareonly parses the body when that header is present (JsonBodyParserMiddleware.php:16); without it the body decodes to[], the strictis_boolguard (backend step 6) sees nopublishedkey, and every toggle 400s.- Success: optimistic UI — flip
data-published, toggle theDraftbadge, 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-livetoast region (the listing's corner overlay has no room for an inline message). Reuse the copy style fromfeed-actions.js: 401/403 → "sign in again"; other → "Couldn't update — try again."
- If turning off (unpublish) AND
Edge cases
- Active trip unpublish → JS
confirm()(above), allowed on confirm. Home then treats it as no active trip: gatehome.html.twig's active-trip branch on the resolved active trip being published as well asconfig.site.travelling({% if config.site.travelling and trip.published %}—tripis already resolved athome.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 touchsite.active_trip. - Anon / non-owner → no toggle rendered; listing shows
.published()only; backend rejects with 401/403. - Unpublished trip visibility → drops from the public
/tripslisting; 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/tripslisting (Draft badge) and re-publishes from there. (Scope note: this toggle governs only whether the trip appears in the/tripslisting — 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).
- TP1 — owner sees the toggle; anon does not. Owner load of
/tripsshows.trip-publish-toggle; an anon (cleared storageState) load does not, and an unpublished fixture trip is absent for anon. - TP2 — unpublish hides it (caching). Owner toggles a published fixture trip
off → reload
/tripsas anon → the trip is absent; owner reload of the/tripslisting → 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). - 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). - TP4 — active-trip confirm. Unpublishing the active trip prompts a confirm; dismissing leaves it published.
- TP5 — authz.
POST /api/v1/trip/<slug>/publishas anon → 401; as a non-owner authenticated user → 403; frontmatter unchanged on disk. - TP6 — active trip unpublished → home falls back. With the fixture trip set as
site.active_tripandtravelling: true, unpublish it → reload/→ home renders its between-trips / pre-departure state, not the draft trip's active-trip view. (Needs theactive_tripoverride 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).