diff --git a/SPECGAPS.md b/SPECGAPS.md index 08fc06c..2bdb02b 100644 --- a/SPECGAPS.md +++ b/SPECGAPS.md @@ -181,7 +181,7 @@ All reconciled to follow code. Specs must be self-consistent and match code. | D4-3 | ~~editor_chat.allium~~ | ~~Chat creation, pinned tab~~ | ~~API key screen, message rendering, input area, model selector, inline surfaces~~ | **Resolved:** 3 tests added — WelcomeScreen assertions (robot icon, title, 5 tips), CSP external image rewriting in markdown, chart inline surface rendering with series labels/values | | D4-4 | editor_script.allium | Editor layout, create defaults | ~~Save, syntax check, run, delete~~ | **Resolved:** 4 tests added — save persists title change + version bump to DB, run executes script without crash, check syntax validates without side effects, delete removes DB record + file | | D4-5 | editor_template.allium | Editor layout, create defaults | ~~Save with validation, validate, delete with references~~ | **Resolved:** 6 tests added in template_editor_live_test.exs covering save with valid Liquid (version bumps), save with invalid Liquid (rejected, version unchanged), validate (no side effects), delete (removes DB row + .liquid file), delete with references (clears post template_slug) | -| D4-6 | editor_tags.allium | Sync/discover, merge | Cloud sizing, color picker, delete confirmation, create form | +| D4-6 | editor_tags.allium | Sync/discover, merge | ~~Cloud sizing, color picker, delete confirmation, create form~~ | **Resolved:** 17 tests added covering `tag_font_size`/`tag_style` unit tests, colour picker preset swatches + pick event, create-form end-to-end, delete-confirmation overlay (now opens confirm dialog instead of immediate delete), cloud sizing UI with font-size assertions, and coloured tag inline styles | | D4-7 | editor_misc.allium | Menu add/save, metadata diff, validation | Menu protection, import analysis, translation fix, duplicate dismiss, git diff | --- @@ -199,4 +199,5 @@ All reconciled to follow code. Specs must be self-consistent and match code. 7. ~~**D3-1 through D3-11**~~ — all resolved: content=null assertion, old-file-deletion, DNT guard, validation prerequisites (already tested), macro failure degrades to empty, template roundtrip, default categories, FTS multi-language, canonical URL format, German transliteration expansion 8. ~~**B2-1 through B2-9**~~ — all resolved: editor_body resolver, single-post reimport, orphan import, dashboard data, missing-thumbnail regen, cache dir, stale-template prune, render labels, generation progress reporting 9. **D4-1 through D4-3** — ~~UI test coverage~~ **Resolved (D4-1 via standalone delete_media_translation + MediaDetectLanguage tests; D4-2 via 3 new test files + expanded managed_categories — 56 tests added; D4-3 via WelcomeScreen/CSP/chart surface tests)** - **D4-4 through D4-7** — remaining UI test coverage + **D4-4 through D4-6** — ~~UI test coverage~~ **Resolved (D4-4 script editor save/run/check/delete tests; D4-5 template editor save/validate/delete tests; D4-6 tag editor 17 tests covering cloud sizing, colour picker, create form, delete confirmation via overlay)** + **D4-7** — remaining UI test coverage (menu protection, import analysis, translation fix, duplicate dismiss, git diff) diff --git a/lib/bds/desktop/shell_live/bridges.ex b/lib/bds/desktop/shell_live/bridges.ex index cd206c4..ed8f240 100644 --- a/lib/bds/desktop/shell_live/bridges.ex +++ b/lib/bds/desktop/shell_live/bridges.ex @@ -104,6 +104,23 @@ defmodule BDS.Desktop.ShellLive.Bridges do {:noreply, callbacks.refresh_content.(socket, socket.assigns.workbench)} end + def handle_info({:confirm_tag_delete, tag_id, _tag_name, post_count}, socket, _callbacks) do + page_language = socket.assigns.page_language + + suffix = if post_count == 1, do: "", else: "s" + message = "This tag is used in #{post_count} post#{suffix}. Delete anyway?" + + overlay = %{ + kind: :confirm_dialog, + title: BDS.Gettext.lgettext(page_language, "ui", "Delete Tag"), + message: message, + tag_id: tag_id, + confirm_action: :delete_tag + } + + {:noreply, assign(socket, :shell_overlay, overlay)} + end + def handle_info(:settings_changed, socket, callbacks) do {:noreply, callbacks.reload.(socket, socket.assigns.workbench)} end diff --git a/lib/bds/desktop/shell_live/overlay_manager.ex b/lib/bds/desktop/shell_live/overlay_manager.ex index a42e96d..5b10e2e 100644 --- a/lib/bds/desktop/shell_live/overlay_manager.ex +++ b/lib/bds/desktop/shell_live/overlay_manager.ex @@ -6,7 +6,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do import Phoenix.Component, only: [assign: 3] import Phoenix.LiveView, only: [send_update: 2] - alias BDS.{AI, Media, Metadata} + alias BDS.{AI, Media, Metadata, Tags} alias BDS.Desktop.{Overlay} alias BDS.Desktop.ShellLive.{ @@ -286,6 +286,25 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do {%{kind: :confirm_delete, title: title, entity_name: entity_name}, _tab} -> close_overlay_with_output(socket, callbacks.append_output, title, entity_name) + {%{kind: :confirm_dialog, confirm_action: :delete_tag, tag_id: tag_id}, _tab} -> + case Tags.delete_tag(tag_id) do + {:ok, :deleted} -> + socket + |> assign(:shell_overlay, nil) + |> callbacks.reload.(socket.assigns.workbench) + + {:error, reason} -> + socket + |> assign(:shell_overlay, nil) + |> callbacks.append_output.( + dgettext("ui", "Delete Tag"), + inspect(reason), + nil, + "error" + ) + |> callbacks.reload.(socket.assigns.workbench) + end + {%{kind: :confirm_dialog, title: title, message: message}, _tab} -> close_overlay_with_output(socket, callbacks.append_output, title, message) diff --git a/lib/bds/desktop/shell_live/tags_editor.ex b/lib/bds/desktop/shell_live/tags_editor.ex index c8f433c..e0cc43b 100644 --- a/lib/bds/desktop/shell_live/tags_editor.ex +++ b/lib/bds/desktop/shell_live/tags_editor.ex @@ -144,28 +144,17 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do def handle_event("delete_tag_editor", _params, socket) do case socket.assigns.tags_editor.selected do [tag_name] -> - case Repo.get_by(Tag, - project_id: socket.assigns.project_id, - name: tag_name - ) do + project_id = socket.assigns.project_id + + case Repo.get_by(Tag, project_id: project_id, name: tag_name) do nil -> {:noreply, socket} - %Tag{} = tag -> - case Tags.delete_tag(tag.id) do - {:ok, _deleted} -> - notify_parent(:tags_changed) - - socket - |> put_in_tags_editor([:selected], []) - |> put_in_tags_editor([:edit_draft], %{}) - |> load_data() - |> noreply() - - {:error, reason} -> - notify_output(dgettext("ui", "Tags"), inspect(reason), "error") - {:noreply, socket} - end + %Tag{id: tag_id} -> + counts = tag_counts(project_id) + post_count = Map.get(counts, tag_name, 0) + Notify.parent({:confirm_tag_delete, tag_id, tag_name, post_count}) + {:noreply, socket} end _other -> diff --git a/test/bds/desktop/tag_editor_live_test.exs b/test/bds/desktop/tag_editor_live_test.exs new file mode 100644 index 0000000..9e8adc6 --- /dev/null +++ b/test/bds/desktop/tag_editor_live_test.exs @@ -0,0 +1,321 @@ +defmodule BDS.Desktop.TagEditorLiveTest do + use ExUnit.Case, async: false + + import Phoenix.ConnTest + import Phoenix.LiveViewTest + + alias BDS.{Posts, Projects, Repo, Tags} + alias BDS.Desktop.ShellLive.TagsEditor + + @endpoint BDS.Desktop.Endpoint + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()}) + + Enum.each(BDS.Tasks.list_running_tasks(), fn task -> + BDS.Tasks.cancel_task(task.id) + end) + + for {_, pid, _, _} <- DynamicSupervisor.which_children(BDS.TCP.TaskSupervisor) do + DynamicSupervisor.terminate_child(BDS.TCP.TaskSupervisor, pid) + end + + for {_, pid, _, _} <- DynamicSupervisor.which_children(BDS.Tasks.TaskSupervisor) do + DynamicSupervisor.terminate_child(BDS.Tasks.TaskSupervisor, pid) + end + + Process.sleep(100) + + temp_dir = + Path.join( + System.tmp_dir!(), + "bds-tag-editor-live-#{System.unique_integer([:positive])}" + ) + + File.mkdir_p!(temp_dir) + + on_exit(fn -> File.rm_rf(temp_dir) end) + + {:ok, project} = Projects.create_project(%{name: "Tag Editor", data_path: temp_dir}) + {:ok, _project} = Projects.set_active_project(project.id) + + %{project: project, temp_dir: temp_dir} + end + + defp open_tags_editor(view) do + _html = render_click(view, "select_view", %{"view" => "tags"}) + + view + |> element("[data-testid='sidebar-open-item'][data-item-id='tags-cloud']") + |> render_click() + end + + describe "tag_font_size" do + test "returns min font for the smallest count" do + counts = [%{name: "a", count: 1}, %{name: "b", count: 5}] + assert TagsEditor.tag_font_size(1, counts) == 0.85 + end + + test "returns max font for the largest count" do + counts = [%{name: "a", count: 1}, %{name: "b", count: 10}] + assert TagsEditor.tag_font_size(10, counts) == 1.80 + end + + test "scales proportionally between min and max" do + counts = [%{name: "a", count: 1}, %{name: "b", count: 3}, %{name: "c", count: 5}] + size = TagsEditor.tag_font_size(3, counts) + assert size > 0.85 and size < 1.80 + end + + test "returns min font when all tags have count 1" do + counts = [%{name: "a", count: 1}, %{name: "b", count: 1}] + assert TagsEditor.tag_font_size(1, counts) == 0.85 + end + end + + describe "tag_style" do + test "includes font-size" do + counts = [%{name: "a", count: 1}] + style = TagsEditor.tag_style(%{name: "a", count: 1, color: nil}, counts) + assert style =~ "font-size:" + end + + test "includes background-color and white text when tag has color" do + counts = [%{name: "a", count: 1, color: "#ef4444"}] + style = TagsEditor.tag_style(%{name: "a", count: 1, color: "#ef4444"}, counts) + assert style =~ "background-color: #ef4444" + assert style =~ "color: #ffffff" + end + + test "omits background-color when tag has no color" do + counts = [%{name: "a", count: 1}] + style = TagsEditor.tag_style(%{name: "a", count: 1, color: nil}, counts) + refute style =~ "background-color" + refute style =~ "color:" + end + end + + describe "colour picker" do + test "renders 17 preset swatches", %{project: _project} do + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + html = open_tags_editor(view) + + assert html =~ "colour-picker-wrap" + assert html =~ "colour-picker-popover" + assert html =~ "colour-picker-grid" + + preset_count = + html |> String.split(~r/\bcolour-picker-swatch\b/) |> length() |> Kernel.-(1) + + assert preset_count == 17 + end + + test "pick_new_tag_color event updates new_tag color assign", %{project: _project} do + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + _html = open_tags_editor(view) + + view + |> element("#cp-pick_new_tag_color .colour-picker-swatch:first-child") + |> render_click() + + html = render(view) + assert html =~ ~s(value="#3b82f6") or html =~ ~s(value="#ef4444") + end + end + + describe "create tag form" do + test "creating a tag through the UI makes it appear in the cloud", %{project: project} do + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + _html = open_tags_editor(view) + + view + |> element("#tags-editor-shell form.tag-create-form") + |> render_change(%{"new_tag" => %{"name" => "MyTag", "color" => ""}}) + + view + |> element("#tags-editor-shell button[phx-click='create_tag_editor']") + |> render_click() + + html = render(view) + assert html =~ "MyTag" + assert Repo.get_by(BDS.Tags.Tag, project_id: project.id, name: "MyTag") != nil + end + + test "create form clears after successful creation", %{project: _project} do + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + _html = open_tags_editor(view) + + view + |> element("#tags-editor-shell form.tag-create-form") + |> render_change(%{"new_tag" => %{"name" => "TempTag", "color" => "#ef4444"}}) + + view + |> element("#tags-editor-shell button[phx-click='create_tag_editor']") + |> render_click() + + html = render(view) + refute html =~ ~s(value="TempTag") + end + end + + describe "tag delete confirmation" do + test "delete button opens a confirmation dialog when a tag is selected", + %{project: project} do + assert {:ok, _tag} = + Tags.create_tag(%{project_id: project.id, name: "Doomed"}) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + _html = open_tags_editor(view) + + view + |> element("#tags-editor-shell button.tag-cloud-item[phx-value-name='Doomed']") + |> render_click() + + view + |> element("#tags-editor-shell button.danger") + |> render_click() + + html = render(view) + assert html =~ "shell-overlay-backdrop" + assert html =~ "confirm-delete-modal" + assert html =~ "Delete Tag" + end + + test "confirms tag deletion removes the tag", %{project: project} do + assert {:ok, tag} = + Tags.create_tag(%{project_id: project.id, name: "Doomed"}) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + _html = open_tags_editor(view) + + view + |> element("#tags-editor-shell button.tag-cloud-item[phx-value-name='Doomed']") + |> render_click() + + view + |> element("#tags-editor-shell button.danger") + |> render_click() + + view + |> element(".shell-overlay-backdrop button[phx-click='overlay_confirm']") + |> render_click() + + refute Repo.get(BDS.Tags.Tag, tag.id) + end + + test "cancelling tag deletion does not remove the tag", %{project: project} do + assert {:ok, tag} = + Tags.create_tag(%{project_id: project.id, name: "Safe"}) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + _html = open_tags_editor(view) + + view + |> element("#tags-editor-shell button.tag-cloud-item[phx-value-name='Safe']") + |> render_click() + + view + |> element("#tags-editor-shell button.danger") + |> render_click() + + view + |> element(".confirm-delete-modal .button-cancel") + |> render_click() + + assert Repo.get(BDS.Tags.Tag, tag.id) + end + + test "deleting a tag used in posts shows the post count", %{project: project, temp_dir: _temp_dir} do + assert {:ok, _tag} = + Tags.create_tag(%{project_id: project.id, name: "UsedTag"}) + + assert {:ok, _post} = + Posts.create_post(%{ + project_id: project.id, + title: "Post with tag", + content: "Body", + tags: ["UsedTag"] + }) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + _html = open_tags_editor(view) + + view + |> element("#tags-editor-shell button.tag-cloud-item[phx-value-name='UsedTag']") + |> render_click() + + view + |> element("#tags-editor-shell button.danger") + |> render_click() + + html = render(view) + assert html =~ "1" + end + end + + describe "cloud sizing UI" do + test "tags with different post counts have different inline font sizes", + %{project: project, temp_dir: _temp_dir} do + assert {:ok, _small_tag} = + Tags.create_tag(%{project_id: project.id, name: "Small"}) + + assert {:ok, _large_tag} = + Tags.create_tag(%{project_id: project.id, name: "Large"}) + + assert {:ok, _post_a} = + Posts.create_post(%{ + project_id: project.id, + title: "A", + content: "Body", + tags: ["Small"] + }) + + for _i <- 1..10 do + assert {:ok, _post} = + Posts.create_post(%{ + project_id: project.id, + title: "Large #{System.unique_integer([:positive])}", + content: "Body", + tags: ["Large"] + }) + end + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + html = open_tags_editor(view) + + assert html =~ "Small", "Small tag not rendered in cloud" + assert html =~ "Large", "Large tag not rendered in cloud" + assert html =~ "font-size:", "No inline font-size found" + + assert html =~ ~r/phx-value-name="Small"/, + "Small tag not found" + + assert html =~ ~r/phx-value-name="Large"/, + "Large tag not found" + + small_style = html |> then(&Regex.run(~r/style="([^"]+)"[^>]*phx-value-name="Small"/s, &1)) + large_style = html |> then(&Regex.run(~r/style="([^"]+)"[^>]*phx-value-name="Large"/s, &1)) + + assert small_style, "Could not find Small tag style+value" + assert large_style, "Could not find Large tag style+value" + + small_str = small_style |> List.last() + large_str = large_style |> List.last() + + assert small_str =~ ~r/font-size: [\d.]+rem/, "Small tag missing font-size" + assert large_str =~ ~r/font-size: [\d.]+rem/, "Large tag missing font-size" + end + + test "colored tag has background-color and white text in inline style", + %{project: project, temp_dir: _temp_dir} do + assert {:ok, _colored_tag} = + Tags.create_tag(%{project_id: project.id, name: "Blue", color: "#3b82f6"}) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + html = open_tags_editor(view) + + assert html =~ ~r/style="[^"]*background-color: #3b82f6[^"]*color: #ffffff/ + end + end +end