D4-6: tag editor UI tests + delete confirmation overlay
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
321
test/bds/desktop/tag_editor_live_test.exs
Normal file
321
test/bds/desktop/tag_editor_live_test.exs
Normal 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
|
||||
Reference in New Issue
Block a user