Class: Panda::Core::NavigationRegistry

Inherits:
Object
  • Object
show all
Defined in:
lib/panda/core/navigation_registry.rb

Overview

Declarative registry for extending admin sidebar navigation.

Gems and host apps register navigation sections and items at boot time, and the registry merges them with the base admin_navigation_items lambda at render time. This avoids the fragile lambda-wrapping pattern where each gem must capture the existing proc, create a new one, and manipulate the resulting array with index lookups and insertions.

Path resolution

  • path: — auto-prefixed with admin_path (e.g. path: "feature_flags" becomes "/admin/feature_flags")
  • url: — used as-is, for full URLs or absolute paths
  • path_helper: — a route helper symbol resolved at build time via helpers
  • target: — HTML target attribute (e.g. "_blank"), omitted when nil

Visibility and filtering

  • visible: — lambda receiving user, evaluated at build time; item/section hidden when false
  • filter() — post-build removal of items/sections by label, conditional on a user-receiving lambda

Usage

Convenience methods on Configuration delegate here, so the typical usage is inside a Panda::Core.configure block:

# In a host app initializer or engine:
Panda::Core.configure do |config|
# Add a new section with items, positioned after "Website"
config.insert_admin_menu_section "Members",
  icon: "fa-solid fa-users",
  after: "Website" do |section|
    section.item "Onboarding", path: "members/onboarding"
  end

# Add an item to an existing section
config.insert_admin_menu_item "Feature Flags",
  section: "Settings",
  path: "feature_flags"

# Permission-based visibility
config.insert_admin_menu_item "Suggestions",
  section: "Tools",
  path: "cms/content_suggestions",
  visible: -> (user) { user.admin? || user.has_permission?(:review_content) }

# Post-build filter (hides for non-admins)
config.filter_admin_menu "Roles",
  visible: -> (user) { user.admin? }

# Bottom section (rendered with UserMenuComponent)
config.insert_admin_menu_section "Notifications",
  position: :bottom do |s|
    s.item "All", path: "notifications"
  end
end

You can also call the class methods directly:

Panda::Core::NavigationRegistry.section("Tools", icon: "fa-solid fa-wrench")
Panda::Core::NavigationRegistry.item("Database", section: "Tools", path: "tools/database")

Build order

NavigationRegistry.build is called once per request by the sidebar component:

  1. The base admin_navigation_items lambda is called (backward compatible)
  2. Registered sections are inserted (skipped if a section with the same label already exists in the base)
  3. Registered items are appended to their target sections
  4. Legacy admin_user_menu_items are migrated into the "My Account" bottom section
  5. Visibility lambdas are evaluated; items/sections hidden when visible: returns false
  6. Post-build filters are applied

Paths are resolved at build time, so admin_path can be configured after registrations are made.

Defined Under Namespace

Classes: SectionContext

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.filtersObject (readonly)

Returns the value of attribute filters.



142
143
144
# File 'lib/panda/core/navigation_registry.rb', line 142

def filters
  @filters
end

.itemsObject (readonly)

Returns the value of attribute items.



142
143
144
# File 'lib/panda/core/navigation_registry.rb', line 142

def items
  @items
end

.sectionsObject (readonly)

Returns the value of attribute sections.



142
143
144
# File 'lib/panda/core/navigation_registry.rb', line 142

def sections
  @sections
end

Class Method Details

.build(user, helpers: nil) ⇒ Array<Hash>

Build the final navigation array for the current user.

Called once per request by the sidebar component. Combines the base admin_navigation_items lambda with all registered sections, items, and filters.

Parameters:

  • user (Object)

    The current authenticated user

  • helpers (Object, nil) (defaults to: nil)

    View helpers for resolving path_helper: symbols

Returns:

  • (Array<Hash>)

    Navigation items ready for rendering



254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
# File 'lib/panda/core/navigation_registry.rb', line 254

