defmodule PhoenixKitWeb.Components.Dashboard.AdminSidebar do @moduledoc """ Admin sidebar component for the PhoenixKit admin panel. Renders the admin navigation using registry-driven Tab structs instead of hardcoded HEEX. Supports: - Permission-gated tabs (filtered by Registry) - Module-enabled filtering (filtered by Registry) - Dynamic children for Entities and Publishing - Subtab expand/collapse - Full reuse of the TabItem component for consistent rendering ## Usage <.admin_sidebar current_path={@current_path} scope={@phoenix_kit_current_scope} locale={@current_locale} /> """ use Phoenix.Component require Logger alias PhoenixKit.Dashboard.{Group, Registry, Tab} alias PhoenixKitWeb.Components.Dashboard.TabItem import PhoenixKit.Dashboard.TabHelpers import PhoenixKitWeb.Components.Core.Icon, only: [icon: 1] @doc """ Renders the complete admin sidebar navigation. ## Attributes - `current_path` - The current URL path for active state detection - `scope` - The current authentication scope for permission filtering - `locale` - The current locale for path generation - `class` - Additional CSS classes """ attr :current_path, :string, default: "/admin" attr :scope, :any, default: nil attr :locale, :string, default: nil attr :class, :string, default: "" def admin_sidebar(assigns) do # Get admin tabs, already filtered by level, permission, and module-enabled # Expand dynamic children BEFORE active state so dynamic tabs get checked too tabs = :telemetry.span([:phoenix_kit, :admin_sidebar, :render], %{}, fn -> result = Registry.get_admin_tabs(scope: assigns.scope) |> expand_dynamic_children(assigns.scope, assigns[:locale]) |> add_active_state(assigns.current_path) {result, %{tab_count: length(result)}} end) # Group tabs grouped_tabs = group_tabs(tabs) groups = Registry.get_groups() assigns = assigns |> assign(:tabs, tabs) |> assign(:grouped_tabs, grouped_tabs) |> assign(:groups, groups) ~H""" """ end attr :group, :map, required: true attr :tabs, :list, required: true attr :all_tabs, :list, required: true attr :locale, :string, default: nil defp admin_tab_group(assigns) do ~H"""
<%= if Group.localized_label(@group) do %>
<%= if @group.icon do %> <.icon name={@group.icon} class="w-3.5 h-3.5" /> <% end %> {Group.localized_label(@group)}
<% end %> <%= for tab <- filter_top_level(@tabs) do %> <.admin_tab_with_subtabs tab={tab} all_tabs={@all_tabs} locale={@locale} /> <% end %>
""" end attr :tab, :any, required: true attr :all_tabs, :list, required: true attr :locale, :string, default: nil defp admin_tab_with_subtabs(assigns) do subtabs = get_subtabs_for(assigns.tab.id, assigns.all_tabs) # Check all descendants (not just direct children) for active state descendant_active = any_descendant_active?(assigns.tab.id, assigns.all_tabs) show_subtabs = Tab.show_subtabs?(assigns.tab, assigns.tab.active) or descendant_active display_tab = maybe_redirect_to_first_subtab(assigns.tab, subtabs) highlight_with_subtabs = Map.get(assigns.tab, :highlight_with_subtabs, false) parent_active = if descendant_active and not highlight_with_subtabs do false else assigns.tab.active end assigns = assigns |> assign(:subtabs, subtabs) |> assign(:show_subtabs, show_subtabs) |> assign(:has_subtabs, subtabs != []) |> assign(:display_tab, display_tab) |> assign(:parent_active, parent_active) ~H"""
<%= if @has_subtabs and @show_subtabs do %>
<%= for subtab <- @subtabs do %> <.admin_subtab_item subtab={subtab} parent_tab={@tab} all_tabs={@all_tabs} locale={@locale} /> <% end %>
<% end %>
""" end attr :subtab, :any, required: true attr :parent_tab, :any, required: true attr :all_tabs, :list, required: true attr :locale, :string, default: nil defp admin_subtab_item(assigns) do children = get_subtabs_for(assigns.subtab.id, assigns.all_tabs) child_active = any_descendant_active?(assigns.subtab.id, assigns.all_tabs) show_children = children != [] and (Tab.show_subtabs?(assigns.subtab, assigns.subtab.active) or child_active) highlight_with_subtabs = Map.get(assigns.subtab, :highlight_with_subtabs, false) subtab_active = if child_active and not highlight_with_subtabs do false else assigns.subtab.active end assigns = assigns |> assign(:children, children) |> assign(:show_children, show_children) |> assign(:subtab_active, subtab_active) ~H""" <%= if @show_children do %>
<%= for child <- @children do %> <% end %>
<% end %> """ end # --- Helpers --- defp expand_dynamic_children(tabs, scope, locale) do # Find tabs with dynamic_children (arity 1 or 2) and expand them. # The 2-arity variant receives locale so modules can render translated # child labels without falling back to `Gettext.get_locale/1`. {parents_with_dynamic, other_tabs} = Enum.split_with(tabs, fn tab -> is_function(tab.dynamic_children, 1) or is_function(tab.dynamic_children, 2) end) dynamic_children = Enum.flat_map(parents_with_dynamic, fn parent -> children = try do invoke_dynamic_children(parent.dynamic_children, scope, locale) rescue error -> Logger.warning( "[AdminSidebar] dynamic_children for #{inspect(parent.id)} failed: #{Exception.message(error)}" ) [] end # Ensure children have parent set, correct level, and resolved paths Enum.map(children, fn child -> child |> Map.put(:parent, child.parent || parent.id) |> Map.put(:level, :admin) |> Tab.resolve_path(:admin) end) end) # Active state is applied after this function by add_active_state/2 other_tabs ++ parents_with_dynamic ++ dynamic_children end # Dispatches on arity so modules can opt in to locale-aware rendering # without breaking existing 1-arity `dynamic_children` implementations. defp invoke_dynamic_children(fun, scope, locale) when is_function(fun, 2), do: fun.(scope, locale) defp invoke_dynamic_children(fun, scope, _locale) when is_function(fun, 1), do: fun.(scope) @doc false # Test-only public delegate. The internal dispatch helper is `defp` because # it shouldn't be part of the runtime API; this `@doc false` wrapper lets # the unit suite exercise the actual arity dispatch (rather than just # re-asserting Elixir's call semantics on anonymous functions). Not # recommended for runtime callers — the contract is `dynamic_children_fn`, # not this wrapper. def __invoke_dynamic_children_for_test__(fun, scope, locale), do: invoke_dynamic_children(fun, scope, locale) # Recursively checks if any descendant (children, grandchildren, etc.) is active. # Includes depth limit and cycle detection for safety with parent-app-registered tabs. defp any_descendant_active?(parent_id, all_tabs, depth \\ 0, visited \\ %{}) defp any_descendant_active?(_parent_id, _all_tabs, depth, _visited) when depth > 5, do: false defp any_descendant_active?(parent_id, all_tabs, depth, visited) do if Map.has_key?(visited, parent_id) do Logger.warning("[AdminSidebar] Circular tab reference detected: #{inspect(parent_id)}") false else children = get_subtabs_for(parent_id, all_tabs) new_visited = Map.put(visited, parent_id, true) Enum.any?(children, fn child -> child.active or any_descendant_active?(child.id, all_tabs, depth + 1, new_visited) end) end end defp maybe_redirect_to_first_subtab(%{redirect_to_first_subtab: true} = tab, [ first_subtab | _ ]) do %{tab | path: first_subtab.path} end defp maybe_redirect_to_first_subtab(tab, _subtabs), do: tab end