fix: A1-5 implement post editor auto-save after 3000ms idle, on tab switch, and on unmount
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user