322 lines
9.1 KiB
Markdown
322 lines
9.1 KiB
Markdown
|
|
# Unified Editor Session
|
||
|
|
|
||
|
|
**Status:** Complete
|
||
|
|
**Depends on:** Editor panel reorganisation (Phase 1-2 complete)
|
||
|
|
**Estimated effort:** 4-5 hours
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
Unify the editing experience across all three tabs (Page, Theme, Site) so they behave consistently:
|
||
|
|
|
||
|
|
- **Instant preview** — all changes show immediately on the shop
|
||
|
|
- **Explicit save** — changes only persist to database when you click Save
|
||
|
|
- **Free tab switching** — no warnings when switching between tabs
|
||
|
|
- **Accumulated changes** — edits across all tabs are saved together
|
||
|
|
- **Navigation warning** — warn on page navigation or refresh if any tab has unsaved changes
|
||
|
|
|
||
|
|
## Current State
|
||
|
|
|
||
|
|
| Tab | Dirty Tracking | Save Method | Preview |
|
||
|
|
|-----|----------------|-------------|---------|
|
||
|
|
| Page | `editor_dirty` | Manual Save | Instant (in-memory blocks) |
|
||
|
|
| Theme | None | Auto-save | Instant (CSS regenerated) |
|
||
|
|
| Site | None | Auto-save | Instant (assigns updated) |
|
||
|
|
|
||
|
|
## Target State
|
||
|
|
|
||
|
|
| Tab | Dirty Tracking | Save Method | Preview |
|
||
|
|
|-----|----------------|-------------|---------|
|
||
|
|
| Page | `page_dirty` | Unified Save | Instant (in-memory blocks) |
|
||
|
|
| Theme | `theme_dirty` | Unified Save | Instant (in-memory settings) |
|
||
|
|
| Site | `site_dirty` | Unified Save | Instant (in-memory settings) |
|
||
|
|
|
||
|
|
Unified dirty flag: `editor_dirty = page_dirty || theme_dirty || site_dirty`
|
||
|
|
|
||
|
|
## Implementation Tasks
|
||
|
|
|
||
|
|
### Phase 1: Theme Tab — Preview Without Auto-Save (1.5h)
|
||
|
|
|
||
|
|
Currently Theme auto-saves every change. We need to decouple preview from persistence.
|
||
|
|
|
||
|
|
#### 1.1 Add theme dirty tracking
|
||
|
|
|
||
|
|
In `page_editor_hook.ex`, add assigns:
|
||
|
|
|
||
|
|
```elixir
|
||
|
|
|> assign(:theme_dirty, false)
|
||
|
|
|> assign(:theme_editor_original, nil) # snapshot at load time
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 1.2 Modify theme event handlers
|
||
|
|
|
||
|
|
Change handlers like `theme_update_setting`, `theme_update_color`, etc. to:
|
||
|
|
|
||
|
|
1. Update `theme_editor_settings` in memory (for preview)
|
||
|
|
2. Regenerate CSS for live preview
|
||
|
|
3. Set `theme_dirty = true`
|
||
|
|
4. **Do NOT call `Settings.update_theme_settings()`**
|
||
|
|
|
||
|
|
#### 1.3 Store original theme state
|
||
|
|
|
||
|
|
When loading theme state in `load_theme_state/1`:
|
||
|
|
|
||
|
|
```elixir
|
||
|
|
|> assign(:theme_editor_original, theme_settings)
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 1.4 Add theme revert logic
|
||
|
|
|
||
|
|
On discard/close with dirty state, restore from `theme_editor_original`.
|
||
|
|
|
||
|
|
### Phase 2: Site Tab — Preview Without Auto-Save (1h)
|
||
|
|
|
||
|
|
#### 2.1 Add site dirty tracking
|
||
|
|
|
||
|
|
```elixir
|
||
|
|
|> assign(:site_dirty, false)
|
||
|
|
|> assign(:site_editor_original, nil)
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 2.2 Modify site event handlers
|
||
|
|
|
||
|
|
Change `handle_site_update/2` to:
|
||
|
|
|
||
|
|
1. Update `site_*` assigns in memory (for preview)
|
||
|
|
2. Set `site_dirty = true`
|
||
|
|
3. **Do NOT call `Site.put_announcement()` or `Site.put_footer_content()`**
|
||
|
|
|
||
|
|
#### 2.3 Store original site state
|
||
|
|
|
||
|
|
When loading site state in `load_site_state/1`:
|
||
|
|
|
||
|
|
```elixir
|
||
|
|
original = %{
|
||
|
|
announcement_text: site_settings.announcement_text,
|
||
|
|
announcement_link: site_settings.announcement_link,
|
||
|
|
announcement_style: site_settings.announcement_style,
|
||
|
|
footer_about: site_settings.footer_about,
|
||
|
|
footer_copyright: site_settings.footer_copyright,
|
||
|
|
show_newsletter: site_settings.show_newsletter
|
||
|
|
}
|
||
|
|
|> assign(:site_editor_original, original)
|
||
|
|
```
|
||
|
|
|
||
|
|
### Phase 3: Unified Save Button (1h)
|
||
|
|
|
||
|
|
#### 3.1 Compute unified dirty state
|
||
|
|
|
||
|
|
```elixir
|
||
|
|
defp compute_editor_dirty(socket) do
|
||
|
|
page_dirty = socket.assigns[:editor_dirty] || false
|
||
|
|
theme_dirty = socket.assigns[:theme_dirty] || false
|
||
|
|
site_dirty = socket.assigns[:site_dirty] || false
|
||
|
|
settings_dirty = socket.assigns[:settings_dirty] || false
|
||
|
|
|
||
|
|
assign(socket, :any_dirty, page_dirty || theme_dirty || site_dirty || settings_dirty)
|
||
|
|
end
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 3.2 Update Save button
|
||
|
|
|
||
|
|
In `editor_sheet` header, change Save button to:
|
||
|
|
|
||
|
|
- Enabled when `@any_dirty`
|
||
|
|
- On click: `editor_save_all` event
|
||
|
|
|
||
|
|
#### 3.3 Implement unified save handler
|
||
|
|
|
||
|
|
```elixir
|
||
|
|
defp handle_editor_event("editor_save_all", _params, socket) do
|
||
|
|
socket = save_all_tabs(socket)
|
||
|
|
{:halt, socket}
|
||
|
|
end
|
||
|
|
|
||
|
|
defp save_all_tabs(socket) do
|
||
|
|
socket
|
||
|
|
|> maybe_save_page()
|
||
|
|
|> maybe_save_theme()
|
||
|
|
|> maybe_save_site()
|
||
|
|
|> maybe_save_settings()
|
||
|
|
|> assign(:editor_save_status, :saved)
|
||
|
|
|> schedule_save_status_clear()
|
||
|
|
end
|
||
|
|
|
||
|
|
defp maybe_save_page(socket) do
|
||
|
|
if socket.assigns[:editor_dirty] do
|
||
|
|
# existing page save logic
|
||
|
|
else
|
||
|
|
socket
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
defp maybe_save_theme(socket) do
|
||
|
|
if socket.assigns[:theme_dirty] do
|
||
|
|
Settings.update_theme_settings(socket.assigns.theme_editor_settings)
|
||
|
|
assign(socket, :theme_dirty, false)
|
||
|
|
else
|
||
|
|
socket
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
defp maybe_save_site(socket) do
|
||
|
|
if socket.assigns[:site_dirty] do
|
||
|
|
site = socket.assigns
|
||
|
|
Site.put_announcement(site.site_announcement_text, site.site_announcement_link, site.site_announcement_style)
|
||
|
|
Site.put_footer_content(site.site_footer_about, site.site_footer_copyright, site.site_footer_show_newsletter)
|
||
|
|
assign(socket, :site_dirty, false)
|
||
|
|
else
|
||
|
|
socket
|
||
|
|
end
|
||
|
|
end
|
||
|
|
```
|
||
|
|
|
||
|
|
### Phase 4: Visual Indicators (0.5h)
|
||
|
|
|
||
|
|
#### 4.1 Dirty dots on tab labels
|
||
|
|
|
||
|
|
In `editor_sheet` tablist:
|
||
|
|
|
||
|
|
```heex
|
||
|
|
<button role="tab" ...>
|
||
|
|
Page
|
||
|
|
<span :if={@page_dirty} class="editor-tab-dirty-dot" aria-label="unsaved changes"></span>
|
||
|
|
</button>
|
||
|
|
```
|
||
|
|
|
||
|
|
CSS:
|
||
|
|
```css
|
||
|
|
.editor-tab-dirty-dot {
|
||
|
|
display: inline-block;
|
||
|
|
width: 6px;
|
||
|
|
height: 6px;
|
||
|
|
border-radius: 50%;
|
||
|
|
background: var(--color-warning);
|
||
|
|
margin-left: 4px;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 4.2 Update EditorKeyboard hook
|
||
|
|
|
||
|
|
Pass unified dirty state to the hook:
|
||
|
|
|
||
|
|
```heex
|
||
|
|
<div id="editor-sheet-inner"
|
||
|
|
phx-hook="EditorKeyboard"
|
||
|
|
data-dirty={to_string(@any_dirty)}>
|
||
|
|
```
|
||
|
|
|
||
|
|
### Phase 5: Navigation Warning Flow (1h)
|
||
|
|
|
||
|
|
#### 5.1 Update EditorKeyboard JS hook
|
||
|
|
|
||
|
|
Modify the navigation intercept in `assets/js/app.js`:
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// On navigation attempt when dirty
|
||
|
|
if (this.el.dataset.dirty === "true") {
|
||
|
|
e.preventDefault();
|
||
|
|
e.stopImmediatePropagation();
|
||
|
|
|
||
|
|
// Send event to LiveView to show modal
|
||
|
|
this.pushEvent("editor_nav_blocked", { href: href });
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 5.2 Add navigation modal component
|
||
|
|
|
||
|
|
```heex
|
||
|
|
<dialog :if={@editor_nav_blocked} class="editor-nav-modal" open>
|
||
|
|
<div class="editor-nav-modal-content">
|
||
|
|
<h3>Unsaved changes</h3>
|
||
|
|
<p>You have unsaved changes. What would you like to do?</p>
|
||
|
|
<div class="editor-nav-modal-actions">
|
||
|
|
<button phx-click="editor_save_and_navigate">Save & continue</button>
|
||
|
|
<button phx-click="editor_discard_and_navigate">Discard & continue</button>
|
||
|
|
<button phx-click="editor_cancel_navigate">Cancel</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</dialog>
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 5.3 Handle modal events
|
||
|
|
|
||
|
|
```elixir
|
||
|
|
defp handle_editor_event("editor_nav_blocked", %{"href" => href}, socket) do
|
||
|
|
{:halt, assign(socket, editor_nav_blocked: href)}
|
||
|
|
end
|
||
|
|
|
||
|
|
defp handle_editor_event("editor_save_and_navigate", _params, socket) do
|
||
|
|
socket = save_all_tabs(socket)
|
||
|
|
{:halt, push_navigate(socket, to: socket.assigns.editor_nav_blocked)}
|
||
|
|
end
|
||
|
|
|
||
|
|
defp handle_editor_event("editor_discard_and_navigate", _params, socket) do
|
||
|
|
socket = revert_all_tabs(socket)
|
||
|
|
{:halt, push_navigate(socket, to: socket.assigns.editor_nav_blocked)}
|
||
|
|
end
|
||
|
|
|
||
|
|
defp handle_editor_event("editor_cancel_navigate", _params, socket) do
|
||
|
|
# Re-open sheet if collapsed
|
||
|
|
socket =
|
||
|
|
socket
|
||
|
|
|> assign(:editor_nav_blocked, nil)
|
||
|
|
|> assign(:editor_sheet_state, :open)
|
||
|
|
{:halt, socket}
|
||
|
|
end
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 5.4 Add revert logic
|
||
|
|
|
||
|
|
```elixir
|
||
|
|
defp revert_all_tabs(socket) do
|
||
|
|
socket
|
||
|
|
|> maybe_revert_page()
|
||
|
|
|> maybe_revert_theme()
|
||
|
|
|> maybe_revert_site()
|
||
|
|
|> assign(:any_dirty, false)
|
||
|
|
end
|
||
|
|
|
||
|
|
defp maybe_revert_theme(socket) do
|
||
|
|
if socket.assigns[:theme_dirty] do
|
||
|
|
original = socket.assigns.theme_editor_original
|
||
|
|
# Regenerate CSS from original
|
||
|
|
# Reset theme_editor_settings to original
|
||
|
|
else
|
||
|
|
socket
|
||
|
|
end
|
||
|
|
end
|
||
|
|
```
|
||
|
|
|
||
|
|
## Files to Modify
|
||
|
|
|
||
|
|
| File | Changes |
|
||
|
|
|------|---------|
|
||
|
|
| `lib/berrypod_web/page_editor_hook.ex` | Dirty tracking, save handlers, revert logic |
|
||
|
|
| `lib/berrypod_web/components/shop_components/layout.ex` | Tab dirty dots, Save button, nav modal |
|
||
|
|
| `lib/berrypod_web/page_renderer.ex` | Pass unified dirty state to editor_sheet |
|
||
|
|
| `assets/js/app.js` | EditorKeyboard nav intercept |
|
||
|
|
| `assets/css/admin/components.css` | Dirty dot styles, nav modal styles |
|
||
|
|
|
||
|
|
## Testing Plan
|
||
|
|
|
||
|
|
1. **Page tab**: Edit blocks → switch tabs → switch back → verify changes preserved
|
||
|
|
2. **Theme tab**: Change color → switch tabs → verify preview still shows new color
|
||
|
|
3. **Site tab**: Edit announcement → switch tabs → verify preview shows new text
|
||
|
|
4. **Save all**: Make changes in all tabs → click Save → verify all persisted
|
||
|
|
5. **Navigate away**: Make changes → click link → verify warning modal
|
||
|
|
6. **Save & continue**: Warning modal → Save & continue → verify saved and navigated
|
||
|
|
7. **Discard & continue**: Warning modal → Discard → verify reverted and navigated
|
||
|
|
8. **Cancel**: Warning modal → Cancel → verify sheet opens, changes preserved
|
||
|
|
9. **Browser refresh**: Make changes → F5 → verify beforeunload warning
|
||
|
|
10. **Tab close**: Make changes → close tab → verify beforeunload warning
|
||
|
|
|
||
|
|
## Rollback Plan
|
||
|
|
|
||
|
|
If issues arise, the immediate fix is to re-enable auto-save for Theme/Site tabs by calling the persistence functions in the event handlers again. The dirty tracking can remain in place.
|
||
|
|
|
||
|
|
## Future Enhancements
|
||
|
|
|
||
|
|
- **Undo/redo for Theme/Site**: Currently only Page tab has history
|
||
|
|
- **Draft persistence**: Save drafts to localStorage for recovery after crash
|
||
|
|
- **Keyboard shortcuts**: Cmd+S to save all
|