chore: and again one more god module down

This commit is contained in:
2026-05-01 14:01:17 +02:00
parent 52857f2959
commit 7463875b81
7 changed files with 668 additions and 573 deletions

View File

@@ -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`). 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 | | # | 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). | | 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). | | 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. | | 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.Media` 993 → 324 (67 %)
- `BDS.Desktop.ShellLive.ImportEditor` 1436 → 776 (46 %) - `BDS.Desktop.ShellLive.ImportEditor` 1436 → 776 (46 %)
- `BDS.Rendering` 838 → 33 (96 %) - `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 ## 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 ### 2026-05-04
- **God modules**: - **God modules**:

View File

@@ -3,21 +3,22 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
use Phoenix.Component use Phoenix.Component
import Ecto.Query
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.{Menu, Metadata, Repo} alias BDS.Desktop.ShellLive.MenuEditor.{
alias BDS.Posts.Post DraftManagement,
PageCategory,
State,
TreeOps,
TreePredicates
}
embed_templates "menu_editor_html/*" embed_templates "menu_editor_html/*"
@home_item_id "menu-home"
def assign_socket(socket) do def assign_socket(socket) do
case socket.assigns[:current_tab] do case socket.assigns[:current_tab] do
%{type: :menu_editor, id: tab_id} -> %{type: :menu_editor, id: tab_id} ->
state = ensure_state(socket.assigns) state = State.ensure_state(socket.assigns)
menu_editor = build(socket.assigns, state) menu_editor = State.build(socket.assigns, state)
socket socket
|> assign(:menu_editor_state, state) |> assign(:menu_editor_state, state)
@@ -37,7 +38,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
def select_item(socket, item_id, reload) do def select_item(socket, item_id, reload) do
socket 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) |> reload.(socket.assigns.workbench)
end end
@@ -45,20 +46,20 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
query = Map.get(params, "query", "") query = Map.get(params, "query", "")
socket 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) |> reload.(socket.assigns.workbench)
end end
def submit_entry(socket, reload) do def submit_entry(socket, reload) do
case current_draft(socket.assigns) do case DraftManagement.current_draft(socket.assigns) do
%{type: :page} -> %{type: :page} ->
socket socket
|> update_state(&finalize_submenu_draft/1) |> State.update_state(&DraftManagement.finalize_submenu_draft/1)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
%{type: :category} -> %{type: :category} ->
socket socket
|> confirm_category_draft() |> DraftManagement.confirm_category_draft(&State.update_state/2)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
_other -> _other ->
@@ -68,17 +69,18 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
def cancel_entry(socket, reload) do def cancel_entry(socket, reload) do
socket socket
|> update_state(&cancel_draft/1) |> State.update_state(&DraftManagement.cancel_draft/1)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
def select_page(socket, post_id, reload) do def select_page(socket, post_id, reload) do
case page_post(socket.assigns.projects.active_project_id, post_id) do case PageCategory.page_post(socket.assigns.projects.active_project_id, post_id) do
nil -> reload.(socket, socket.assigns.workbench) nil ->
reload.(socket, socket.assigns.workbench)
post -> post ->
socket socket
|> update_state(&assign_page_to_draft(&1, post)) |> State.update_state(&DraftManagement.assign_page_to_draft(&1, post))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
end end
@@ -86,12 +88,13 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
def select_category(socket, name, reload) do def select_category(socket, name, reload) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id
case Enum.find(category_options(project_id), &(&1.name == name)) do case Enum.find(PageCategory.category_options(project_id), &(&1.name == name)) do
nil -> reload.(socket, socket.assigns.workbench) nil ->
reload.(socket, socket.assigns.workbench)
category -> category ->
socket socket
|> update_state(&assign_category_to_draft(&1, category)) |> State.update_state(&DraftManagement.assign_category_to_draft(&1, category))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
end end
@@ -100,40 +103,40 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
case action do case action do
"add-entry" -> "add-entry" ->
socket socket
|> update_state(&start_page_draft/1) |> State.update_state(&DraftManagement.start_page_draft/1)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
"add-category-archive" -> "add-category-archive" ->
socket socket
|> update_state(&start_category_draft/1) |> State.update_state(&DraftManagement.start_category_draft/1)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
"save" -> "save" ->
save(socket, reload, append_output) State.save(socket, reload, append_output)
"move-up" -> "move-up" ->
socket socket
|> update_state(&move_selected(&1, :up)) |> State.update_state(&TreeOps.move_selected(&1, :up))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
"move-down" -> "move-down" ->
socket socket
|> update_state(&move_selected(&1, :down)) |> State.update_state(&TreeOps.move_selected(&1, :down))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
"indent" -> "indent" ->
socket socket
|> update_state(&indent_selected/1) |> State.update_state(&TreeOps.indent_selected/1)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
"unindent" -> "unindent" ->
socket socket
|> update_state(&unindent_selected/1) |> State.update_state(&TreeOps.unindent_selected/1)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
"delete" -> "delete" ->
socket socket
|> update_state(&delete_selected/1) |> State.update_state(&TreeOps.delete_selected/1)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
_other -> _other ->
@@ -143,7 +146,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
def drop_item(socket, drag_item_id, target_item_id, position, reload) do def drop_item(socket, drag_item_id, target_item_id, position, reload) do
socket 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) |> reload.(socket.assigns.workbench)
end end
@@ -303,7 +306,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
""" """
end 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 def row_label(item, category_titles) do
if item.kind == :category_archive 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(:category_archive), do: translated("menuEditor.type.categoryArchive")
def kind_label(:submenu), do: translated("menuEditor.type.submenu") def kind_label(:submenu), do: translated("menuEditor.type.submenu")
def draft_item?(menu_editor, item_id) do defdelegate draft_item?(menu_editor, item_id), to: TreePredicates
match?(%{item_id: ^item_id}, menu_editor.draft)
end
def editing_title(%{draft: %{type: :category}}), do: translated("menuEditor.addCategoryArchive") def editing_title(%{draft: %{type: :category}}), do: translated("menuEditor.addCategoryArchive")
def editing_title(_menu_editor), do: translated("menuEditor.pagePicker.title") 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(%{draft: %{type: :category}}), do: translated("menuEditor.newCategoryPlaceholder")
def editing_placeholder(_menu_editor), do: translated("menuEditor.newEntryPlaceholder") 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 end

View File

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

View File

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

View File

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

View File

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

View File

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