D4-6: tag editor UI tests + delete confirmation overlay

This commit is contained in:
2026-05-30 20:30:39 +02:00
parent f6e1b679f0
commit c30757b3b7
5 changed files with 369 additions and 22 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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 ->

View File

@@ -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