def build(user, helpers: nil)
  base = Panda::Core.config.admin_navigation_items&.call(user) || []
  admin_path = Panda::Core.config.admin_path

  # Convert permission: keys from base lambda items into _visible lambdas
  apply_permission_visibility!(base)

  # Apply registered sections
  @sections.each do |section|
    # Skip if a section with this label already exists in base
    next if base.any? { |item| item[:label] == section[:label] }

    entry = {label: section[:label], icon: section[:icon], position: section[:position], _visible: section[:visible]}
    if section[:items].any?
      entry[:children] = section[:items].map { |item|
        resolved = resolve_item(item, admin_path, helpers)
        resolved[:_visible] = item[:visible]
        resolved
      }
    end

    insert_section(base, entry, after: section[:after], before: section[:before])
  end

  # Apply registered items to their target sections
  @items.each do |item|
    target_section = base.find { |s| s[:label] == item[:section] }
    next unless target_section

    target_section[:children] ||= []
    resolved = resolve_item(item, admin_path, helpers)
    resolved[:_visible] = item[:visible]

    insert_item(target_section[:children], resolved, before: item[:before], after: item[:after])
  end

  # Migrate legacy admin_user_menu_items into the "My Account" bottom section
  legacy_items = Panda::Core.config.admin_user_menu_items
  if legacy_items&.any?
     = base.find { |s| s[:label] == "My Account" && s[:position] == :bottom }
    if 
      legacy_items.each do |menu_item|
        next if menu_item[:path].blank? && menu_item[:url].blank?
        resolved = {label: menu_item[:label], path: menu_item[:path]}
        resolved[:_visible] = menu_item[:visible]
        # Insert before Logout if it exists
        logout_idx = [:children]&.index { |c| c[:label] == "Logout" }
        if logout_idx
          [:children].insert(logout_idx, resolved)
        else
          ([:children] ||= []) << resolved
        end
      end
    end
  end

  # Apply section-level visible: — remove sections hidden for this user
  base.reject! { |entry| entry[:_visible] && !entry[:_visible].call(user) }

  # Apply item-level visible: — remove children hidden for this user
  base.each do |entry|
    next unless entry[:children]
    entry[:children].reject! { |child| child[:_visible] && !child[:_visible].call(user) }
  end

  # Apply registered filters — walk all sections and children
  @filters.each do |filter|
    base.reject! { |entry| entry[:label] == filter[:label] && !filter[:visible].call(user) }
    base.each do |entry|
      next unless entry[:children]
      entry[:children].reject! { |child| child[:label] == filter[:label] && !filter[:visible].call(user) }
    end
  end

  # Clean up internal keys before returning
  base.each do |entry|
    entry.delete(:_visible)
    entry[:position] ||= :top
    entry[:children]&.each { |child| child.delete(:_visible) }
  end

  base
end

.filter(label, visible:) ⇒ Object

Register a post-build filter that conditionally hides items by label. Walks all sections and their children during build().

Parameters:

  • label (String)

    Label to match

  • visible (Proc)

    Lambda receiving user — item hidden when false



241
242
243
# File 'lib/panda/core/navigation_registry.rb', line 241

def filter(label, visible:)
  @filters << {label: label, visible: visible}
end

.item(label, section:, path: nil, url: nil, target: nil, visible: nil, permission: nil, before: nil, after: nil, method: nil, button_options: {}, path_helper: nil) ⇒ Object

Register an item to be appended to a named section.

The target section can come from either the base lambda or a previously registered section. If the section doesn't exist at build time, the item is silently skipped.

rubocop:disable Metrics/ParameterLists

Examples:

Add to an existing section with auto-prefixed path

item("Feature Flags", section: "Settings", path: "feature_flags")

Permission-gated item

item("Roles", section: "Settings", path: "cms/roles",
  permission: :manage_roles)

