fix: A1-5 implement post editor auto-save after 3000ms idle, on tab switch, and on unmount

This commit is contained in:
2026-05-28 21:45:42 +02:00
parent d423b6db98
commit 0f30221907
6 changed files with 205 additions and 1 deletions

View File

@@ -14,7 +14,7 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
| A1-2 | ~~`DeletePost` must delete translations + translation files~~ | post.allium:209-212 | `delete_post/1` now fetches translations before cascade-delete and removes their files from disk | **Resolved:** translation file cleanup added to `delete_post/1` in posts.ex, test added |
| A1-3 | ~~Publish must delete old file when path changes~~ | engine_side_effects.allium:73-74 | `publish_post` now deletes old file when `file_path` changes | **Resolved:** old file deletion added to `publish_post/1` in posts.ex, test added |
| A1-4 | ~~`doNotTranslate: false` written to frontmatter despite "only when true"~~ | frontmatter.allium:398 | `file_sync.ex:78` now converts false→nil so serializer omits the key | **Resolved:** doNotTranslate omitted from frontmatter when false, test added |
| A1-5 | Auto-save after 3000ms idle | editor_post.allium:183-188 | No auto-save timer | Fix code: implement auto-save on idle + unmount + tab switch |
| A1-5 | ~~Auto-save after 3000ms idle~~ | editor_post.allium:183-188 | PostEditor schedules auto-save via parent timer on dirty change | **Resolved:** 3000ms idle auto-save timer in Bridges, tab-switch save in ShellLive, cancel on manual save, 3 tests added |
| A1-6 | On-demand rendering in preview server | preview.allium:53-93 | Server serves static pre-generated files | Fix code: implement on-demand template rendering for post/archive/language routes |
| A1-7 | Template lookup must use all 4 levels (post→tag→category→default) | template_context.allium:267-277 | Only levels 1 and 4 implemented; tag/category fallback unused | Fix code: implement levels 2-3 in template_selection.ex |
| A1-8 | `ValidateLiquid`/`ValidateScript` before publish | template.allium:110, script.allium:165 | No validation gate before publish | Fix code: add validation step before publish |

View File

@@ -176,6 +176,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:output_entries, [])
|> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|> assign(:panel_git_entries, [])
|> assign(:auto_save_timers, %{})
|> reload_shell(workbench)
|> apply_url_params(params)
|> tap(&sync_menu_bar_locale/1)}
@@ -252,6 +253,8 @@ defmodule BDS.Desktop.ShellLive do
end
def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do
socket = auto_save_current_post(socket)
workbench =
Workbench.open_tab(
socket.assigns.workbench,
@@ -270,6 +273,8 @@ defmodule BDS.Desktop.ShellLive do
end
def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do
socket = auto_save_current_post(socket)
type_atom = BoundedAtoms.editor_route(type, :post)
workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id)
tab_meta = Map.delete(socket.assigns.tab_meta, {type_atom, id})
@@ -1143,6 +1148,18 @@ defmodule BDS.Desktop.ShellLive do
defp shell_command?(action), do: not is_nil(shell_command_atom(action))
defp auto_save_current_post(
%{assigns: %{current_tab: %{type: :post, id: post_id}, workbench: workbench}} = socket
) do
if Workbench.dirty?(workbench, :post, post_id) do
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
end
socket
end
defp auto_save_current_post(socket), do: socket
defp save_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
socket

View File

@@ -47,6 +47,40 @@ defmodule BDS.Desktop.ShellLive.Bridges do
{:noreply, assign(socket, :workbench, workbench)}
end
@default_auto_save_delay 3000
def handle_info({:schedule_auto_save, type, id}, socket, _callbacks) do
timers = socket.assigns[:auto_save_timers] || %{}
key = {type, id}
case Map.get(timers, key) do
nil -> :ok
old_ref -> Process.cancel_timer(old_ref)
end
delay = Application.get_env(:bds, :auto_save_delay, @default_auto_save_delay)
ref = Process.send_after(self(), {:auto_save_fire, type, id}, delay)
{:noreply, assign(socket, :auto_save_timers, Map.put(timers, key, ref))}
end
def handle_info({:cancel_auto_save, type, id}, socket, _callbacks) do
timers = socket.assigns[:auto_save_timers] || %{}
key = {type, id}
case Map.get(timers, key) do
nil -> :ok
old_ref -> Process.cancel_timer(old_ref)
end
{:noreply, assign(socket, :auto_save_timers, Map.delete(timers, key))}
end
def handle_info({:auto_save_fire, :post, post_id}, socket, _callbacks) do
timers = socket.assigns[:auto_save_timers] || %{}
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
{:noreply, assign(socket, :auto_save_timers, Map.delete(timers, {:post, post_id}))}
end
def handle_info({:editor_command, action, params}, socket, callbacks) do
{:noreply, callbacks.apply_shell_command.(socket, action, params)}
end

