From 7463875b81091209eb35733570a276805a82a0c0 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 1 May 2026 14:01:17 +0200 Subject: [PATCH] chore: and again one more god module down --- CODESMELL.md | 9 +- lib/bds/desktop/shell_live/menu_editor.ex | 606 +----------------- .../menu_editor/draft_management.ex | 132 ++++ .../shell_live/menu_editor/page_category.ex | 58 ++ .../desktop/shell_live/menu_editor/state.ex | 85 +++ .../shell_live/menu_editor/tree_ops.ex | 296 +++++++++ .../shell_live/menu_editor/tree_predicates.ex | 55 ++ 7 files changed, 668 insertions(+), 573 deletions(-) create mode 100644 lib/bds/desktop/shell_live/menu_editor/draft_management.ex create mode 100644 lib/bds/desktop/shell_live/menu_editor/page_category.ex create mode 100644 lib/bds/desktop/shell_live/menu_editor/state.ex create mode 100644 lib/bds/desktop/shell_live/menu_editor/tree_ops.ex create mode 100644 lib/bds/desktop/shell_live/menu_editor/tree_predicates.ex diff --git a/CODESMELL.md b/CODESMELL.md index 0f2651e..309e866 100644 --- a/CODESMELL.md +++ b/CODESMELL.md @@ -2,7 +2,7 @@ Living document. Each section lists **status**, then **open work in priority order**, then a brief notes block. Completed work is listed compactly at the bottom (`## Changelog`). -Last refreshed: 2026-05-04. +Last refreshed: 2026-05-05. --- @@ -14,7 +14,6 @@ Last refreshed: 2026-05-04. | # | Module | Current lines | Target | Strategy | |---|---|---|---|---| -| 5 | `BDS.Desktop.ShellLive.MenuEditor` | 871 | ≤ 350 | Extract `TreeOps` (~280), `TreePredicates` (~60), `DraftManagement` (~140), `PageCategory` (~120), `State` (~80). | | 6 | `BDS.Desktop.ShellLive.PostEditor` | 963 | ≤ 400 | Extract `DraftManagement` (~180), `ListValues` (~160), `Persistence` (~140), `PostMetadata` (~150). | | 7 | `BDS.Desktop.ShellLive.SettingsEditor` | 872 | ≤ 350 | Extract `ProjectSettings` (~140), `AISettings` (~150), `PublishingSettings` (~80), `ManagedCategories` (~140), `StyleEditor` (~80), `MCPConfig` (~60). | | 8 | `BDS.Desktop.ShellLive.ChatEditor` | 972 | ≤ 400 | Extract `ToolSurfaces` (~280), `ToolTracking` (~140), `MessageBuild` (~160), `ModelSelection` (~100). Defer — highest internal coupling. | @@ -33,6 +32,7 @@ Last refreshed: 2026-05-04. - `BDS.Media` 993 → 324 (67 %) - `BDS.Desktop.ShellLive.ImportEditor` 1436 → 776 (46 %) - `BDS.Rendering` 838 → 33 (96 %) +- `BDS.Desktop.ShellLive.MenuEditor` 871 → 335 (62 %) --- @@ -166,6 +166,11 @@ Most tests share the SQLite repo and named GenServers (`BDS.Tasks`, `BDS.Search` ## Changelog +### 2026-05-05 + +- **God modules**: + - `BDS.Desktop.ShellLive.MenuEditor` 871 → 335 (62 %). Submodules under `lib/bds/desktop/shell_live/menu_editor/`: `TreeOps` (296, home_item/home_item_id + ui_item + persisted_item + first_item_id + insert_target + path_prefix? + find_path + item_at_path + items_at_path + replace_items_at_path + update_item + insert_item + remove_item + remove_item_with_value + append_child + move_selected + indent_selected + unindent_selected + delete_selected + drop_selected + insert_dropped_item), `TreePredicates` (55, can_move_up?/can_move_down?/can_indent?/can_unindent?/can_delete? + draft_item?), `DraftManagement` (132, current_draft + start_page_draft + start_category_draft + finalize_submenu_draft + assign_page_to_draft + assign_category_to_draft + cancel_draft + confirm_category_draft), `PageCategory` (58, page_posts + page_post + filter_page_posts + category_options + filter_categories + blank_to_nil), `State` (85, ensure_state + update_state + build + save + load_state). Coordinator keeps the 11 public event handlers (assign_socket, select_item, change_entry, submit_entry, cancel_entry, select_page, select_category, toolbar_action, drop_item, handle_keydown), the 3 HEEx components (menu_editor, menu_tree_level, kind_icon), and the render-time helpers (translated, row_label, kind_label, editing_title/hint/placeholder); `draft_item?/2` is exposed via `defdelegate` so HEEx call sites stay unchanged. Cross-submodule deps are linear: State → PageCategory + TreeOps + TreePredicates; DraftManagement → PageCategory + TreeOps; TreePredicates → TreeOps; PageCategory and TreeOps are leaves. `confirm_category_draft/2` takes the State.update_state function as an argument to avoid a cycle. Validates clean: `mix compile --warnings-as-errors`, `mix dialyzer --format short` (0 errors), `mix test` (342 tests, 0 failures, 4 skipped). + ### 2026-05-04 - **God modules**: diff --git a/lib/bds/desktop/shell_live/menu_editor.ex b/lib/bds/desktop/shell_live/menu_editor.ex index a506b67..83e8391 100644 --- a/lib/bds/desktop/shell_live/menu_editor.ex +++ b/lib/bds/desktop/shell_live/menu_editor.ex @@ -3,21 +3,22 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do use Phoenix.Component - import Ecto.Query - alias BDS.Desktop.ShellData - alias BDS.{Menu, Metadata, Repo} - alias BDS.Posts.Post + alias BDS.Desktop.ShellLive.MenuEditor.{ + DraftManagement, + PageCategory, + State, + TreeOps, + TreePredicates + } embed_templates "menu_editor_html/*" - @home_item_id "menu-home" - def assign_socket(socket) do case socket.assigns[:current_tab] do %{type: :menu_editor, id: tab_id} -> - state = ensure_state(socket.assigns) - menu_editor = build(socket.assigns, state) + state = State.ensure_state(socket.assigns) + menu_editor = State.build(socket.assigns, state) socket |> assign(:menu_editor_state, state) @@ -37,7 +38,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do def select_item(socket, item_id, reload) do socket - |> update_state(fn state -> %{state | selected_id: item_id} end) + |> State.update_state(fn state -> %{state | selected_id: item_id} end) |> reload.(socket.assigns.workbench) end @@ -45,20 +46,20 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do query = Map.get(params, "query", "") socket - |> update_state(fn state -> put_in(state, [:draft, :query], query) end) + |> State.update_state(fn state -> put_in(state, [:draft, :query], query) end) |> reload.(socket.assigns.workbench) end def submit_entry(socket, reload) do - case current_draft(socket.assigns) do + case DraftManagement.current_draft(socket.assigns) do %{type: :page} -> socket - |> update_state(&finalize_submenu_draft/1) + |> State.update_state(&DraftManagement.finalize_submenu_draft/1) |> reload.(socket.assigns.workbench) %{type: :category} -> socket - |> confirm_category_draft() + |> DraftManagement.confirm_category_draft(&State.update_state/2) |> reload.(socket.assigns.workbench) _other -> @@ -68,17 +69,18 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do def cancel_entry(socket, reload) do socket - |> update_state(&cancel_draft/1) + |> State.update_state(&DraftManagement.cancel_draft/1) |> reload.(socket.assigns.workbench) end def select_page(socket, post_id, reload) do - case page_post(socket.assigns.projects.active_project_id, post_id) do - nil -> reload.(socket, socket.assigns.workbench) + case PageCategory.page_post(socket.assigns.projects.active_project_id, post_id) do + nil -> + reload.(socket, socket.assigns.workbench) post -> socket - |> update_state(&assign_page_to_draft(&1, post)) + |> State.update_state(&DraftManagement.assign_page_to_draft(&1, post)) |> reload.(socket.assigns.workbench) end end @@ -86,12 +88,13 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do def select_category(socket, name, reload) do project_id = socket.assigns.projects.active_project_id - case Enum.find(category_options(project_id), &(&1.name == name)) do - nil -> reload.(socket, socket.assigns.workbench) + case Enum.find(PageCategory.category_options(project_id), &(&1.name == name)) do + nil -> + reload.(socket, socket.assigns.workbench) category -> socket - |> update_state(&assign_category_to_draft(&1, category)) + |> State.update_state(&DraftManagement.assign_category_to_draft(&1, category)) |> reload.(socket.assigns.workbench) end end @@ -100,40 +103,40 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do case action do "add-entry" -> socket - |> update_state(&start_page_draft/1) + |> State.update_state(&DraftManagement.start_page_draft/1) |> reload.(socket.assigns.workbench) "add-category-archive" -> socket - |> update_state(&start_category_draft/1) + |> State.update_state(&DraftManagement.start_category_draft/1) |> reload.(socket.assigns.workbench) "save" -> - save(socket, reload, append_output) + State.save(socket, reload, append_output) "move-up" -> socket - |> update_state(&move_selected(&1, :up)) + |> State.update_state(&TreeOps.move_selected(&1, :up)) |> reload.(socket.assigns.workbench) "move-down" -> socket - |> update_state(&move_selected(&1, :down)) + |> State.update_state(&TreeOps.move_selected(&1, :down)) |> reload.(socket.assigns.workbench) "indent" -> socket - |> update_state(&indent_selected/1) + |> State.update_state(&TreeOps.indent_selected/1) |> reload.(socket.assigns.workbench) "unindent" -> socket - |> update_state(&unindent_selected/1) + |> State.update_state(&TreeOps.unindent_selected/1) |> reload.(socket.assigns.workbench) "delete" -> socket - |> update_state(&delete_selected/1) + |> State.update_state(&TreeOps.delete_selected/1) |> reload.(socket.assigns.workbench) _other -> @@ -143,7 +146,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do def drop_item(socket, drag_item_id, target_item_id, position, reload) do socket - |> update_state(&drop_selected(&1, drag_item_id, target_item_id, position)) + |> State.update_state(&TreeOps.drop_selected(&1, drag_item_id, target_item_id, position)) |> reload.(socket.assigns.workbench) end @@ -303,7 +306,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do """ end - def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) + def translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) def row_label(item, category_titles) do if item.kind == :category_archive do @@ -318,9 +322,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do def kind_label(:category_archive), do: translated("menuEditor.type.categoryArchive") def kind_label(:submenu), do: translated("menuEditor.type.submenu") - def draft_item?(menu_editor, item_id) do - match?(%{item_id: ^item_id}, menu_editor.draft) - end + defdelegate draft_item?(menu_editor, item_id), to: TreePredicates def editing_title(%{draft: %{type: :category}}), do: translated("menuEditor.addCategoryArchive") def editing_title(_menu_editor), do: translated("menuEditor.pagePicker.title") @@ -330,542 +332,4 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do def editing_placeholder(%{draft: %{type: :category}}), do: translated("menuEditor.newCategoryPlaceholder") def editing_placeholder(_menu_editor), do: translated("menuEditor.newEntryPlaceholder") - - defp ensure_state(assigns) do - project_id = assigns.projects.active_project_id - - case assigns[:menu_editor_state] do - %{project_id: ^project_id} = state -> state - _other -> load_state(project_id) - end - end - - defp load_state(nil) do - %{project_id: nil, items: [home_item()], selected_id: @home_item_id, draft: nil} - end - - defp load_state(project_id) do - {:ok, %{items: items}} = Menu.get_menu(project_id) - items = Enum.map(items, &ui_item/1) - - %{ - project_id: project_id, - items: items, - selected_id: first_item_id(items), - draft: nil - } - end - - defp build(_assigns, state) do - categories = category_options(state.project_id) - draft = state.draft - draft_query = Map.get(draft || %{}, :query, "") - - %{ - title: translated("menuEditor.title"), - description: translated("menuEditor.description"), - items: state.items, - selected_id: state.selected_id, - draft: draft, - draft_query: draft_query, - filtered_pages: - if(match?(%{type: :page}, draft), - do: filter_page_posts(page_posts(state.project_id), draft_query), - else: [] - ), - filtered_categories: - if(match?(%{type: :category}, draft), - do: filter_categories(categories, draft_query), - else: [] - ), - category_titles: Map.new(categories, &{&1.name, &1.title}), - can_move_up?: can_move_up?(state.items, state.selected_id), - can_move_down?: can_move_down?(state.items, state.selected_id), - can_indent?: can_indent?(state.items, state.selected_id), - can_unindent?: can_unindent?(state.items, state.selected_id), - can_delete?: can_delete?(state.selected_id), - has_items?: state.items != [] - } - end - - defp save(socket, reload, append_output) do - state = socket.assigns.menu_editor_state - - {:ok, _menu} = Menu.update_menu(state.project_id, Enum.map(state.items, &persisted_item/1)) - - socket - |> append_output.(translated("menuEditor.tabTitle"), translated("menuEditor.saved"), nil, "info") - |> reload.(socket.assigns.workbench) - end - - defp confirm_category_draft(socket) do - project_id = socket.assigns.projects.active_project_id - draft = current_draft(socket.assigns) - normalized = String.trim(Map.get(draft || %{}, :query, "")) - - category = - Enum.find(category_options(project_id), fn option -> - String.downcase(option.name) == String.downcase(normalized) or - String.downcase(option.title) == String.downcase(normalized) - end) - - category = - cond do - category != nil -> category - normalized == "" -> %{name: "", title: ""} - true -> - {:ok, _metadata} = Metadata.add_category(project_id, normalized) - %{name: normalized, title: normalized} - end - - update_state(socket, &assign_category_to_draft(&1, category)) - end - - defp update_state(socket, updater) do - state = ensure_state(socket.assigns) - assign(socket, :menu_editor_state, updater.(state)) - end - - defp start_page_draft(state) do - item = %{item_id: Ecto.UUID.generate(), kind: :page, label: translated("menuEditor.newPage"), slug: nil, children: [], is_home: false} - {parent_path, index} = insert_target(state.items, state.selected_id) - items = insert_item(state.items, parent_path, index, item) - %{state | items: items, selected_id: item.item_id, draft: %{item_id: item.item_id, type: :page, query: ""}} - end - - defp start_category_draft(state) do - item = %{item_id: Ecto.UUID.generate(), kind: :category_archive, label: "", slug: nil, children: [], is_home: false} - {parent_path, index} = insert_target(state.items, state.selected_id) - items = insert_item(state.items, parent_path, index, item) - %{state | items: items, selected_id: item.item_id, draft: %{item_id: item.item_id, type: :category, query: ""}} - end - - defp finalize_submenu_draft(%{draft: %{item_id: item_id, query: query}} = state) do - label = if(String.trim(query) == "", do: translated("menuEditor.newSubmenu"), else: String.trim(query)) - - %{state | items: update_item(state.items, item_id, fn item -> %{item | kind: :submenu, label: label, slug: nil, children: item.children || []} end), draft: nil} - end - - defp finalize_submenu_draft(state), do: state - - defp assign_page_to_draft(%{draft: %{item_id: item_id}} = state, post) do - %{ - state - | items: - update_item(state.items, item_id, fn item -> - %{item | kind: :page, label: post.title, slug: blank_to_nil(post.slug), children: []} - end), - draft: nil - } - end - - defp assign_page_to_draft(state, _post), do: state - - defp assign_category_to_draft(%{draft: %{item_id: item_id}} = state, category) do - label = blank_to_nil(category.title) || category.name - - %{ - state - | items: - update_item(state.items, item_id, fn item -> - %{item | kind: :category_archive, label: label, slug: category.name, children: []} - end), - draft: nil - } - end - - defp assign_category_to_draft(state, _category), do: state - - defp cancel_draft(%{draft: %{item_id: item_id}} = state) do - items = remove_item(state.items, item_id) - %{state | items: items, selected_id: first_item_id(items), draft: nil} - end - - defp cancel_draft(state), do: state - - defp move_selected(%{selected_id: selected_id} = state, direction) when direction in [:up, :down] do - case find_path(state.items, selected_id) do - nil -> state - [] -> state - path -> - parent_path = Enum.drop(path, -1) - index = List.last(path) - siblings = items_at_path(state.items, parent_path) - delta = if(direction == :up, do: -1, else: 1) - target_index = index + delta - - if target_index < 0 or target_index >= length(siblings) do - state - else - reordered = - List.pop_at(siblings, index) - |> then(fn {item, rest} -> List.insert_at(rest, target_index, item) end) - - %{state | items: replace_items_at_path(state.items, parent_path, reordered)} - end - end - end - - defp indent_selected(%{selected_id: selected_id} = state) do - case find_path(state.items, selected_id) do - nil -> state - [] -> state - path -> - parent_path = Enum.drop(path, -1) - index = List.last(path) - - cond do - index <= 0 -> - state - - true -> - previous_sibling_path = parent_path ++ [index - 1] - - case item_at_path(state.items, previous_sibling_path) do - %{kind: :submenu, item_id: sibling_id} -> - case remove_item_with_value(state.items, selected_id) do - {_next_items, nil} -> state - {next_items, removed_item} -> - %{ - state - | items: append_child(next_items, sibling_id, removed_item) - } - end - - _other -> - state - end - end - end - end - - defp unindent_selected(%{selected_id: selected_id} = state) do - case find_path(state.items, selected_id) do - nil -> state - [] -> state - [_root_index] -> state - path -> - parent_path = Enum.drop(path, -1) - parent_index = List.last(parent_path) - grand_parent_path = Enum.drop(parent_path, -1) - - case remove_item_with_value(state.items, selected_id) do - {_next_items, nil} -> state - {next_items, removed_item} -> - %{ - state - | items: insert_item(next_items, grand_parent_path, parent_index + 1, removed_item) - } - end - end - end - - defp delete_selected(%{selected_id: @home_item_id} = state), do: state - - defp delete_selected(%{selected_id: selected_id} = state) do - items = remove_item(state.items, selected_id) - %{state | items: items, selected_id: first_item_id(items), draft: nil} - end - - defp drop_selected(state, drag_item_id, target_item_id, _position) - when drag_item_id in [nil, ""] or target_item_id in [nil, ""] do - state - end - - defp drop_selected(state, drag_item_id, target_item_id, _position) when drag_item_id == target_item_id, - do: state - - defp drop_selected(state, drag_item_id, target_item_id, position) do - drag_path = find_path(state.items, drag_item_id) - target_path = find_path(state.items, target_item_id) - - cond do - is_nil(drag_path) or is_nil(target_path) -> - state - - path_prefix?(drag_path, target_path) -> - state - - true -> - case remove_item_with_value(state.items, drag_item_id) do - {_next_items, nil} -> - state - - {next_items, dragged_item} -> - case find_path(next_items, target_item_id) do - nil -> - state - - next_target_path -> - insert_dropped_item(state, next_items, dragged_item, next_target_path, position) - end - end - end - end - - defp insert_dropped_item(state, next_items, dragged_item, target_path, "inside") do - case item_at_path(next_items, target_path) do - %{kind: :submenu} -> - %{state | items: insert_item(next_items, target_path, 0, dragged_item), selected_id: dragged_item.item_id} - - _other -> - state - end - end - - defp insert_dropped_item(state, next_items, dragged_item, target_path, "before") do - parent_path = Enum.drop(target_path, -1) - index = List.last(target_path) - %{state | items: insert_item(next_items, parent_path, index, dragged_item), selected_id: dragged_item.item_id} - end - - defp insert_dropped_item(state, next_items, dragged_item, target_path, _position) do - parent_path = Enum.drop(target_path, -1) - index = List.last(target_path) + 1 - %{state | items: insert_item(next_items, parent_path, index, dragged_item), selected_id: dragged_item.item_id} - end - - defp current_draft(assigns), do: Map.get(assigns.menu_editor_state || %{}, :draft) - - defp page_posts(nil), do: [] - - defp page_posts(project_id) do - Repo.all(from post in Post, where: post.project_id == ^project_id, order_by: [asc: post.title, asc: post.slug]) - |> Enum.filter(&("page" in (&1.categories || []))) - end - - defp page_post(nil, _post_id), do: nil - - defp page_post(project_id, post_id) do - Enum.find(page_posts(project_id), &(&1.id == post_id)) - end - - defp filter_page_posts(posts, query) do - normalized = query |> to_string() |> String.trim() |> String.downcase() - - Enum.filter(posts, fn post -> - normalized == "" or - String.contains?(String.downcase(post.title || ""), normalized) or - String.contains?(String.downcase(post.slug || ""), normalized) - end) - end - - defp category_options(nil), do: [] - - defp category_options(project_id) do - {:ok, metadata} = Metadata.get_project_metadata(project_id) - - Enum.map(metadata.categories || [], fn name -> - title = get_in(metadata.category_settings || %{}, [name, "title"]) - %{name: name, title: blank_to_nil(title) || name} - end) - end - - defp filter_categories(categories, query) do - normalized = query |> to_string() |> String.trim() |> String.downcase() - - Enum.filter(categories, fn category -> - normalized == "" or - String.contains?(String.downcase(category.name), normalized) or - String.contains?(String.downcase(category.title), normalized) - end) - end - - defp can_move_up?(items, selected_id) do - case find_path(items, selected_id) do - [_parent, index] -> index > 0 - [index] -> index > 0 - path when is_list(path) -> List.last(path) > 0 - _other -> false - end - end - - defp can_move_down?(items, selected_id) do - case find_path(items, selected_id) do - nil -> false - path -> - parent_path = Enum.drop(path, -1) - index = List.last(path) - index < length(items_at_path(items, parent_path)) - 1 - end - end - - defp can_indent?(items, selected_id) do - case find_path(items, selected_id) do - nil -> false - [] -> false - [_index] = path -> - index = List.last(path) - index > 0 and match?(%{kind: :submenu}, item_at_path(items, [index - 1])) - - path -> - index = List.last(path) - - index > 0 and - match?(%{kind: :submenu}, item_at_path(items, Enum.drop(path, -1) ++ [index - 1])) - end - end - - defp can_unindent?(items, selected_id) do - case find_path(items, selected_id) do - [_index] -> false - path when is_list(path) -> length(path) > 1 - _other -> false - end - end - - defp can_delete?(selected_id), do: is_binary(selected_id) and selected_id != @home_item_id - - defp home_item do - %{item_id: @home_item_id, kind: :home, label: "Home", slug: nil, children: [], is_home: true} - end - - defp ui_item(%{kind: :home}), do: home_item() - - defp ui_item(item) do - kind = Map.get(item, :kind, :page) - - %{ - item_id: Ecto.UUID.generate(), - kind: kind, - label: Map.get(item, :label, ""), - slug: Map.get(item, :slug), - children: Enum.map(Map.get(item, :children, []), &ui_item/1), - is_home: false - } - end - - defp persisted_item(%{kind: :home}), do: %{kind: :home, label: "Home", slug: nil} - - defp persisted_item(%{kind: :submenu} = item) do - %{kind: :submenu, label: item.label, slug: nil, children: Enum.map(item.children || [], &persisted_item/1)} - end - - defp persisted_item(item) do - %{kind: item.kind, label: item.label, slug: item.slug} - end - - defp insert_target(items, nil), do: {[], length(items)} - - defp insert_target(items, selected_id) do - case find_path(items, selected_id) do - nil -> {[], length(items)} - [] -> {[], length(items)} - path -> - case item_at_path(items, path) do - %{kind: :submenu} -> {path, 0} - _other -> {Enum.drop(path, -1), List.last(path) + 1} - end - end - end - - defp first_item_id([item | _rest]), do: item.item_id - defp first_item_id([]), do: nil - - defp blank_to_nil(nil), do: nil - defp blank_to_nil(value) do - trimmed = String.trim(to_string(value)) - if trimmed == "", do: nil, else: trimmed - end - - defp path_prefix?(prefix, path) when length(prefix) > length(path), do: false - defp path_prefix?(prefix, path), do: Enum.take(path, length(prefix)) == prefix - - defp find_path(items, item_id, path \\ []) do - Enum.find_value(Enum.with_index(items), fn {item, index} -> - next_path = path ++ [index] - - cond do - item.item_id == item_id -> - next_path - - item.children != [] -> - find_path(item.children, item_id, next_path) - - true -> - nil - end - end) - end - - defp item_at_path(_items, []), do: nil - - defp item_at_path(items, [index]) do - Enum.at(items, index) - end - - defp item_at_path(items, [index | rest]) do - case Enum.at(items, index) do - nil -> nil - item -> item_at_path(item.children || [], rest) - end - end - - defp items_at_path(items, []), do: items - - defp items_at_path(items, [index | rest]) do - case Enum.at(items, index) do - nil -> [] - item -> items_at_path(item.children || [], rest) - end - end - - defp replace_items_at_path(_items, [], replacement), do: replacement - - defp replace_items_at_path(items, [index | rest], replacement) do - List.update_at(items, index, fn item -> - %{item | children: replace_items_at_path(item.children || [], rest, replacement)} - end) - end - - defp update_item(items, item_id, updater) do - Enum.map(items, fn item -> - cond do - item.item_id == item_id -> updater.(item) - item.children != [] -> %{item | children: update_item(item.children, item_id, updater)} - true -> item - end - end) - end - - defp insert_item(items, [], index, item) do - List.insert_at(items, index, item) - end - - defp insert_item(items, [head | tail], index, item) do - List.update_at(items, head, fn current -> - %{current | children: insert_item(current.children || [], tail, index, item)} - end) - end - - defp remove_item(items, item_id) do - remove_item_with_value(items, item_id) |> elem(0) - end - - defp remove_item_with_value(items, item_id) do - Enum.reduce_while(Enum.with_index(items), {items, nil}, fn {item, index}, _acc -> - cond do - item.item_id == item_id -> - {:halt, {List.delete_at(items, index), item}} - - item.children != [] -> - {next_children, removed_item} = remove_item_with_value(item.children, item_id) - - if removed_item do - {:halt, {List.replace_at(items, index, %{item | children: next_children}), removed_item}} - else - {:cont, {items, nil}} - end - - true -> - {:cont, {items, nil}} - end - end) - end - - defp append_child(items, parent_item_id, child) do - update_item(items, parent_item_id, fn item -> - %{item | children: (item.children || []) ++ [child]} - end) - end end diff --git a/lib/bds/desktop/shell_live/menu_editor/draft_management.ex b/lib/bds/desktop/shell_live/menu_editor/draft_management.ex new file mode 100644 index 0000000..46ca3f6 --- /dev/null +++ b/lib/bds/desktop/shell_live/menu_editor/draft_management.ex @@ -0,0 +1,132 @@ +defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do + @moduledoc false + + alias BDS.Desktop.ShellData + alias BDS.Metadata + alias BDS.Desktop.ShellLive.MenuEditor.PageCategory + alias BDS.Desktop.ShellLive.MenuEditor.TreeOps + + def current_draft(assigns), do: Map.get(assigns.menu_editor_state || %{}, :draft) + + def start_page_draft(state) do + item = %{ + item_id: Ecto.UUID.generate(), + kind: :page, + label: translated("menuEditor.newPage"), + slug: nil, + children: [], + is_home: false + } + + {parent_path, index} = TreeOps.insert_target(state.items, state.selected_id) + items = TreeOps.insert_item(state.items, parent_path, index, item) + + %{ + state + | items: items, + selected_id: item.item_id, + draft: %{item_id: item.item_id, type: :page, query: ""} + } + end + + def start_category_draft(state) do + item = %{ + item_id: Ecto.UUID.generate(), + kind: :category_archive, + label: "", + slug: nil, + children: [], + is_home: false + } + + {parent_path, index} = TreeOps.insert_target(state.items, state.selected_id) + items = TreeOps.insert_item(state.items, parent_path, index, item) + + %{ + state + | items: items, + selected_id: item.item_id, + draft: %{item_id: item.item_id, type: :category, query: ""} + } + end + + def finalize_submenu_draft(%{draft: %{item_id: item_id, query: query}} = state) do + label = + if(String.trim(query) == "", + do: translated("menuEditor.newSubmenu"), + else: String.trim(query) + ) + + %{ + state + | items: + TreeOps.update_item(state.items, item_id, fn item -> + %{item | kind: :submenu, label: label, slug: nil, children: item.children || []} + end), + draft: nil + } + end + + def finalize_submenu_draft(state), do: state + + def assign_page_to_draft(%{draft: %{item_id: item_id}} = state, post) do + %{ + state + | items: + TreeOps.update_item(state.items, item_id, fn item -> + %{item | kind: :page, label: post.title, slug: PageCategory.blank_to_nil(post.slug), children: []} + end), + draft: nil + } + end + + def assign_page_to_draft(state, _post), do: state + + def assign_category_to_draft(%{draft: %{item_id: item_id}} = state, category) do + label = PageCategory.blank_to_nil(category.title) || category.name + + %{ + state + | items: + TreeOps.update_item(state.items, item_id, fn item -> + %{item | kind: :category_archive, label: label, slug: category.name, children: []} + end), + draft: nil + } + end + + def assign_category_to_draft(state, _category), do: state + + def cancel_draft(%{draft: %{item_id: item_id}} = state) do + items = TreeOps.remove_item(state.items, item_id) + %{state | items: items, selected_id: TreeOps.first_item_id(items), draft: nil} + end + + def cancel_draft(state), do: state + + def confirm_category_draft(socket, update_state_fun) do + project_id = socket.assigns.projects.active_project_id + draft = current_draft(socket.assigns) + normalized = String.trim(Map.get(draft || %{}, :query, "")) + + category = + Enum.find(PageCategory.category_options(project_id), fn option -> + String.downcase(option.name) == String.downcase(normalized) or + String.downcase(option.title) == String.downcase(normalized) + end) + + category = + cond do + category != nil -> category + normalized == "" -> %{name: "", title: ""} + true -> + {:ok, _metadata} = Metadata.add_category(project_id, normalized) + %{name: normalized, title: normalized} + end + + update_state_fun.(socket, &assign_category_to_draft(&1, category)) + end + + defp translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) +end diff --git a/lib/bds/desktop/shell_live/menu_editor/page_category.ex b/lib/bds/desktop/shell_live/menu_editor/page_category.ex new file mode 100644 index 0000000..d38d575 --- /dev/null +++ b/lib/bds/desktop/shell_live/menu_editor/page_category.ex @@ -0,0 +1,58 @@ +defmodule BDS.Desktop.ShellLive.MenuEditor.PageCategory do + @moduledoc false + + import Ecto.Query + + alias BDS.{Metadata, Repo} + alias BDS.Posts.Post + + def page_posts(nil), do: [] + + def page_posts(project_id) do + Repo.all(from post in Post, where: post.project_id == ^project_id, order_by: [asc: post.title, asc: post.slug]) + |> Enum.filter(&("page" in (&1.categories || []))) + end + + def page_post(nil, _post_id), do: nil + + def page_post(project_id, post_id) do + Enum.find(page_posts(project_id), &(&1.id == post_id)) + end + + def filter_page_posts(posts, query) do + normalized = query |> to_string() |> String.trim() |> String.downcase() + + Enum.filter(posts, fn post -> + normalized == "" or + String.contains?(String.downcase(post.title || ""), normalized) or + String.contains?(String.downcase(post.slug || ""), normalized) + end) + end + + def category_options(nil), do: [] + + def category_options(project_id) do + {:ok, metadata} = Metadata.get_project_metadata(project_id) + + Enum.map(metadata.categories || [], fn name -> + title = get_in(metadata.category_settings || %{}, [name, "title"]) + %{name: name, title: blank_to_nil(title) || name} + end) + end + + def filter_categories(categories, query) do + normalized = query |> to_string() |> String.trim() |> String.downcase() + + Enum.filter(categories, fn category -> + normalized == "" or + String.contains?(String.downcase(category.name), normalized) or + String.contains?(String.downcase(category.title), normalized) + end) + end + + def blank_to_nil(nil), do: nil + def blank_to_nil(value) do + trimmed = String.trim(to_string(value)) + if trimmed == "", do: nil, else: trimmed + end +end diff --git a/lib/bds/desktop/shell_live/menu_editor/state.ex b/lib/bds/desktop/shell_live/menu_editor/state.ex new file mode 100644 index 0000000..47d87c2 --- /dev/null +++ b/lib/bds/desktop/shell_live/menu_editor/state.ex @@ -0,0 +1,85 @@ +defmodule BDS.Desktop.ShellLive.MenuEditor.State do + @moduledoc false + + use Phoenix.Component + + alias BDS.Desktop.ShellData + alias BDS.Menu + alias BDS.Desktop.ShellLive.MenuEditor.{PageCategory, TreeOps, TreePredicates} + + def ensure_state(assigns) do + project_id = assigns.projects.active_project_id + + case assigns[:menu_editor_state] do + %{project_id: ^project_id} = state -> state + _other -> load_state(project_id) + end + end + + def update_state(socket, updater) do + state = ensure_state(socket.assigns) + assign(socket, :menu_editor_state, updater.(state)) + end + + def build(_assigns, state) do + categories = PageCategory.category_options(state.project_id) + draft = state.draft + draft_query = Map.get(draft || %{}, :query, "") + + %{ + title: translated("menuEditor.title"), + description: translated("menuEditor.description"), + items: state.items, + selected_id: state.selected_id, + draft: draft, + draft_query: draft_query, + filtered_pages: + if(match?(%{type: :page}, draft), + do: PageCategory.filter_page_posts(PageCategory.page_posts(state.project_id), draft_query), + else: [] + ), + filtered_categories: + if(match?(%{type: :category}, draft), + do: PageCategory.filter_categories(categories, draft_query), + else: [] + ), + category_titles: Map.new(categories, &{&1.name, &1.title}), + can_move_up?: TreePredicates.can_move_up?(state.items, state.selected_id), + can_move_down?: TreePredicates.can_move_down?(state.items, state.selected_id), + can_indent?: TreePredicates.can_indent?(state.items, state.selected_id), + can_unindent?: TreePredicates.can_unindent?(state.items, state.selected_id), + can_delete?: TreePredicates.can_delete?(state.selected_id), + has_items?: state.items != [] + } + end + + def save(socket, reload, append_output) do + state = socket.assigns.menu_editor_state + + {:ok, _menu} = + Menu.update_menu(state.project_id, Enum.map(state.items, &TreeOps.persisted_item/1)) + + socket + |> append_output.(translated("menuEditor.tabTitle"), translated("menuEditor.saved"), nil, "info") + |> reload.(socket.assigns.workbench) + end + + defp load_state(nil) do + %{project_id: nil, items: [TreeOps.home_item()], selected_id: TreeOps.home_item_id(), draft: nil} + end + + defp load_state(project_id) do + {:ok, %{items: items}} = Menu.get_menu(project_id) + items = Enum.map(items, &TreeOps.ui_item/1) + + %{ + project_id: project_id, + items: items, + selected_id: TreeOps.first_item_id(items), + draft: nil + } + end + + defp translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) +end diff --git a/lib/bds/desktop/shell_live/menu_editor/tree_ops.ex b/lib/bds/desktop/shell_live/menu_editor/tree_ops.ex new file mode 100644 index 0000000..6919efd --- /dev/null +++ b/lib/bds/desktop/shell_live/menu_editor/tree_ops.ex @@ -0,0 +1,296 @@ +defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do + @moduledoc false + + @home_item_id "menu-home" + + def home_item_id, do: @home_item_id + + def home_item do + %{item_id: @home_item_id, kind: :home, label: "Home", slug: nil, children: [], is_home: true} + end + + def ui_item(%{kind: :home}), do: home_item() + + def ui_item(item) do + kind = Map.get(item, :kind, :page) + + %{ + item_id: Ecto.UUID.generate(), + kind: kind, + label: Map.get(item, :label, ""), + slug: Map.get(item, :slug), + children: Enum.map(Map.get(item, :children, []), &ui_item/1), + is_home: false + } + end + + def persisted_item(%{kind: :home}), do: %{kind: :home, label: "Home", slug: nil} + + def persisted_item(%{kind: :submenu} = item) do + %{kind: :submenu, label: item.label, slug: nil, children: Enum.map(item.children || [], &persisted_item/1)} + end + + def persisted_item(item) do + %{kind: item.kind, label: item.label, slug: item.slug} + end + + def first_item_id([item | _rest]), do: item.item_id + def first_item_id([]), do: nil + + def insert_target(items, nil), do: {[], length(items)} + + def insert_target(items, selected_id) do + case find_path(items, selected_id) do + nil -> {[], length(items)} + [] -> {[], length(items)} + path -> + case item_at_path(items, path) do + %{kind: :submenu} -> {path, 0} + _other -> {Enum.drop(path, -1), List.last(path) + 1} + end + end + end + + def path_prefix?(prefix, path) when length(prefix) > length(path), do: false + def path_prefix?(prefix, path), do: Enum.take(path, length(prefix)) == prefix + + def find_path(items, item_id, path \\ []) do + Enum.find_value(Enum.with_index(items), fn {item, index} -> + next_path = path ++ [index] + + cond do + item.item_id == item_id -> + next_path + + item.children != [] -> + find_path(item.children, item_id, next_path) + + true -> + nil + end + end) + end + + def item_at_path(_items, []), do: nil + + def item_at_path(items, [index]) do + Enum.at(items, index) + end + + def item_at_path(items, [index | rest]) do + case Enum.at(items, index) do + nil -> nil + item -> item_at_path(item.children || [], rest) + end + end + + def items_at_path(items, []), do: items + + def items_at_path(items, [index | rest]) do + case Enum.at(items, index) do + nil -> [] + item -> items_at_path(item.children || [], rest) + end + end + + def replace_items_at_path(_items, [], replacement), do: replacement + + def replace_items_at_path(items, [index | rest], replacement) do + List.update_at(items, index, fn item -> + %{item | children: replace_items_at_path(item.children || [], rest, replacement)} + end) + end + + def update_item(items, item_id, updater) do + Enum.map(items, fn item -> + cond do + item.item_id == item_id -> updater.(item) + item.children != [] -> %{item | children: update_item(item.children, item_id, updater)} + true -> item + end + end) + end + + def insert_item(items, [], index, item) do + List.insert_at(items, index, item) + end + + def insert_item(items, [head | tail], index, item) do + List.update_at(items, head, fn current -> + %{current | children: insert_item(current.children || [], tail, index, item)} + end) + end + + def remove_item(items, item_id) do + remove_item_with_value(items, item_id) |> elem(0) + end + + def remove_item_with_value(items, item_id) do + Enum.reduce_while(Enum.with_index(items), {items, nil}, fn {item, index}, _acc -> + cond do + item.item_id == item_id -> + {:halt, {List.delete_at(items, index), item}} + + item.children != [] -> + {next_children, removed_item} = remove_item_with_value(item.children, item_id) + + if removed_item do + {:halt, {List.replace_at(items, index, %{item | children: next_children}), removed_item}} + else + {:cont, {items, nil}} + end + + true -> + {:cont, {items, nil}} + end + end) + end + + def append_child(items, parent_item_id, child) do + update_item(items, parent_item_id, fn item -> + %{item | children: (item.children || []) ++ [child]} + end) + end + + def move_selected(%{selected_id: selected_id} = state, direction) when direction in [:up, :down] do + case find_path(state.items, selected_id) do + nil -> state + [] -> state + path -> + parent_path = Enum.drop(path, -1) + index = List.last(path) + siblings = items_at_path(state.items, parent_path) + delta = if(direction == :up, do: -1, else: 1) + target_index = index + delta + + if target_index < 0 or target_index >= length(siblings) do + state + else + reordered = + List.pop_at(siblings, index) + |> then(fn {item, rest} -> List.insert_at(rest, target_index, item) end) + + %{state | items: replace_items_at_path(state.items, parent_path, reordered)} + end + end + end + + def indent_selected(%{selected_id: selected_id} = state) do + case find_path(state.items, selected_id) do + nil -> state + [] -> state + path -> + parent_path = Enum.drop(path, -1) + index = List.last(path) + + cond do + index <= 0 -> + state + + true -> + previous_sibling_path = parent_path ++ [index - 1] + + case item_at_path(state.items, previous_sibling_path) do + %{kind: :submenu, item_id: sibling_id} -> + case remove_item_with_value(state.items, selected_id) do + {_next_items, nil} -> state + {next_items, removed_item} -> + %{ + state + | items: append_child(next_items, sibling_id, removed_item) + } + end + + _other -> + state + end + end + end + end + + def unindent_selected(%{selected_id: selected_id} = state) do + case find_path(state.items, selected_id) do + nil -> state + [] -> state + [_root_index] -> state + path -> + parent_path = Enum.drop(path, -1) + parent_index = List.last(parent_path) + grand_parent_path = Enum.drop(parent_path, -1) + + case remove_item_with_value(state.items, selected_id) do + {_next_items, nil} -> state + {next_items, removed_item} -> + %{ + state + | items: insert_item(next_items, grand_parent_path, parent_index + 1, removed_item) + } + end + end + end + + def delete_selected(%{selected_id: @home_item_id} = state), do: state + + def delete_selected(%{selected_id: selected_id} = state) do + items = remove_item(state.items, selected_id) + %{state | items: items, selected_id: first_item_id(items), draft: nil} + end + + def drop_selected(state, drag_item_id, target_item_id, _position) + when drag_item_id in [nil, ""] or target_item_id in [nil, ""] do + state + end + + def drop_selected(state, drag_item_id, target_item_id, _position) when drag_item_id == target_item_id, + do: state + + def drop_selected(state, drag_item_id, target_item_id, position) do + drag_path = find_path(state.items, drag_item_id) + target_path = find_path(state.items, target_item_id) + + cond do + is_nil(drag_path) or is_nil(target_path) -> + state + + path_prefix?(drag_path, target_path) -> + state + + true -> + case remove_item_with_value(state.items, drag_item_id) do + {_next_items, nil} -> + state + + {next_items, dragged_item} -> + case find_path(next_items, target_item_id) do + nil -> + state + + next_target_path -> + insert_dropped_item(state, next_items, dragged_item, next_target_path, position) + end + end + end + end + + defp insert_dropped_item(state, next_items, dragged_item, target_path, "inside") do + case item_at_path(next_items, target_path) do + %{kind: :submenu} -> + %{state | items: insert_item(next_items, target_path, 0, dragged_item), selected_id: dragged_item.item_id} + + _other -> + state + end + end + + defp insert_dropped_item(state, next_items, dragged_item, target_path, "before") do + parent_path = Enum.drop(target_path, -1) + index = List.last(target_path) + %{state | items: insert_item(next_items, parent_path, index, dragged_item), selected_id: dragged_item.item_id} + end + + defp insert_dropped_item(state, next_items, dragged_item, target_path, _position) do + parent_path = Enum.drop(target_path, -1) + index = List.last(target_path) + 1 + %{state | items: insert_item(next_items, parent_path, index, dragged_item), selected_id: dragged_item.item_id} + end +end diff --git a/lib/bds/desktop/shell_live/menu_editor/tree_predicates.ex b/lib/bds/desktop/shell_live/menu_editor/tree_predicates.ex new file mode 100644 index 0000000..cabf4a0 --- /dev/null +++ b/lib/bds/desktop/shell_live/menu_editor/tree_predicates.ex @@ -0,0 +1,55 @@ +defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do + @moduledoc false + + alias BDS.Desktop.ShellLive.MenuEditor.TreeOps + + def can_move_up?(items, selected_id) do + case TreeOps.find_path(items, selected_id) do + [_parent, index] -> index > 0 + [index] -> index > 0 + path when is_list(path) -> List.last(path) > 0 + _other -> false + end + end + + def can_move_down?(items, selected_id) do + case TreeOps.find_path(items, selected_id) do + nil -> false + path -> + parent_path = Enum.drop(path, -1) + index = List.last(path) + index < length(TreeOps.items_at_path(items, parent_path)) - 1 + end + end + + def can_indent?(items, selected_id) do + case TreeOps.find_path(items, selected_id) do + nil -> false + [] -> false + [_index] = path -> + index = List.last(path) + index > 0 and match?(%{kind: :submenu}, TreeOps.item_at_path(items, [index - 1])) + + path -> + index = List.last(path) + + index > 0 and + match?(%{kind: :submenu}, TreeOps.item_at_path(items, Enum.drop(path, -1) ++ [index - 1])) + end + end + + def can_unindent?(items, selected_id) do + case TreeOps.find_path(items, selected_id) do + [_index] -> false + path when is_list(path) -> length(path) > 1 + _other -> false + end + end + + def can_delete?(selected_id), + do: is_binary(selected_id) and selected_id != TreeOps.home_item_id() + + def draft_item?(menu_editor, item_id) do + match?(%{item_id: ^item_id}, menu_editor.draft) + end +end