From 2be751400d404d4271f7ed5a341d4b27e0b75a0a Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 2 May 2026 19:50:13 +0200 Subject: [PATCH] fix: parity in behaviour for scripts, templates and posts --- lib/bds/desktop/shell_live.ex | 10 ++ .../desktop/shell_live/code_entity_editor.ex | 150 +++++++++++------- .../script_editor.html.heex | 15 +- .../template_editor.html.heex | 13 +- lib/bds/scripts.ex | 28 +++- lib/bds/templates.ex | 27 +++- priv/ui/app.css | 50 ++++-- test/bds/desktop/shell_live_test.exs | 120 +++++++++++++- 8 files changed, 331 insertions(+), 82 deletions(-) diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 0166020..ac86acf 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -885,6 +885,11 @@ defmodule BDS.Desktop.ShellLive do {:noreply, CodeEntityEditor.save_script(socket, &reload_shell/2, &append_output_entry/5)} end + def handle_event("publish_script_editor", %{"id" => _script_id}, socket) do + {:noreply, + CodeEntityEditor.publish_script(socket, &reload_shell/2, &append_output_entry/5)} + end + def handle_event("run_script_editor", _params, socket) do {:noreply, CodeEntityEditor.run_script(socket, &reload_shell/2, &append_output_entry/5)} end @@ -905,6 +910,11 @@ defmodule BDS.Desktop.ShellLive do {:noreply, CodeEntityEditor.save_template(socket, &reload_shell/2, &append_output_entry/5)} end + def handle_event("publish_template_editor", %{"id" => _template_id}, socket) do + {:noreply, + CodeEntityEditor.publish_template(socket, &reload_shell/2, &append_output_entry/5)} + end + def handle_event("validate_template_editor", _params, socket) do {:noreply, CodeEntityEditor.validate_template(socket, &reload_shell/2, &append_output_entry/5)} diff --git a/lib/bds/desktop/shell_live/code_entity_editor.ex b/lib/bds/desktop/shell_live/code_entity_editor.ex index a1e0718..e48847e 100644 --- a/lib/bds/desktop/shell_live/code_entity_editor.ex +++ b/lib/bds/desktop/shell_live/code_entity_editor.ex @@ -31,38 +31,12 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do @spec save_script(term(), term(), term()) :: term() def save_script(socket, reload, append_output) do - %{id: script_id} = socket.assigns.current_tab + persist_script(socket, :save, reload, append_output) + end - case Scripts.get_script(script_id) do - nil -> - reload.(socket, socket.assigns.workbench) - - %Script{} = script -> - draft = current_script_draft(socket.assigns, script) - - case Scripting.validate(draft["content"] || "") do - :ok -> - case Scripts.update_script(script.id, script_attrs(draft)) do - {:ok, _updated} -> - socket - |> assign( - :script_editor_drafts, - Map.delete(socket.assigns.script_editor_drafts, script.id) - ) - |> reload.(socket.assigns.workbench) - - {:error, reason} -> - socket - |> append_output.(translated("Scripts"), inspect(reason), nil, "error") - |> reload.(socket.assigns.workbench) - end - - {:error, reason} -> - socket - |> append_output.(translated("Scripts"), inspect(reason), nil, "error") - |> reload.(socket.assigns.workbench) - end - end + @spec publish_script(term(), term(), term()) :: term() + def publish_script(socket, reload, append_output) do + persist_script(socket, :publish, reload, append_output) end @spec check_script(term(), term(), term()) :: term() @@ -148,33 +122,12 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do @spec save_template(term(), term(), term()) :: term() def save_template(socket, reload, append_output) do - %{id: template_id} = socket.assigns.current_tab + persist_template(socket, :save, reload, append_output) + end - case Templates.get_template(template_id) do - nil -> - reload.(socket, socket.assigns.workbench) - - %Template{} = template -> - draft = current_template_draft(socket.assigns, template) - - with {:ok, %{valid: true}} <- MCP.validate_template(draft["content"] || ""), - {:ok, _updated} <- Templates.update_template(template.id, template_attrs(draft)) do - socket - |> assign( - :template_editor_drafts, - Map.delete(socket.assigns.template_editor_drafts, template.id) - ) - |> reload.(socket.assigns.workbench) - else - {:ok, %{valid: false, errors: errors}} -> - append_output.(socket, translated("Templates"), Enum.join(errors, "; "), nil, "error") - |> reload.(socket.assigns.workbench) - - {:error, reason} -> - append_output.(socket, translated("Templates"), inspect(reason), nil, "error") - |> reload.(socket.assigns.workbench) - end - end + @spec publish_template(term(), term(), term()) :: term() + def publish_template(socket, reload, append_output) do + persist_template(socket, :publish, reload, append_output) end @spec validate_template(term(), term(), term()) :: term() @@ -239,6 +192,8 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do enabled: draft["enabled"], content: draft["content"], entrypoints: discover_entrypoints(draft["content"]), + status: script.status || :draft, + can_publish?: script.status == :draft, created_at: script.created_at, updated_at: script.updated_at } @@ -263,6 +218,8 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do kind: draft["kind"], enabled: draft["enabled"], content: draft["content"], + status: template.status || :draft, + can_publish?: template.status == :draft, created_at: template.created_at, updated_at: template.updated_at } @@ -271,6 +228,9 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do def build_template(_assigns), do: nil + @spec status_label(term()) :: term() + def status_label(status), do: ShellData.dashboard_status_label(status) + @spec translated(term(), term()) :: term() def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) @@ -342,6 +302,82 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do } end + defp persist_script(socket, action, reload, append_output) do + %{id: script_id} = socket.assigns.current_tab + + case Scripts.get_script(script_id) do + nil -> + reload.(socket, socket.assigns.workbench) + + %Script{} = script -> + draft = current_script_draft(socket.assigns, script) + + case Scripting.validate(draft["content"] || "") do + :ok -> + case Scripts.update_script(script.id, script_attrs(draft)) + |> maybe_publish_script(script.id, action) do + {:ok, _updated} -> + socket + |> assign( + :script_editor_drafts, + Map.delete(socket.assigns.script_editor_drafts, script.id) + ) + |> reload.(socket.assigns.workbench) + + {:error, reason} -> + socket + |> append_output.(translated("Scripts"), inspect(reason), nil, "error") + |> reload.(socket.assigns.workbench) + end + + {:error, reason} -> + socket + |> append_output.(translated("Scripts"), inspect(reason), nil, "error") + |> reload.(socket.assigns.workbench) + end + end + end + + defp persist_template(socket, action, reload, append_output) do + %{id: template_id} = socket.assigns.current_tab + + case Templates.get_template(template_id) do + nil -> + reload.(socket, socket.assigns.workbench) + + %Template{} = template -> + draft = current_template_draft(socket.assigns, template) + + with {:ok, %{valid: true}} <- MCP.validate_template(draft["content"] || ""), + {:ok, _updated} <- + Templates.update_template(template.id, template_attrs(draft)) + |> maybe_publish_template(template.id, action) do + socket + |> assign( + :template_editor_drafts, + Map.delete(socket.assigns.template_editor_drafts, template.id) + ) + |> reload.(socket.assigns.workbench) + else + {:ok, %{valid: false, errors: errors}} -> + append_output.(socket, translated("Templates"), Enum.join(errors, "; "), nil, "error") + |> reload.(socket.assigns.workbench) + + {:error, reason} -> + append_output.(socket, translated("Templates"), inspect(reason), nil, "error") + |> reload.(socket.assigns.workbench) + end + end + end + + defp maybe_publish_script({:ok, _script}, script_id, :publish), do: Scripts.publish_script(script_id) + defp maybe_publish_script(result, _script_id, _action), do: result + + defp maybe_publish_template({:ok, _template}, template_id, :publish), + do: Templates.publish_template(template_id) + + defp maybe_publish_template(result, _template_id, _action), do: result + defp normalize_template_kind("post"), do: :post defp normalize_template_kind("list"), do: :list defp normalize_template_kind("not-found"), do: :"not-found" diff --git a/lib/bds/desktop/shell_live/code_entity_editor_html/script_editor.html.heex b/lib/bds/desktop/shell_live/code_entity_editor_html/script_editor.html.heex index 877fddd..7beb855 100644 --- a/lib/bds/desktop/shell_live/code_entity_editor_html/script_editor.html.heex +++ b/lib/bds/desktop/shell_live/code_entity_editor_html/script_editor.html.heex @@ -1,10 +1,17 @@ -
+
<%= @script_editor.title %>
- - - + <%= status_label(@script_editor.status) %> + <%= if @script_editor.can_publish? do %> + + <% end %> + + +
diff --git a/lib/bds/desktop/shell_live/code_entity_editor_html/template_editor.html.heex b/lib/bds/desktop/shell_live/code_entity_editor_html/template_editor.html.heex index 1c5e6ee..9ee6339 100644 --- a/lib/bds/desktop/shell_live/code_entity_editor_html/template_editor.html.heex +++ b/lib/bds/desktop/shell_live/code_entity_editor_html/template_editor.html.heex @@ -1,9 +1,16 @@ -
+
<%= @template_editor.title %>
- - + <%= status_label(@template_editor.status) %> + <%= if @template_editor.can_publish? do %> + + <% end %> + +
diff --git a/lib/bds/scripts.ex b/lib/bds/scripts.ex index be3cac8..d420261 100644 --- a/lib/bds/scripts.ex +++ b/lib/bds/scripts.ex @@ -43,7 +43,12 @@ defmodule BDS.Scripts do end @spec get_script(String.t()) :: Script.t() | nil - def get_script(script_id), do: Repo.get(Script, script_id) + def get_script(script_id) do + case Repo.get(Script, script_id) do + %Script{} = script -> hydrate_script_content(script) + nil -> nil + end + end @spec publish_script(String.t()) :: script_result() | {:error, :not_found} def publish_script(script_id) do @@ -91,7 +96,8 @@ defmodule BDS.Scripts do script.slug end - content_changed? = has_attr?(attrs, :content) and attr(attrs, :content) != script.content + content_changed? = + has_attr?(attrs, :content) and attr(attrs, :content) != effective_script_content(script) now = Persistence.now_ms() updates = @@ -294,6 +300,24 @@ defmodule BDS.Scripts do end end + defp effective_script_content(%Script{} = script) do + case hydrate_script_content(script) do + %Script{content: content} when is_binary(content) -> content + _other -> "" + end + end + + defp hydrate_script_content(%Script{} = script) do + case script do + %Script{content: content} when is_binary(content) -> script + %Script{status: :published, file_path: file_path} when file_path not in [nil, ""] -> + %{script | content: published_script_body(script)} + + _other -> + script + end + end + defp upsert_script_from_file(project_id, project, path) do contents = File.read!(path) {:ok, %{fields: fields}} = Frontmatter.parse_document(contents) diff --git a/lib/bds/templates.ex b/lib/bds/templates.ex index c457d07..65a341d 100644 --- a/lib/bds/templates.ex +++ b/lib/bds/templates.ex @@ -43,7 +43,12 @@ defmodule BDS.Templates do end @spec get_template(String.t()) :: Template.t() | nil - def get_template(template_id), do: Repo.get(Template, template_id) + def get_template(template_id) do + case Repo.get(Template, template_id) do + %Template{} = template -> hydrate_template_content(template) + nil -> nil + end + end @spec publish_template(String.t()) :: template_result() | {:error, :not_found} def publish_template(template_id) do @@ -97,7 +102,7 @@ defmodule BDS.Templates do end content_changed? = - has_attr?(attrs, :content) and attr(attrs, :content) != template.content + has_attr?(attrs, :content) and attr(attrs, :content) != effective_template_content(template) slug_changed? = next_slug != template.slug now = Persistence.now_ms() @@ -458,6 +463,24 @@ defmodule BDS.Templates do end end + defp effective_template_content(%Template{} = template) do + case hydrate_template_content(template) do + %Template{content: content} when is_binary(content) -> content + _other -> "" + end + end + + defp hydrate_template_content(%Template{} = template) do + case template do + %Template{content: content} when is_binary(content) -> template + %Template{status: :published, file_path: file_path} when file_path not in [nil, ""] -> + %{template | content: published_template_body(template)} + + _other -> + template + end + end + defp upsert_template_from_file(project_id, project, path) do contents = File.read!(path) {:ok, %{fields: fields}} = Frontmatter.parse_document(contents) diff --git a/priv/ui/app.css b/priv/ui/app.css index 3e0324b..cb1b19a 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -899,7 +899,9 @@ button svg * { border-bottom: 1px solid var(--vscode-panel-border); } -.post-editor.editor { +.post-editor.editor, +.scripts-view-shell.editor, +.templates-view-shell.editor { flex: 1; display: flex; flex-direction: column; @@ -907,7 +909,9 @@ button svg * { overflow: hidden; } -.post-editor .editor-header { +.post-editor .editor-header, +.scripts-view-shell.editor .editor-header, +.templates-view-shell.editor .editor-header { display: flex; align-items: center; justify-content: space-between; @@ -918,14 +922,18 @@ button svg * { border-bottom: 1px solid var(--vscode-panel-border); } -.post-editor .editor-tabs { +.post-editor .editor-tabs, +.scripts-view-shell.editor .editor-tabs, +.templates-view-shell.editor .editor-tabs { display: flex; align-items: center; gap: 2px; min-width: 0; } -.post-editor .editor-tab { +.post-editor .editor-tab, +.scripts-view-shell.editor .editor-tab, +.templates-view-shell.editor .editor-tab { display: flex; align-items: center; gap: 6px; @@ -937,12 +945,16 @@ button svg * { border-radius: 4px 4px 0 0; } -.post-editor .editor-tab.active { +.post-editor .editor-tab.active, +.scripts-view-shell.editor .editor-tab.active, +.templates-view-shell.editor .editor-tab.active { background-color: var(--vscode-tab-activeBackground); color: var(--vscode-tab-activeForeground); } -.post-editor .editor-tab-title { +.post-editor .editor-tab-title, +.scripts-view-shell.editor .editor-tab-title, +.templates-view-shell.editor .editor-tab-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -960,7 +972,9 @@ button svg * { white-space: nowrap; } -.post-editor .editor-actions { +.post-editor .editor-actions, +.scripts-view-shell.editor .editor-actions, +.templates-view-shell.editor .editor-actions { display: flex; align-items: center; gap: 8px; @@ -1047,7 +1061,9 @@ button svg * { opacity: 0.7; } -.post-editor .status-badge { +.post-editor .status-badge, +.scripts-view-shell.editor .status-badge, +.templates-view-shell.editor .status-badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; @@ -1055,17 +1071,23 @@ button svg * { text-transform: uppercase; } -.post-editor .status-badge.status-draft { +.post-editor .status-badge.status-draft, +.scripts-view-shell.editor .status-badge.status-draft, +.templates-view-shell.editor .status-badge.status-draft { background-color: rgba(204, 167, 0, 0.2); color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-editorWarning-foreground)); } -.post-editor .status-badge.status-published { +.post-editor .status-badge.status-published, +.scripts-view-shell.editor .status-badge.status-published, +.templates-view-shell.editor .status-badge.status-published { background-color: rgba(115, 201, 145, 0.2); color: var(--vscode-testing-iconPassed); } -.post-editor .status-badge.status-archived { +.post-editor .status-badge.status-archived, +.scripts-view-shell.editor .status-badge.status-archived, +.templates-view-shell.editor .status-badge.status-archived { background-color: rgba(133, 133, 133, 0.2); color: var(--vscode-descriptionForeground); } @@ -1668,6 +1690,8 @@ button svg * { @media (max-width: 980px) { .post-editor .editor-header, + .scripts-view-shell.editor .editor-header, + .templates-view-shell.editor .editor-header, .post-editor .metadata-toggle-header, .post-editor .editor-toolbar { display: flex; @@ -1686,7 +1710,9 @@ button svg * { } .post-editor .editor-toolbar-right, - .post-editor .editor-actions { + .post-editor .editor-actions, + .scripts-view-shell.editor .editor-actions, + .templates-view-shell.editor .editor-actions { justify-content: flex-start; } } diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index bdfc499..3e69be3 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -2227,6 +2227,122 @@ defmodule BDS.Desktop.ShellLiveTest do refute html =~ ~s(phx-value-mode="visual") end + test "script and template editors surface lifecycle state, load published file content, and allow publishing drafts", + %{project: project} do + {:ok, draft_script} = + Scripts.create_script(%{ + project_id: project.id, + title: "Draft Utility", + kind: :utility, + content: "function main() return 'draft' end" + }) + + {:ok, published_script_seed} = + Scripts.create_script(%{ + project_id: project.id, + title: "Published Utility", + kind: :utility, + content: "function main() return 'published script' end" + }) + + assert {:ok, _published_script} = Scripts.publish_script(published_script_seed.id) + published_script = Scripts.get_script(published_script_seed.id) + + {:ok, draft_template} = + Templates.create_template(%{ + project_id: project.id, + title: "Draft Template", + kind: :post, + content: "
draft template
" + }) + + {:ok, published_template_seed} = + Templates.create_template(%{ + project_id: project.id, + title: "Published Template", + kind: :post, + content: "
published template
" + }) + + assert {:ok, _published_template} = Templates.publish_template(published_template_seed.id) + published_template = Templates.get_template(published_template_seed.id) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + published_script_html = + render_click(view, "pin_sidebar_item", %{ + "route" => "scripts", + "id" => published_script.id, + "title" => published_script.title, + "subtitle" => "published" + }) + + assert published_script_html =~ ~s(class="scripts-view-shell editor") + assert published_script_html =~ ~s(data-testid="script-editor") + assert published_script_html =~ ~s(data-testid="script-status-badge") + assert published_script_html =~ ~s(class="status-badge status-published") + assert published_script_html =~ ~s(class="secondary scripts-save-button") + assert published_script_html =~ ~s(class="secondary scripts-run-button") + assert published_script_html =~ ~s(class="secondary scripts-check-button") + assert published_script_html =~ "published" + + assert published_script_html =~ "published script" + + refute published_script_html =~ ~s(data-testid="script-publish-button") + + published_template_html = + render_click(view, "pin_sidebar_item", %{ + "route" => "templates", + "id" => published_template.id, + "title" => published_template.title, + "subtitle" => "published" + }) + + assert published_template_html =~ ~s(class="templates-view-shell editor") + assert published_template_html =~ ~s(data-testid="template-editor") + assert published_template_html =~ ~s(data-testid="template-status-badge") + assert published_template_html =~ ~s(class="status-badge status-published") + assert published_template_html =~ ~s(class="secondary templates-save-button") + assert published_template_html =~ ~s(class="secondary templates-validate-button") + assert published_template_html =~ "published" + + assert published_template_html =~ "published template" + + refute published_template_html =~ ~s(data-testid="template-publish-button") + + draft_script_html = + render_click(view, "pin_sidebar_item", %{ + "route" => "scripts", + "id" => draft_script.id, + "title" => draft_script.title, + "subtitle" => "draft" + }) + + assert draft_script_html =~ ~s(data-testid="script-publish-button") + assert draft_script_html =~ ~s(class="success") + draft_script_html = render_click(view, "publish_script_editor", %{"id" => draft_script.id}) + assert Scripts.get_script(draft_script.id).status == :published + refute draft_script_html =~ ~s(data-testid="script-publish-button") + assert draft_script_html =~ ~s(data-testid="script-status-badge") + assert draft_script_html =~ "published" + + draft_template_html = + render_click(view, "pin_sidebar_item", %{ + "route" => "templates", + "id" => draft_template.id, + "title" => draft_template.title, + "subtitle" => "draft" + }) + + assert draft_template_html =~ ~s(data-testid="template-publish-button") + assert draft_template_html =~ ~s(class="success") + draft_template_html = render_click(view, "publish_template_editor", %{"id" => draft_template.id}) + assert Templates.get_template(draft_template.id).status == :published + refute draft_template_html =~ ~s(data-testid="template-publish-button") + assert draft_template_html =~ ~s(data-testid="template-status-badge") + assert draft_template_html =~ "published" + end + test "media tabs render a real editor and drive explicit save flows", %{ project: project, temp_dir: temp_dir @@ -2505,7 +2621,7 @@ defmodule BDS.Desktop.ShellLiveTest do "subtitle" => script.slug }) - assert script_html =~ ~s(class="scripts-view-shell") + assert script_html =~ ~s(class="scripts-view-shell editor") assert script_html =~ "scripts-monaco" assert script_html =~ ~s(data-monaco-language="lua") assert script_html =~ ~s(data-monaco-word-wrap="on") @@ -2520,7 +2636,7 @@ defmodule BDS.Desktop.ShellLiveTest do "subtitle" => template.slug }) - assert template_html =~ ~s(class="templates-view-shell") + assert template_html =~ ~s(class="templates-view-shell editor") assert template_html =~ "templates-monaco" assert template_html =~ ~s(data-monaco-language="liquid") assert template_html =~ ~s(data-monaco-word-wrap="on")