defmodule PhoenixKit.Dashboard.Registry do @moduledoc """ Registry for managing dashboard tabs across the application. The Registry provides both compile-time configuration via application config and runtime registration for dynamic tabs. Tabs are stored in an ETS table for efficient access. ## Configuration Tabs can be configured in your application config: config :phoenix_kit, :user_dashboard_tabs, [ %{ id: :orders, label: "Orders", icon: "hero-shopping-bag", path: "/dashboard/orders", priority: 100 }, %{ id: :settings, label: "Settings", icon: "hero-cog-6-tooth", path: "/dashboard/settings", priority: 900 } ] ## Runtime Registration Parent applications can register tabs at runtime: PhoenixKit.Dashboard.Registry.register(:my_app, [ Tab.new!(id: :custom, label: "Custom", path: "/dashboard/custom", priority: 150) ]) ## Groups Tabs can be organized into groups: config :phoenix_kit, :user_dashboard_tab_groups, [ %{id: :main, label: nil, priority: 100}, %{id: :farm, label: "Farm Management", priority: 200, icon: "hero-cube"}, %{id: :account, label: "Account", priority: 900} ] Then assign tabs to groups: %{id: :printers, label: "Printers", path: "/dashboard/printers", group: :farm} ## PubSub Integration The registry can broadcast tab updates: PhoenixKit.Dashboard.Registry.update_tab_badge(:notifications, Badge.count(5)) LiveViews subscribed to "phoenix_kit:dashboard:tabs" will receive updates. """ use GenServer require Logger alias PhoenixKit.Dashboard.{AdminTabs, Badge, Tab} alias PhoenixKit.PubSubHelper alias PhoenixKit.Users.Permissions # Suppress warnings about optional modules (loaded conditionally) @compile {:no_warn_undefined, PhoenixKit.Modules.Tickets} @compile {:no_warn_undefined, PhoenixKit.Modules.Billing} @compile {:no_warn_undefined, PhoenixKit.Modules.Shop} @ets_table :phoenix_kit_dashboard_tabs @pubsub_topic "phoenix_kit:dashboard:tabs" # Client API @doc """ Starts the Registry GenServer. This is typically called by the PhoenixKit supervisor. """ def start_link(opts \\ []) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @doc """ Registers tabs for an application namespace. ## Examples Registry.register(:my_app, [ Tab.new!(id: :home, label: "Home", path: "/dashboard", icon: "hero-home"), Tab.new!(id: :orders, label: "Orders", path: "/dashboard/orders") ]) # Register a single tab Registry.register(:my_app, Tab.new!(id: :settings, label: "Settings", path: "/dashboard/settings")) """ @spec register(atom(), Tab.t() | [Tab.t()]) :: :ok def register(namespace, %Tab{} = tab) do register(namespace, [tab]) end def register(namespace, tabs) when is_atom(namespace) and is_list(tabs) do GenServer.call(__MODULE__, {:register, namespace, tabs}) end @doc """ Registers tabs from a map/keyword configuration. Useful for registering tabs from config files. ## Examples Registry.register_from_config(:my_app, [ %{id: :home, label: "Home", path: "/dashboard", icon: "hero-home"}, %{id: :orders, label: "Orders", path: "/dashboard/orders"} ]) """ @spec register_from_config(atom(), [map()] | [keyword()]) :: :ok | {:error, term()} def register_from_config(namespace, config) when is_atom(namespace) and is_list(config) do tabs = Enum.reduce_while(config, {:ok, []}, fn item, {:ok, acc} -> case Tab.new(item) do {:ok, tab} -> {:cont, {:ok, [tab | acc]}} {:error, reason} -> {:halt, {:error, reason}} end end) case tabs do {:ok, tab_list} -> register(namespace, Enum.reverse(tab_list)) :ok {:error, reason} -> {:error, reason} end end @doc """ Unregisters all tabs for a namespace. """ @spec unregister(atom()) :: :ok def unregister(namespace) when is_atom(namespace) do GenServer.call(__MODULE__, {:unregister, namespace}) end @doc """ Unregisters a specific tab by ID. """ @spec unregister_tab(atom()) :: :ok def unregister_tab(tab_id) when is_atom(tab_id) do GenServer.call(__MODULE__, {:unregister_tab, tab_id}) end @doc """ Gets all registered tabs, sorted by priority. ## Options - `:scope` - The current scope (for visibility filtering) - `:level` - Filter by tab level: `:admin`, `:user`, or nil for all - `:path` - The current path (for active state detection) - `:include_hidden` - Include tabs that would be hidden (default: false) ## Examples Registry.get_tabs() Registry.get_tabs(scope: scope, level: :user) Registry.get_tabs(scope: scope, level: :admin) """ @spec get_tabs(keyword()) :: [Tab.t()] def get_tabs(opts \\ []) do scope = opts[:scope] level = opts[:level] include_hidden = opts[:include_hidden] || false all_tabs() |> maybe_filter_level(level) |> maybe_filter_enabled() |> maybe_filter_permission(scope) |> maybe_filter_visibility(scope, include_hidden) |> sort_tabs() end @doc """ Gets admin-level tabs, filtered by permission and module-enabled status. ## Options - `:scope` - The current scope (for permission and visibility filtering) - `:include_hidden` - Include tabs that would be hidden (default: false) """ @spec get_admin_tabs(keyword()) :: [Tab.t()] def get_admin_tabs(opts \\ []) do get_tabs(Keyword.put(opts, :level, :admin)) end @doc """ Gets user-level tabs. ## Options - `:scope` - The current scope (for visibility filtering) - `:include_hidden` - Include tabs that would be hidden (default: false) """ @spec get_user_tabs(keyword()) :: [Tab.t()] def get_user_tabs(opts \\ []) do get_tabs(Keyword.put(opts, :level, :user)) end @doc """ Updates an existing tab's attributes. ## Examples Registry.update_tab(:admin_dashboard, %{label: "Home", icon: "hero-house"}) """ @spec update_tab(atom(), map()) :: :ok def update_tab(tab_id, attrs) when is_atom(tab_id) and is_map(attrs) do GenServer.call(__MODULE__, {:update_tab, tab_id, attrs}) end @doc """ Gets a specific tab by ID. """ @spec get_tab(atom()) :: Tab.t() | nil def get_tab(tab_id) when is_atom(tab_id) do if initialized?() do case :ets.lookup(@ets_table, {:tab, tab_id}) do [{_, tab}] -> tab [] -> nil end else nil end end @doc """ Gets all tabs in a specific group. """ @spec get_tabs_in_group(atom(), keyword()) :: [Tab.t()] def get_tabs_in_group(group_id, opts \\ []) do get_tabs(opts) |> Enum.filter(&(&1.group == group_id)) end @doc """ Gets all subtabs for a given parent tab ID. ## Examples Registry.get_subtabs(:orders) # => [%Tab{id: :pending_orders, parent: :orders, ...}, ...] """ @spec get_subtabs(atom(), keyword()) :: [Tab.t()] def get_subtabs(parent_id, opts \\ []) when is_atom(parent_id) do get_tabs(opts) |> Enum.filter(&(&1.parent == parent_id)) end @doc """ Gets only top-level tabs (tabs without a parent). ## Options Same as `get_tabs/1`. ## Examples Registry.get_top_level_tabs() # => [%Tab{id: :orders, parent: nil, ...}, ...] """ @spec get_top_level_tabs(keyword()) :: [Tab.t()] def get_top_level_tabs(opts \\ []) do get_tabs(opts) |> Enum.filter(&Tab.top_level?/1) end @doc """ Checks if a tab has any subtabs. ## Examples Registry.has_subtabs?(:orders) # => true """ @spec has_subtabs?(atom()) :: boolean() def has_subtabs?(tab_id) when is_atom(tab_id) do get_subtabs(tab_id) |> Enum.any?() end @doc """ Gets all registered groups, sorted by priority. """ @spec get_groups() :: [map()] def get_groups do if initialized?() do case :ets.lookup(@ets_table, :groups) do [{:groups, groups}] -> Enum.sort_by(groups, & &1.priority) [] -> [] end else [] end end @doc """ Registers tab groups. ## Examples Registry.register_groups([ %{id: :main, label: nil, priority: 100}, %{id: :farm, label: "Farm Management", priority: 200}, %{id: :account, label: "Account", priority: 900} ]) """ @spec register_groups([map()]) :: :ok def register_groups(groups) when is_list(groups) do GenServer.call(__MODULE__, {:register_groups, groups}) end @doc """ Updates a tab's badge. This broadcasts an update to all subscribed LiveViews. ## Examples Registry.update_tab_badge(:notifications, Badge.count(5)) Registry.update_tab_badge(:printers, Badge.count(3, color: :warning)) """ @spec update_tab_badge(atom(), Badge.t() | map() | nil) :: :ok def update_tab_badge(tab_id, badge) do GenServer.call(__MODULE__, {:update_badge, tab_id, badge}) end @doc """ Sets an attention animation on a tab. ## Examples Registry.set_tab_attention(:alerts, :pulse) Registry.set_tab_attention(:notifications, :bounce) """ @spec set_tab_attention(atom(), atom()) :: :ok def set_tab_attention(tab_id, attention) when attention in [nil, :pulse, :bounce, :shake, :glow] do GenServer.call(__MODULE__, {:set_attention, tab_id, attention}) end @doc """ Clears attention animation from a tab. """ @spec clear_tab_attention(atom()) :: :ok def clear_tab_attention(tab_id) do set_tab_attention(tab_id, nil) end @doc """ Gets the PubSub topic for tab updates. LiveViews can subscribe to this topic to receive real-time tab updates. ## Example def mount(_params, _session, socket) do if connected?(socket) do Phoenix.PubSub.subscribe(PubSubHelper.pubsub(), Registry.pubsub_topic()) end {:ok, socket} end def handle_info({:tab_updated, tab}, socket) do # Handle tab update {:noreply, socket} end """ @spec pubsub_topic() :: String.t() def pubsub_topic, do: @pubsub_topic @doc """ Broadcasts a tab update to all subscribers. """ @spec broadcast_update(Tab.t()) :: :ok def broadcast_update(%Tab{} = tab) do Phoenix.PubSub.broadcast(PubSubHelper.pubsub(), @pubsub_topic, {:tab_updated, tab}) :ok rescue _ -> :ok end @doc """ Broadcasts a full tab list refresh to all subscribers. """ @spec broadcast_refresh() :: :ok def broadcast_refresh do Phoenix.PubSub.broadcast(PubSubHelper.pubsub(), @pubsub_topic, :tabs_refreshed) :ok rescue _ -> :ok end @doc """ Checks if the registry has been initialized. """ @spec initialized?() :: boolean() def initialized? do case :ets.info(@ets_table) do :undefined -> false _ -> true end end @doc """ Gets all tabs with their active state for the given path. Returns tabs with an additional `:active` key set based on path matching. """ @spec get_tabs_with_active(String.t(), keyword()) :: [map()] def get_tabs_with_active(current_path, opts \\ []) do get_tabs(opts) |> Enum.map(fn tab -> Map.put(tab, :active, Tab.matches_path?(tab, current_path)) end) end @doc """ Loads the default PhoenixKit tabs (Dashboard, Settings). Called during initialization and can be used to reset to defaults. """ @spec load_defaults() :: :ok def load_defaults do GenServer.call(__MODULE__, :load_defaults) end @doc """ Loads tabs from application configuration. Reads from `:phoenix_kit, :user_dashboard_tabs` config key. """ @spec load_from_config() :: :ok def load_from_config do GenServer.call(__MODULE__, :load_from_config) end @doc """ Loads admin default tabs. Called during initialization. """ @spec load_admin_defaults() :: :ok def load_admin_defaults do GenServer.call(__MODULE__, :load_admin_defaults) end # GenServer Callbacks @impl true def init(_opts) do # Create ETS table for tab storage :ets.new(@ets_table, [:named_table, :set, :public, read_concurrency: true]) # Load user dashboard defaults and config load_defaults_internal() load_from_config_internal() # Load admin dashboard defaults and config load_admin_defaults_internal() load_admin_from_config_internal() {:ok, %{namespaces: MapSet.new([:phoenix_kit, :phoenix_kit_admin])}} end @impl true def handle_call({:register, namespace, tabs}, _from, state) do Enum.each(tabs, fn tab -> :ets.insert(@ets_table, {{:tab, tab.id}, tab}) :ets.insert(@ets_table, {{:namespace, namespace, tab.id}, true}) end) broadcast_refresh() {:reply, :ok, %{state | namespaces: MapSet.put(state.namespaces, namespace)}} end @impl true def handle_call({:unregister, namespace}, _from, state) do # Find and remove all tabs for this namespace pattern = {{:namespace, namespace, :_}, :_} :ets.match_object(@ets_table, pattern) |> Enum.each(fn {{:namespace, ^namespace, tab_id}, _} -> :ets.delete(@ets_table, {:tab, tab_id}) :ets.delete(@ets_table, {:namespace, namespace, tab_id}) end) broadcast_refresh() {:reply, :ok, %{state | namespaces: MapSet.delete(state.namespaces, namespace)}} end @impl true def handle_call({:unregister_tab, tab_id}, _from, state) do :ets.delete(@ets_table, {:tab, tab_id}) # Remove from all namespace mappings :ets.match_delete(@ets_table, {{:namespace, :_, tab_id}, :_}) broadcast_refresh() {:reply, :ok, state} end @impl true def handle_call({:register_groups, groups}, _from, state) do :ets.insert(@ets_table, {:groups, groups}) broadcast_refresh() {:reply, :ok, state} end @impl true def handle_call({:update_badge, tab_id, badge}, _from, state) do case get_tab(tab_id) do nil -> {:reply, :ok, state} tab -> updated_tab = Tab.update_badge(tab, badge) :ets.insert(@ets_table, {{:tab, tab_id}, updated_tab}) broadcast_update(updated_tab) {:reply, :ok, state} end end @impl true def handle_call({:set_attention, tab_id, attention}, _from, state) do case get_tab(tab_id) do nil -> {:reply, :ok, state} tab -> updated_tab = Tab.set_attention(tab, attention) :ets.insert(@ets_table, {{:tab, tab_id}, updated_tab}) broadcast_update(updated_tab) {:reply, :ok, state} end end @impl true def handle_call(:load_defaults, _from, state) do load_defaults_internal() broadcast_refresh() {:reply, :ok, state} end @impl true def handle_call(:load_from_config, _from, state) do load_from_config_internal() broadcast_refresh() {:reply, :ok, state} end @impl true def handle_call(:load_admin_defaults, _from, state) do load_admin_defaults_internal() broadcast_refresh() {:reply, :ok, state} end @impl true def handle_call({:update_tab, tab_id, attrs}, _from, state) do case get_tab(tab_id) do nil -> {:reply, :ok, state} tab -> updated_tab = Enum.reduce(attrs, tab, fn {:label, v}, acc -> %{acc | label: v} {:icon, v}, acc -> %{acc | icon: v} {:path, v}, acc -> %{acc | path: v} {:priority, v}, acc -> %{acc | priority: v} {:visible, v}, acc -> %{acc | visible: v} {:permission, v}, acc -> %{acc | permission: v} {:group, v}, acc -> %{acc | group: v} {:metadata, v}, acc -> %{acc | metadata: Map.merge(acc.metadata, v)} _, acc -> acc end) :ets.insert(@ets_table, {{:tab, tab_id}, updated_tab}) broadcast_update(updated_tab) {:reply, :ok, state} end end # Private helpers defp all_tabs do :ets.match_object(@ets_table, {{:tab, :_}, :_}) |> Enum.map(fn {_, tab} -> tab end) rescue _ -> [] end defp maybe_filter_visibility(tabs, nil, _include_hidden), do: tabs defp maybe_filter_visibility(tabs, _scope, true), do: tabs defp maybe_filter_visibility(tabs, scope, false) do Enum.filter(tabs, &Tab.visible?(&1, scope)) end defp sort_tabs(tabs) do Enum.sort_by(tabs, & &1.priority) end # Filter tabs by level. :admin returns admin+all, :user returns user+all defp maybe_filter_level(tabs, nil), do: tabs defp maybe_filter_level(tabs, :admin) do Enum.filter(tabs, fn tab -> level = Map.get(tab, :level, :user) level in [:admin, :all] end) end defp maybe_filter_level(tabs, :user) do Enum.filter(tabs, fn tab -> level = Map.get(tab, :level, :user) level in [:user, :all] end) end defp maybe_filter_level(tabs, _), do: tabs # Filter out tabs whose associated module is disabled. # Precomputes enabled state per unique permission key to avoid # redundant DB queries (e.g., 5 "publishing" tabs = 1 query, not 5). defp maybe_filter_enabled(tabs) do enabled_cache = tabs |> Enum.map(& &1.permission) |> Enum.reject(&is_nil/1) |> Enum.uniq() |> Map.new(fn perm -> enabled = try do Permissions.feature_enabled?(perm) rescue _ -> false end {perm, enabled} end) Enum.filter(tabs, fn tab -> case tab.permission do nil -> true perm -> Map.get(enabled_cache, perm, true) end end) end # Filter tabs by permission access defp maybe_filter_permission(tabs, nil), do: tabs defp maybe_filter_permission(tabs, scope) do Enum.filter(tabs, &Tab.permission_granted?(&1, scope)) end defp clear_namespace_tabs(namespace) do # Find and remove all tabs for this namespace pattern = {{:namespace, namespace, :_}, :_} :ets.match_object(@ets_table, pattern) |> Enum.each(fn {{:namespace, ^namespace, tab_id}, _} -> :ets.delete(@ets_table, {:tab, tab_id}) :ets.delete(@ets_table, {:namespace, namespace, tab_id}) end) rescue _ -> :ok end defp load_defaults_internal do # Clear existing phoenix_kit namespace tabs first clear_namespace_tabs(:phoenix_kit) # Default PhoenixKit tabs defaults = [ Tab.new!( id: :dashboard_home, label: "Dashboard", icon: "hero-home", path: "/dashboard", priority: 100, match: :exact, group: :main ), Tab.new!( id: :dashboard_settings, label: "Settings", icon: "hero-cog-6-tooth", path: "/dashboard/settings", priority: 900, match: :prefix, group: :account ) ] # Add tickets tab if module is enabled (checking at runtime) defaults = if tickets_enabled?() do ticket_tab = Tab.new!( id: :dashboard_tickets, label: "My Tickets", icon: "hero-ticket", path: "/dashboard/tickets", priority: 800, match: :prefix, group: :account ) defaults ++ [ticket_tab] else defaults end # Add billing tabs if module is enabled defaults = if billing_enabled?() do billing_tabs = [ Tab.new!( id: :dashboard_orders, label: "My Orders", icon: "hero-shopping-bag", path: "/dashboard/orders", priority: 200, match: :prefix, group: :main ), Tab.new!( id: :dashboard_billing_profiles, label: "Billing Profiles", icon: "hero-identification", path: "/dashboard/billing-profiles", priority: 850, match: :prefix, group: :account ) ] defaults ++ billing_tabs else defaults end # Add shop tabs if module is enabled defaults = if shop_enabled?() do shop_tabs = [ Tab.new!( id: :dashboard_shop, label: "Shop", icon: "hero-building-storefront", path: "/shop", priority: 300, match: :prefix, group: :shop ), Tab.new!( id: :dashboard_cart, label: "My Cart", icon: "hero-shopping-cart", path: "/cart", priority: 310, match: :prefix, group: :shop ) ] defaults ++ shop_tabs else defaults end # Default groups groups = [ %{id: :main, label: nil, priority: 100}, %{id: :shop, label: nil, priority: 200}, %{id: :account, label: nil, priority: 900} ] Enum.each(defaults, fn tab -> :ets.insert(@ets_table, {{:tab, tab.id}, tab}) :ets.insert(@ets_table, {{:namespace, :phoenix_kit, tab.id}, true}) end) :ets.insert(@ets_table, {:groups, groups}) end defp load_from_config_internal do # Load from user_dashboard_categories (admin-style config) load_from_categories_config() # Load tab configuration (flat list style) case Application.get_env(:phoenix_kit, :user_dashboard_tabs) do nil -> :ok tabs when is_list(tabs) -> Enum.each(tabs, fn tab_config -> case Tab.new(tab_config) do {:ok, tab} -> :ets.insert(@ets_table, {{:tab, tab.id}, tab}) :ets.insert(@ets_table, {{:namespace, :config, tab.id}, true}) {:error, _reason} -> # Log error but continue :ok end end) end # Load group configuration case Application.get_env(:phoenix_kit, :user_dashboard_tab_groups) do nil -> :ok groups when is_list(groups) -> :ets.insert(@ets_table, {:groups, groups}) end end # Load from user_dashboard_categories config (admin-style format) defp load_from_categories_config do alias PhoenixKit.Config.UserDashboardCategories categories = UserDashboardCategories.get_categories() if categories != [] do # Convert categories to tabs and register them tabs = UserDashboardCategories.to_tabs(categories) Enum.each(tabs, fn tab_config -> case Tab.new(tab_config) do {:ok, tab} -> :ets.insert(@ets_table, {{:tab, tab.id}, tab}) :ets.insert(@ets_table, {{:namespace, :categories, tab.id}, true}) {:error, _reason} -> # Log error but continue :ok end end) # Convert categories to groups and register them groups = UserDashboardCategories.to_groups(categories) existing_groups = get_groups() merged_groups = merge_groups(existing_groups, groups) :ets.insert(@ets_table, {:groups, merged_groups}) end end # Merge groups, preferring existing groups with same ID defp merge_groups(existing, new) do existing_ids = MapSet.new(Enum.map(existing, & &1.id)) new_groups = Enum.reject(new, fn group -> MapSet.member?(existing_ids, group.id) end) (existing ++ new_groups) |> Enum.sort_by(& &1.priority) end defp tickets_enabled? do Code.ensure_loaded?(PhoenixKit.Modules.Tickets) and function_exported?(PhoenixKit.Modules.Tickets, :enabled?, 0) and call_tickets_enabled() rescue _ -> false end defp billing_enabled? do Code.ensure_loaded?(PhoenixKit.Modules.Billing) and function_exported?(PhoenixKit.Modules.Billing, :enabled?, 0) and call_billing_enabled() rescue _ -> false end defp shop_enabled? do Code.ensure_loaded?(PhoenixKit.Modules.Shop) and function_exported?(PhoenixKit.Modules.Shop, :enabled?, 0) and call_shop_enabled() rescue _ -> false end # credo:disable-for-next-line Credo.Check.Design.AliasUsage defp call_tickets_enabled, do: PhoenixKit.Modules.Tickets.enabled?() # credo:disable-for-next-line Credo.Check.Design.AliasUsage defp call_billing_enabled, do: PhoenixKit.Modules.Billing.enabled?() # credo:disable-for-next-line Credo.Check.Design.AliasUsage defp call_shop_enabled, do: PhoenixKit.Modules.Shop.enabled?() # --- Admin Tab Loading --- defp load_admin_defaults_internal do clear_namespace_tabs(:phoenix_kit_admin) tabs = AdminTabs.default_tabs() groups = AdminTabs.default_groups() Enum.each(tabs, fn tab -> :ets.insert(@ets_table, {{:tab, tab.id}, tab}) :ets.insert(@ets_table, {{:namespace, :phoenix_kit_admin, tab.id}, true}) end) # Merge admin groups with existing groups existing_groups = get_groups() merged = merge_groups(existing_groups, groups) :ets.insert(@ets_table, {:groups, merged}) end defp load_admin_from_config_internal do # Load legacy AdminDashboardCategories config and convert to admin-level tabs load_legacy_admin_categories() # Load new :admin_dashboard_tabs config (highest precedence) case Application.get_env(:phoenix_kit, :admin_dashboard_tabs) do nil -> :ok tabs when is_list(tabs) -> Enum.each(tabs, fn tab_config -> # Auto-set level to :admin for admin dashboard tabs tab_config = Map.put_new(tab_config, :level, :admin) case Tab.new(tab_config) do {:ok, tab} -> :ets.insert(@ets_table, {{:tab, tab.id}, tab}) :ets.insert(@ets_table, {{:namespace, :admin_config, tab.id}, true}) {:error, _reason} -> :ok end end) end end # Convert legacy AdminDashboardCategories to admin-level Tab structs defp load_legacy_admin_categories do alias PhoenixKit.Config.AdminDashboardCategories categories = AdminDashboardCategories.get_categories() if categories != [] do Logger.info( "[PhoenixKit] Legacy :admin_dashboard_categories config detected. " <> "Consider migrating to :admin_dashboard_tabs format." ) # Convert each category to admin-level tabs categories |> Enum.with_index() |> Enum.each(fn {category, cat_idx} -> # Create parent tab from category cat_id = :"admin_custom_#{cat_idx}" first_url = case category.subsections do [first | _] -> Map.get(first, :url, "/admin") _ -> "/admin" end parent = %Tab{ id: cat_id, label: category.title, icon: category.icon || "hero-folder", path: first_url, priority: 700 + cat_idx * 10, level: :admin, match: :prefix, group: :admin_modules, subtab_display: :when_active, highlight_with_subtabs: false } :ets.insert(@ets_table, {{:tab, parent.id}, parent}) :ets.insert(@ets_table, {{:namespace, :admin_legacy, parent.id}, true}) # Create child tabs from subsections category.subsections |> Enum.with_index() |> Enum.each(fn {subsection, sub_idx} -> child_id = :"admin_custom_#{cat_idx}_#{sub_idx}" child = %Tab{ id: child_id, label: subsection.title, icon: subsection.icon || "hero-document-text", path: subsection.url, priority: 701 + cat_idx * 10 + sub_idx, level: :admin, match: :prefix, parent: cat_id } :ets.insert(@ets_table, {{:tab, child.id}, child}) :ets.insert(@ets_table, {{:namespace, :admin_legacy, child.id}, true}) end) end) end end end