Parameters:

  • label (String)

    Item label displayed in the sidebar

  • section (String)

    Label of the target section to add to

  • path (String, nil) (defaults to: nil)

    Relative path (auto-prefixed with admin_path)

  • url (String, nil) (defaults to: nil)

    Absolute URL (used as-is, no prefixing)

  • target (String, nil) (defaults to: nil)

    HTML target attribute (e.g. "_blank")

  • visible (Proc, nil) (defaults to: nil)

    Lambda receiving user, hides item when false

  • permission (Symbol, nil) (defaults to: nil)

    Permission key — item hidden unless user is authorized. Checked via config.authorization_policy. Overridden by visible: if both given.

  • before (Symbol, String, nil) (defaults to: nil)

    :all to insert at beginning, or label to insert before

  • after (Symbol, String, nil) (defaults to: nil)

    :all to insert at end (default), or label to insert after

  • method (Symbol, nil) (defaults to: nil)

    HTTP method (e.g. :delete for logout buttons)

  • button_options (Hash) (defaults to: {})

    Extra options for button_to rendering

  • path_helper (Symbol, nil) (defaults to: nil)

    Route helper name, resolved at build time via helpers



218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
# File 'lib/panda/core/navigation_registry.rb', line 218

def item(label, section:, path: nil, url: nil, target: nil, visible: nil, permission: nil,
  before: nil, after: nil, method: nil, button_options: {}, path_helper: nil)
  effective_visible = visible || permission_to_visible(permission)
  @items << {
    label: label,
    section: section,
    path: path,
    url: url,
    target: target,
    visible: effective_visible,
    before: before,
    after: after,
    method: method,
    button_options: button_options,
    path_helper: path_helper
  }
end

.reset!Object

Clear all registrations and re-register defaults (for test isolation). This ensures the default "My Account" bottom section is always present after a reset, preventing order-dependent test failures.



341
342
343
344
345
346
# File 'lib/panda/core/navigation_registry.rb', line 341

def reset!
  @sections = []
  @items = []
  @filters = []
  Panda::Core.config.send(:register_default_user_menu)
end

.section(label, icon: nil, after: nil, before: nil, visible: nil, permission: nil, position: :top) {|SectionContext| ... } ⇒ Object

Register a new navigation section.

If a section with the same label already exists in the base navigation (from the admin_navigation_items lambda), the registration is skipped at build time — this prevents duplicate sections.

rubocop:disable Metrics/ParameterLists

Examples:

Add a section with items after "Website"

section("Members", icon: "fa-solid fa-users", after: "Website") do |s|
  s.item "Onboarding", path: "members/onboarding"
end

Add a permission-gated section

section("Settings", icon: "fa-solid fa-gear", permission: :manage_settings)

Add a bottom section (rendered with UserMenuComponent)

section("Notifications", position: :bottom) do |s|
  s.item "All", path: "notifications"
end

Parameters:

  • label (String)

    Section label displayed in the sidebar

  • icon (String) (defaults to: nil)

    FontAwesome icon class (e.g. "fa-solid fa-users")

  • after (String, nil) (defaults to: nil)

    Insert after the section with this label

  • before (String, nil) (defaults to: nil)

    Insert before the section with this label

  • visible (Proc, nil) (defaults to: nil)

    Lambda receiving user, hides section when false

  • permission (Symbol, nil) (defaults to: nil)

    Permission key — section hidden unless user is authorized. Checked via config.authorization_policy. Overridden by visible: if both given.

  • position (Symbol) (defaults to: :top)

    :top (default) or :bottom — controls sidebar placement

Yields:



173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/panda/core/navigation_registry.rb', line 173

def section(label, icon: nil, after: nil, before: nil, visible: nil, permission: nil, position: :top, &block)
  context = SectionContext.new
  yield context if block

  effective_visible = visible || permission_to_visible(permission)

  @sections << {
    label: label,
    icon: icon,
    after: after,
    before: before,
    visible: effective_visible,
    position: position,
    items: context.items
  }
end