All checks were successful
deploy / deploy (push) Successful in 6m49s
- fix Site tab not loading theme state on direct URL navigation - fix nav editor showing "Custom URL" for page links (detect by URL match) - add Home option to nav page picker - mark editor-reorganisation plan as complete - add dynamic-url-customisation and draft-publish-workflow plans Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
11 KiB
11 KiB
Draft-then-publish workflow
Status: Planned (after unified-editing-mode)
Problem
Current editing workflow uses explicit save — changes are either saved (immediately live) or lost on navigation. This creates:
- Data loss anxiety — navigate away accidentally, lose all work
beforeunloadwarnings — browser-native dialogs with no custom options- No experimentation — can't safely try changes without affecting live site
Solution
Implement a draft-then-publish model with site-level publishing and version history:
- Site starts unpublished — New sites are in draft mode until first publish
- Auto-save drafts — All edits auto-save, no risk of lost work
- Visitors see published only — No accidental exposure of drafts
- Version history — Each publish creates a snapshot, enabling rollback
- URLs are versioned — URL slugs are part of version data (ties into dynamic URL customisation)
Design principles
- Site-level lifecycle — Site has a
first_published_attimestamp; until then, visitors see "coming soon" - Drafts are per-user — each admin sees their own pending changes
- Visitors always see published — no accidental exposure of drafts
- Auto-save constantly — no risk of lost work
- Clear visual distinction — obvious when viewing draft vs published
- Single publish action — one button publishes all pending changes
- Version snapshots — each publish creates a version record for rollback
- Atomic rollback — rolling back restores content AND URL (redirect chain updates automatically)
Scope
Three types of editable content:
| Type | Storage | Draft approach |
|---|---|---|
| Page blocks | pages.blocks (JSON) |
draft_blocks column |
| Theme settings | settings.theme_settings (JSON) |
draft_theme_settings key |
| Shop settings | settings table (various keys) |
draft_* keys or settings_drafts table |
Data model
Site-level publishing
# Add to settings or dedicated sites table
# Option 1: Settings key
%{"site_first_published_at" => nil} # nil = unpublished, datetime = published
# Option 2: Dedicated sites table (if multi-tenant later)
create table(:sites, primary_key: false) do
add :id, :binary_id, primary_key: true
add :first_published_at, :utc_datetime_usec # nil until first publish
timestamps(type: :utc_datetime_usec)
end
Page versions (recommended approach)
Rather than draft columns, use a proper version table. Each publish creates a version snapshot.
create table(:page_versions, primary_key: false) do
add :id, :binary_id, primary_key: true
add :page_id, references(:pages, type: :binary_id, on_delete: :delete_all), null: false
add :version_number, :integer, null: false
add :blocks, :map, null: false
add :url_slug, :string # URL is part of version data
add :meta_title, :string
add :meta_description, :string
add :published_at, :utc_datetime_usec
add :published_by_id, references(:users, type: :binary_id, on_delete: :nilify_all)
timestamps(type: :utc_datetime_usec)
end
create index(:page_versions, [:page_id, :version_number])
create unique_index(:page_versions, [:page_id, :version_number])
# Pages table changes
alter table(:pages) do
add :published_version_id, references(:page_versions, type: :binary_id, on_delete: :nilify_all)
add :draft_blocks, :map
add :draft_url_slug, :string
add :draft_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
add :draft_updated_at, :utc_datetime_usec
end
How it works:
pages.draft_*fields hold current work-in-progress- On publish: create new
page_versionsrecord, updatepages.published_version_id - On rollback: set
published_version_idto older version, update redirect chain - Visitors always see the version pointed to by
published_version_id
URL versioning and redirects
When a page version has a different URL than the current published version:
- The redirect system tracks URL history
- All old URLs redirect to current canonical URL
- On rollback, old URL becomes canonical again, redirect chain updates
# Example: Page starts at /about, renamed to /about-us, then rolled back
# Version 1: url_slug = "about" <- created
# Version 2: url_slug = "about-us" <- published, redirect /about → /about-us created
# Rollback to v1: redirect /about-us → /about created (or /about redirect deleted)
This ties directly into the dynamic URL customisation plan.
Implementation
Phase 0: Site-level publishing
| Task | Est | Notes |
|---|---|---|
Add site_first_published_at setting |
15m | nil until first publish |
| Update coming soon page to check this setting | 30m | Show until site is published |
| Add "Publish site" button to admin dashboard | 30m | First-time publish action |
| Hide "Publish site" after first publish | 15m | One-time action |
Phase 1: Page versions and drafts
| Task | Est | Notes |
|---|---|---|
| Create page_versions table | 30m | Migration with version data |
| Add draft columns and published_version_id to pages | 30m | Migration |
| Create PageVersions context module | 1.5h | create_version/2, rollback_to/2, list_versions/1 |
| Update Pages context with draft functions | 1h | save_draft/2, publish_draft/1, discard_draft/1, has_draft?/1 |
| Modify PageEditorHook to auto-save drafts | 1h | Debounced save on every change |
| Add "Publish" and "Discard" buttons to editor | 1h | Replace current "Save" |
| Show draft indicator when viewing page with unpublished changes | 1h | Banner or badge |
Remove beforeunload warning (no longer needed) |
15m | Drafts persist |
Phase 2: Theme drafts
| Task | Est | Notes |
|---|---|---|
| Add draft_theme_settings to settings | 30m | New key |
| Update Settings context with theme draft functions | 1h | Similar to pages |
| Modify theme editor to auto-save drafts | 1h | |
| Theme preview shows draft, shop shows published | 1h | Conditional CSS injection |
| Add publish/discard to theme editor | 30m |
Phase 3: Settings drafts
| Task | Est | Notes |
|---|---|---|
| Decide which settings are draftable | 30m | Not all need drafts (e.g. API keys) |
| Add draft handling to Settings context | 1.5h | |
| Update settings editor to use drafts | 1h | |
| Unified "Publish all" option | 1h | Publish page + theme + settings together |
Phase 4: Version history and rollback
| Task | Est | Notes |
|---|---|---|
| Version history panel in editor | 1.5h | List of published versions with timestamps |
| Rollback action with confirmation | 1h | "Revert to version 3?" |
| URL redirect chain update on rollback | 1h | Update redirects to point to restored URL |
| Version comparison view (stretch) | 2h | Side-by-side diff |
Phase 5: Polish
| Task | Est | Notes |
|---|---|---|
| Draft age indicator ("Last saved 5 mins ago") | 30m | |
| Draft conflict handling (stale draft warning) | 1h | Edge case: published changed since draft created |
| Version pruning (keep last N versions) | 1h | Prevent unbounded growth |
UX flow
First-time site publish
- New shop completes setup wizard
- Site is in "coming soon" mode — visitors see placeholder
- Admin edits pages, theme, settings (all auto-saved as drafts)
- Admin clicks "Publish site" on dashboard
- Site goes live —
first_published_atset, all current drafts become version 1
Editing a page
- Admin navigates to page, clicks "Edit"
- Editor panel opens, showing current published state
- Admin makes changes — auto-saved as draft every few seconds
- Admin can navigate away freely — draft persists
- Admin returns later — draft loaded automatically
- "Publish" button commits draft → new version
- "Discard" button deletes draft, reverts to current published version
Rolling back a page
- Admin opens version history in editor
- Sees list: "Version 3 (current) — 2 hours ago", "Version 2 — yesterday", etc.
- Clicks "Revert to version 2"
- Confirmation: "This will restore version 2 content and URL. Continue?"
- On confirm:
published_version_idupdated, redirect chain adjusted - Current draft (if any) is NOT affected — can discard or keep editing
Visual indicators
┌─────────────────────────────────────────────────────────┐
│ ⚠️ You have unpublished changes (last saved 2 mins ago) │
│ [Publish] [Discard] │
└─────────────────────────────────────────────────────────┘
When admin views a page with a draft:
- Banner at top of editor panel
- "Unpublished" badge next to page title
- Different background tint in editor (subtle)
Visitors see published only
The render_page/1 function checks:
- If admin is viewing AND has draft → show draft
- Otherwise → show published
blocks
defp get_blocks_for_render(page, current_user) do
if current_user && page.draft_user_id == current_user.id && page.draft_blocks do
page.draft_blocks
else
page.blocks
end
end
Migration path
- Deploy with draft support disabled (feature flag)
- Run migration to add draft columns
- Enable draft mode
- Existing pages continue working (no draft = show published)
- New edits create drafts
Future considerations
- Multi-user drafts — currently single draft per page, could extend to per-user drafts
- Draft preview link — shareable URL that shows draft to non-admins (for review)
- Scheduled publishing — "Publish at 9am tomorrow"
- Theme/settings versioning — extend version history to theme and shop settings
- Bulk rollback — "Revert entire site to yesterday" across all pages
Dependencies
- Unified editing mode (phases 1-7) must be complete first
- This work builds on the editor panel UI established there
Related Plans
- dynamic-url-customisation.md — URLs are versioned data; these plans integrate cleanly
- Can be implemented in either order, but implementing together gives the cleanest result
Estimates
| Phase | Est |
|---|---|
| Phase 0: Site-level publishing | 1.5h |
| Phase 1: Page versions and drafts | 7h |
| Phase 2: Theme drafts | 4h |
| Phase 3: Settings drafts | 4h |
| Phase 4: Version history and rollback | 5.5h |
| Phase 5: Polish | 2.5h |
| Total | 24.5h |