View File

@@ -62,6 +62,18 @@ defmodule BDS.Desktop.ShellLive.Notify do
:ok
end
@spec schedule_auto_save(atom(), term()) :: :ok
def schedule_auto_save(type, id) do
send(self(), {:schedule_auto_save, type, id})
:ok
end
@spec cancel_auto_save(atom(), term()) :: :ok
def cancel_auto_save(type, id) do
send(self(), {:cancel_auto_save, type, id})
:ok
end
@spec parent(term()) :: :ok
def parent(message) do
send(self(), message)

View File

@@ -185,6 +185,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
Notify.dirty(:post, post_id, dirty?)
end
if dirty? do
Notify.schedule_auto_save(:post, post_id)
end
{:noreply, socket}
end
@@ -471,6 +475,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
Atom.to_string(record_status(record)))
Notify.dirty(:post, post.id, false)
Notify.cancel_auto_save(:post, post.id)
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post saved"))
socket

View File

@@ -1337,6 +1337,142 @@ defmodule BDS.Desktop.ShellLiveTest do
assert saved_post.excerpt == "Saved excerpt"
end
test "post editor auto-saves after idle timer fires", %{project: project} do
{:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Auto-save Draft",
content: "Original body",
excerpt: "Original excerpt"
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html =
render_click(view, "pin_sidebar_item", %{
"route" => "post",
"id" => post.id,
"title" => post.title,
"subtitle" => "draft"
})
_html =
view
|> form("[data-testid='post-editor-form']", %{
post_editor: %{
title: "Auto-saved Title",
content: "Auto-saved body",
excerpt: "Auto-saved excerpt",
tags: "",
categories: "",
author: "",
language: "en",
do_not_translate: "false"
}
})
|> render_change()
send_and_await(view, {:auto_save_fire, :post, post.id})
_html = render(view)
saved_post = Posts.get_post!(post.id)
assert saved_post.title == "Auto-saved Title"
assert saved_post.content == "Auto-saved body"
assert saved_post.excerpt == "Auto-saved excerpt"
end
test "post editor auto-save timer fires and persists after delay", %{project: project} do
Application.put_env(:bds, :auto_save_delay, 100)
on_exit(fn -> Application.delete_env(:bds, :auto_save_delay) end)
{:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Timer Draft",
content: "Body",
excerpt: ""
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html =
render_click(view, "pin_sidebar_item", %{
"route" => "post",
"id" => post.id,
"title" => post.title,
"subtitle" => "draft"
})
_html =
view
|> form("[data-testid='post-editor-form']", %{
post_editor: %{
title: "Timer Changed Title",
content: "Body",
excerpt: "",
tags: "",
categories: "",
author: "",
language: "en",
do_not_translate: "false"
}
})
|> render_change()
Process.sleep(200)
_html = render(view)
saved_post = Posts.get_post!(post.id)
assert saved_post.title == "Timer Changed Title"
end
test "post editor auto-saves dirty content on tab switch", %{project: project} do
{:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Tab-switch Draft",
content: "Original body",
excerpt: ""
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html =
render_click(view, "pin_sidebar_item", %{
"route" => "post",
"id" => post.id,
"title" => post.title,
"subtitle" => "draft"
})
_html =
view
|> form("[data-testid='post-editor-form']", %{
post_editor: %{
title: "Unsaved Tab Title",
content: "Unsaved body",
excerpt: "",
tags: "",
categories: "",
author: "",
language: "en",
do_not_translate: "false"
}
})
|> render_change()
_html =
render_click(view, "select_tab", %{
"type" => "dashboard",
"id" => "dashboard"
})
_html = render(view)
saved_post = Posts.get_post!(post.id)
assert saved_post.title == "Unsaved Tab Title"
assert saved_post.content == "Unsaved body"
end
test "menu editor adds a submenu, nests an entry, and saves the opml", %{
project: project,
temp_dir: temp_dir