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-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-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-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-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-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 |
|
| 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(:output_entries, [])
|
||||||
|> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|
|> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|
||||||
|> assign(:panel_git_entries, [])
|
|> assign(:panel_git_entries, [])
|
||||||
|
|> assign(:auto_save_timers, %{})
|
||||||
|> reload_shell(workbench)
|
|> reload_shell(workbench)
|
||||||
|> apply_url_params(params)
|
|> apply_url_params(params)
|
||||||
|> tap(&sync_menu_bar_locale/1)}
|
|> tap(&sync_menu_bar_locale/1)}
|
||||||
@@ -252,6 +253,8 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do
|
def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do
|
||||||
|
socket = auto_save_current_post(socket)
|
||||||
|
|
||||||
workbench =
|
workbench =
|
||||||
Workbench.open_tab(
|
Workbench.open_tab(
|
||||||
socket.assigns.workbench,
|
socket.assigns.workbench,
|
||||||
@@ -270,6 +273,8 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do
|
def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do
|
||||||
|
socket = auto_save_current_post(socket)
|
||||||
|
|
||||||
type_atom = BoundedAtoms.editor_route(type, :post)
|
type_atom = BoundedAtoms.editor_route(type, :post)
|
||||||
workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id)
|
workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id)
|
||||||
tab_meta = Map.delete(socket.assigns.tab_meta, {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 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
|
defp save_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
|
||||||
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
|
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
|
||||||
socket
|
socket
|
||||||
|
|||||||
@@ -47,6 +47,40 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
|||||||
{:noreply, assign(socket, :workbench, workbench)}
|
{:noreply, assign(socket, :workbench, workbench)}
|
||||||
end
|
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
|
def handle_info({:editor_command, action, params}, socket, callbacks) do
|
||||||
{:noreply, callbacks.apply_shell_command.(socket, action, params)}
|
{:noreply, callbacks.apply_shell_command.(socket, action, params)}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -62,6 +62,18 @@ defmodule BDS.Desktop.ShellLive.Notify do
|
|||||||
:ok
|
:ok
|
||||||
end
|
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
|
@spec parent(term()) :: :ok
|
||||||
def parent(message) do
|
def parent(message) do
|
||||||
send(self(), message)
|
send(self(), message)
|
||||||
|
|||||||
@@ -185,6 +185,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
Notify.dirty(:post, post_id, dirty?)
|
Notify.dirty(:post, post_id, dirty?)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if dirty? do
|
||||||
|
Notify.schedule_auto_save(:post, post_id)
|
||||||
|
end
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -471,6 +475,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
Atom.to_string(record_status(record)))
|
Atom.to_string(record_status(record)))
|
||||||
|
|
||||||
Notify.dirty(:post, post.id, false)
|
Notify.dirty(:post, post.id, false)
|
||||||
|
Notify.cancel_auto_save(:post, post.id)
|
||||||
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post saved"))
|
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post saved"))
|
||||||
socket
|
socket
|
||||||
|
|
||||||
|
|||||||
@@ -1337,6 +1337,142 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert saved_post.excerpt == "Saved excerpt"
|
assert saved_post.excerpt == "Saved excerpt"
|
||||||
end
|
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", %{
|
test "menu editor adds a submenu, nests an entry, and saves the opml", %{
|
||||||
project: project,
|
project: project,
|
||||||
temp_dir: temp_dir
|
temp_dir: temp_dir
|
||||||
|
|||||||
Reference in New Issue
Block a user