Module: LcpRuby::LayoutHelper
- Defined in:
- app/helpers/lcp_ruby/layout_helper.rb
Constant Summary collapse
- PROVIDER_STRIP_KEYS =
Internal/derived keys silently dropped from provider returns. ‘type` is always re-derived from the destination keys, so a provider that echoes it (e.g. from a stored to_kwargs snapshot) must not trip the forbidden-key check below — nor reach MenuItem.from_hash_at_depth, which rejects `type` to stay consistent with the JSON schema.
%w[type].freeze
- PROVIDER_RETURN_ALLOWED_KEYS =
MenuItem-shaped keys allowed in provider returns. ‘provider:` and `panel_provider:` are NOT listed (chains are banned — each YAML position resolves to one source, never a chain), so a provider return carrying them hits the forbidden-key branch and raises (record_error in prod) —they are rejected, not silently dropped (that is PROVIDER_STRIP_KEYS). MUST stay a subset of MenuItem::KNOWN_KEYS — every key here is fed straight into MenuItem.from_hash_at_depth, which rejects unknown keys.
%w[ label label_key icon url method aria_label aria_label_key badge children visible_when disable_when presenter alias action defaults render render_panel widget position options ].freeze
Instance Method Summary collapse
-
#apply_path_template(text) ⇒ Object
Substitute ‘namespacenamespace.member` tokens in a label/aria string at render time, mirroring `menu_item_url`’s template handling.
-
#build_menu_request_context ⇒ Object
Builds the ‘request_context:` hash passed to provider classes.
- #current_density_default ⇒ Object
- #current_radius_default ⇒ Object
-
#current_theme_default ⇒ Object
— Theme helpers —.
-
#deep_strip_provider_keys(hash) ⇒ Object
Removes PROVIDER_STRIP_KEYS (internal/derived keys a provider may echo, e.g. ‘type`) from a provider-returned item AND recursively from its nested `children:`.
-
#expand_menu_provider(item, user:, request_context:, depth:) ⇒ Object
Calls a ‘provider:` class and expands its return into the walker’s current depth.
-
#field_label_for(col_or_field, field_def: nil, model_name: nil) ⇒ Object
Resolves a column or field label using the same fallback chain as FieldDefinition#resolved_label, with one extra step for synthetic fields (timestamps, FK columns, dotted association paths) that don’t have a FieldDefinition object.
-
#filter_provider_keys(hash, provider_name) ⇒ Object
Filters a provider-returned hash to the whitelisted MenuItem keys.
- #hidden_on_classes(config) ⇒ Object
-
#inject_sidebar_toggle_if_needed(items, menu_def) ⇒ Object
Internal helper; views should call ‘visible_top_menu_items` instead.
-
#lcp_emit_engine_assets? ⇒ Boolean
Whether the engine should emit its own pipeline-dependent asset tags (‘javascript_include_tag`/`stylesheet_link_tag`/`asset_path` for `lcp_ruby/*`).
- #lcp_page_title_tag ⇒ Object
- #menu_bottom_items(items) ⇒ Object
-
#menu_defined? ⇒ Boolean
— Menu system helpers —.
- #menu_definition ⇒ Object
-
#menu_item_active?(item, current_slug) ⇒ Boolean
Check if a menu item is active for the current slug.
-
#menu_item_badge(item) ⇒ Object
Render badge HTML for a menu item, or nil if no badge / provider returns nil.
-
#menu_item_icon_html(item) ⇒ Object
Render the icon placeholder for a menu item.
-
#menu_item_icon_only?(item) ⇒ Boolean
True when the item renders nothing visible besides an icon — used by the layout partials to attach the ‘–icon-only` class to the surrounding `<li>` for compact spacing.
-
#menu_item_label(item) ⇒ Object
Resolve the visible display label for a menu item.
-
#menu_item_link_aria_label(item) ⇒ Object
Resolve the accessible name for a menu item — used by templates to set ‘aria-label` on the link/button when no visible label would render.
-
#menu_item_path(item) ⇒ Object
Generate the path for a menu item.
-
#menu_item_url(item) ⇒ Object
Resolves a menu item to a ‘{ url:, method: }` hash for rendering.
-
#menu_item_visible?(item, helper: self) ⇒ Boolean
Check if a menu item should be visible to the current user.
-
#menu_layout ⇒ Object
Returns “top”, “sidebar”, or “both”.
-
#menu_left_top_items(items) ⇒ Object
Split top_menu items into left (default) and right-aligned groups.
-
#menu_main_items(items) ⇒ Object
Split items into main (non-bottom) and bottom items for sidebar.
- #menu_right_top_items(items) ⇒ Object
- #navigable_entries ⇒ Object
-
#panel_items_for(item) ⇒ Object
Returns the resolved panel children for a ‘:group + render_panel:` item — `static_children + panel_provider_items`.
-
#panel_provider_items_for(item) ⇒ Object
Lazy resolver for ‘panel_provider:` items.
-
#permission_evaluator_for(presenter_def, user) ⇒ Object
Per-render PermissionEvaluator cache for ‘presenter:` menu items.
-
#presenter_description(presenter, view:) ⇒ Object
Resolves a presenter view description with i18n override path ‘lcp_ruby.presenters.<name>.<view>.description`.
- #render_custom_partial(partial:, locals:, context: "custom partial") ⇒ Object
-
#render_menu_item(item, context: :panel) ⇒ Object
Renders a MenuItem in one of two contexts.
-
#resolve_section_title(section, presenter_name = nil) ⇒ Object
Resolve section title via i18n with fallback to the YAML value.
-
#responsive_top_nav_data_attrs(menu_def) ⇒ Object
data-* attributes for the top <nav> element.
-
#sidebar_initial_rail_state(menu_def) ⇒ Object
Read the current rail state from the server-set cookie (with policy fallback).
- #theme_switching_allowed? ⇒ Boolean
-
#top_menu_item_responsive_data_attrs(item, index:) ⇒ Object
Per-item data-* attributes for top_menu items.
-
#visible_menu_items(items, depth: 0) ⇒ Object
Filter menu items by visibility (role + presenter access), AND expand ‘provider:` items via the menu_items registry.
-
#visible_top_menu_items(menu_def) ⇒ Object
Wrapper around ‘visible_menu_items` for the TOP menu specifically, auto-injecting a synthesized `sidebar_toggle:` item when the responsive policy enables off-canvas mode and no explicit toggle is authored.
Instance Method Details
#apply_path_template(text) ⇒ Object
Substitute ‘LcpRuby::LayoutHelper.namespacenamespace.member` tokens in a label/aria string at render time, mirroring `menu_item_url`’s template handling. nil in → nil out. Runtime errors degrade safely in production: the untemplated raw string falls through so the item still renders.
609 610 611 612 613 614 615 616 617 618 619 620 621 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 609 def apply_path_template(text) return text unless text.is_a?(String) && text.include?("{") LcpRuby::Metadata::PathTemplate.resolve( text, current_user: LcpRuby::Current.user, request: (request if respond_to?(:request)) ) rescue LcpRuby::MenuRenderError => e raise unless Rails.env.production? LcpRuby.record_error(e, subsystem: "menu_path_template", text: text) text end |
#build_menu_request_context ⇒ Object
Builds the ‘request_context:` hash passed to provider classes. Provider may run outside a controller-driven request (cache warm-up, mailer, background job that builds an HTML preview); `path: nil` and `params: {}` are documented fallbacks.
350 351 352 353 354 355 356 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 350 def if respond_to?(:request) && request { path: request.path, params: request.filtered_parameters } else { path: nil, params: {} } end end |
#current_density_default ⇒ Object
813 814 815 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 813 def current_density_default _preference_default("density", %w[comfortable compact], :density, :default) end |
#current_radius_default ⇒ Object
817 818 819 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 817 def current_radius_default _preference_default("border_radius", %w[rounded sharp], :border_radius, :default) end |
#current_theme_default ⇒ Object
— Theme helpers —
809 810 811 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 809 def current_theme_default _preference_default("theme", %w[auto light dark], :default) end |
#deep_strip_provider_keys(hash) ⇒ Object
Removes PROVIDER_STRIP_KEYS (internal/derived keys a provider may echo, e.g. ‘type`) from a provider-returned item AND recursively from its nested `children:`. These keys are re-derived by MenuItem, so stripping them at every depth keeps reject_unknown_keys! from raising on an echoed `type:` in a nested child. Key type (String/Symbol) is preserved.
334 335 336 337 338 339 340 341 342 343 344 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 334 def deep_strip_provider_keys(hash) hash.each_with_object({}) do |(k, v), out| next if PROVIDER_STRIP_KEYS.include?(k.to_s) out[k] = if k.to_s == "children" && v.is_a?(Array) v.map { |child| child.is_a?(Hash) ? deep_strip_provider_keys(child) : child } else v end end end |
#expand_menu_provider(item, user:, request_context:, depth:) ⇒ Object
Calls a ‘provider:` class and expands its return into the walker’s current depth. Returns an array of MenuItem instances (siblings to be flat_mapped in by the walker). nil/[] returns produce silent collapse.
Provider returns are validated through MenuItem.from_hash_at_depth with the same destination mutex + structural rules as YAML —invalid shapes raise in dev/test, record_error + drop in prod. Depth is threaded so providers can’t sneak past MAX_NESTING_DEPTH.
244 245 246 247 248 249 250 251 252 253 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 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 244 def (item, user:, request_context:, depth:) provider_class = Services::Registry.lookup("menu_items", item.provider_name) unless provider_class raise LcpRuby::MetadataError, "unknown menu_items provider: #{item.provider_name}" end result = provider_class.call( options: item. || {}, current_user: user, request_context: request_context ) return [] if result.nil? || result == [] # Strip every destination + provider-specific kwarg from the # original item's snapshot — only modifiers (label, icon, # position, etc.) carry over to the expanded children. Provider # supplies the destination via its returned hash, so leaving # `children: []` (constructor default) in base_kwargs would # collide with a provider-returned `url:` and trip the # destination mutex check at from_hash_at_depth. base_modifiers = { "label" => item.label.is_a?(String) ? item.label : nil, "label_key" => item.label_key, "icon" => item.icon, "aria_label" => item.aria_label, "aria_label_key" => item.aria_label_key, "position" => item.position, "badge" => item.badge, "visible_when" => (item.visible_when.empty? ? nil : item.visible_when), "disable_when" => (item.disable_when.empty? ? nil : item.disable_when), "render" => item.render_partial }.compact Array.wrap(result).flat_map do |hash| sanitized = filter_provider_keys(hash, item.provider_name) # Provider keys win on conflict — provider's label: overrides # YAML's label: per spec. merged = base_modifiers.merge(sanitized) constructed = LcpRuby::Metadata::MenuItem.from_hash_at_depth(merged, depth) # Provider may have returned a :group with children — recurse. if constructed.children.any? [ constructed.with(children: (constructed.children, depth: depth + 1)) ] else [ constructed ] end end rescue LcpRuby::MetadataError raise # depth overflow / structural — never silently swallow rescue StandardError => e raise unless Rails.env.production? LcpRuby.record_error( e, subsystem: "menu_item_provider", provider: item.provider_name ) [] end |
#field_label_for(col_or_field, field_def: nil, model_name: nil) ⇒ Object
Resolves a column or field label using the same fallback chain as FieldDefinition#resolved_label, with one extra step for synthetic fields (timestamps, FK columns, dotted association paths) that don’t have a FieldDefinition object.
1. col_or_field["label"] — explicit override in presenter YAML
2. field_def.resolved_label(model_name:) — model-scoped + global i18n
3. I18n.t("lcp_ruby.fields.<basename>") — global, when no field_def
4. basename.humanize
24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 24 def field_label_for(col_or_field, field_def: nil, model_name: nil) explicit = col_or_field.is_a?(Hash) ? (col_or_field["label"] || col_or_field[:label]) : nil return explicit if explicit.present? field_name = (col_or_field.is_a?(Hash) ? (col_or_field["field"] || col_or_field[:field]) : col_or_field).to_s basename = field_name.split(".").last if field_def from_field = field_def.resolved_label(model_name: model_name) return from_field if from_field.present? end I18n.t("lcp_ruby.fields.#{basename}", default: basename.humanize) end |
#filter_provider_keys(hash, provider_name) ⇒ Object
Filters a provider-returned hash to the whitelisted MenuItem keys. PROVIDER_STRIP_KEYS (e.g. ‘type`) are dropped silently at EVERY depth first (so a `children: [type:…]` echo doesn’t trip reject_unknown_keys! during the nested build), then top-level keys outside PROVIDER_RETURN_ALLOWED_KEYS raise — record_error in production.
307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 307 def filter_provider_keys(hash, provider_name) stringified = deep_strip_provider_keys(hash).transform_keys(&:to_s) sanitized = {} stringified.each do |k, v| if PROVIDER_RETURN_ALLOWED_KEYS.include?(k) sanitized[k] = v else err = LcpRuby::MetadataError.new( "menu_items provider '#{provider_name}' returned forbidden key " \ "#{k.inspect}; allowed keys: #{PROVIDER_RETURN_ALLOWED_KEYS.join(', ')}" ) raise err unless Rails.env.production? LcpRuby.record_error( err, subsystem: "menu_item_provider", provider: provider_name, key: k ) end end sanitized end |
#hidden_on_classes(config) ⇒ Object
64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 64 def hidden_on_classes(config) return "" unless config.is_a?(Hash) classes = [] hidden_on = config["hidden_on"] if hidden_on.is_a?(Array) hidden_on.each do |breakpoint| classes << "lcp-hidden-#{breakpoint}" end elsif hidden_on.is_a?(String) classes << "lcp-hidden-#{hidden_on}" end classes.join(" ") end |
#inject_sidebar_toggle_if_needed(items, menu_def) ⇒ Object
Internal helper; views should call ‘visible_top_menu_items` instead. Lives at the helper layer (not inside `visible_menu_items`) so the synth has access to the resolved responsive policy and the caller context (top vs sidebar menu).
756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 756 def (items, ) policy = &.responsive return items unless policy return items unless &. return items unless policy. == "off_canvas" return items unless policy. return items if items.any?(&:sidebar_toggle?) # Positional argument (not kwarg splat) — Ruby 3 would otherwise # reinterpret a string-keyed hash as kwargs and raise ArgumentError. synthetic = LcpRuby::Metadata::MenuItem.from_hash({ "sidebar_toggle" => true, "icon" => "menu", "aria_label_key" => "lcp_ruby.nav.open_menu", "position" => "left" }) [ synthetic ] + items end |
#lcp_emit_engine_assets? ⇒ Boolean
Whether the engine should emit its own pipeline-dependent asset tags (‘javascript_include_tag`/`stylesheet_link_tag`/`asset_path` for `lcp_ruby/*`). False when the host opted out via `config.skip_asset_pipeline_check = true` — that host runs its own JS bundler and owns including the LCP assets, so emitting the tags would raise Propshaft::MissingAssetError → 500. Single source of truth so the layout’s several engine-asset blocks (core bundle, dev toolbar, vendored mermaid) can’t drift — adding a new one just calls this.
11 12 13 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 11 def lcp_emit_engine_assets? !LcpRuby.configuration.skip_asset_pipeline_check end |
#lcp_page_title_tag ⇒ Object
801 802 803 804 805 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 801 def lcp_page_title_tag default_title = Rails.application.class.module_parent_name.humanize app_title = I18n.t("lcp_ruby.app.title", default: default_title).presence || default_title content_tag(:title, [ content_for(:title).presence, app_title ].compact.join(" \u2014 ")) end |
#menu_bottom_items(items) ⇒ Object
679 680 681 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 679 def (items) items.select(&:bottom?) end |
#menu_defined? ⇒ Boolean
— Menu system helpers —
113 114 115 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 113 def LcpRuby.loader. end |
#menu_definition ⇒ Object
117 118 119 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 117 def LcpRuby.loader. end |
#menu_item_active?(item, current_slug) ⇒ Boolean
Check if a menu item is active for the current slug
529 530 531 532 533 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 529 def (item, current_slug) return false if current_slug.blank? item.contains_slug?(current_slug, LcpRuby.loader) end |
#menu_item_badge(item) ⇒ Object
Render badge HTML for a menu item, or nil if no badge / provider returns nil
655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 655 def (item) return nil unless item.has_badge? provider = Services::Registry.lookup("data_providers", item.badge_provider) unless provider Rails.logger.debug("[LcpRuby::Menu] Data provider '#{item.badge_provider}' not registered") return nil end data = provider.call(user: LcpRuby::Current.user) return nil if data.nil? (item, data) rescue => e raise unless Rails.env.production? LcpRuby.record_error(e, subsystem: "menu_badge", badge_provider: item.badge_provider) nil end |
#menu_item_icon_html(item) ⇒ Object
Render the icon placeholder for a menu item. Emits an ‘<i data-lucide=“…”>` element which Lucide replaces with an inline SVG (see `app/assets/javascripts/lcp_ruby/lucide_init.js`). Always carries `aria-hidden=“true”` so the icon contributes nothing to the surrounding link/button’s accessible name —see the W3C accessible-name algorithm. Returns nil when the item has no resolved icon, so callers can use ‘<%= menu_item_icon_html(item) %>` unconditionally.
631 632 633 634 635 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 631 def (item) icon = item.resolved_icon(LcpRuby.loader) return nil if icon.blank? content_tag(:i, "", "data-lucide" => icon, class: "lcp-menu-icon", "aria-hidden" => "true") end |
#menu_item_icon_only?(item) ⇒ Boolean
True when the item renders nothing visible besides an icon —used by the layout partials to attach the ‘–icon-only` class to the surrounding `<li>` for compact spacing.
Items with ‘render:` (custom trigger renderer like lcp_ruby/user_menu) are NOT icon-only — the renderer owns full inner content including any label/avatar/caret it chooses. The framework can’t introspect what the partial draws, so we conservatively assume the renderer supplies a visible name and skip icon-only spacing rules. This keeps render-based items aligned with regular group headers (icon column ~16px) instead of being centered like real icon-only items (notifications bell, help link, etc.).
649 650 651 652 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 649 def (item) return false if item.render_partial.present? (item).blank? end |
#menu_item_label(item) ⇒ Object
Resolve the visible display label for a menu item. Returns nil when the item renders icon-only (no ‘label`, or `label: false`, or a view_group whose presenter exposes no resolvable label — the validator catches the misconfiguration form at boot).
591 592 593 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 591 def (item) apply_path_template(item.resolved_label(LcpRuby.loader)) end |
#menu_item_link_aria_label(item) ⇒ Object
Resolve the accessible name for a menu item — used by templates to set ‘aria-label` on the link/button when no visible label would render. When a visible label IS rendered, returns nil so the link’s accessible name comes from the visible text instead (preventing screen-reader duplication or aria-label shadowing).
600 601 602 603 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 600 def (item) return nil if (item).present? apply_path_template(item.resolved_aria_label(LcpRuby.loader)) end |
#menu_item_path(item) ⇒ Object
Generate the path for a menu item. Kept for backwards-compat with any external caller; the wrappers below now use the richer ‘menu_item_url` which also returns the HTTP method (so `link_to` vs `button_to` dispatch is possible).
539 540 541 542 543 544 545 546 547 548 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 539 def (item) if item.view_group? slug = item.resolved_slug(LcpRuby.loader) return nil unless slug lcp_ruby.resources_path(lcp_slug: slug) elsif item.link? item.url end end |
#menu_item_url(item) ⇒ Object
Resolves a menu item to a ‘{ url:, method: }` hash for rendering. The method dictates `link_to` (GET) vs `button_to` (non-GET) in the navigation partials. Returns nil when the URL cannot be resolved (unknown view_group page, unregistered presenter, or path-template runtime error in production —`record_error` fires once and the item silently drops).
Centralizes the path-template runtime rescue here so the layout partials don’t need their own ‘rescue` branches; any MenuRenderError surfaces in dev/test (raised) but is swallowed in production with the canonical idiom (record_error + return nil → guard at `if link_data` skips the wrapper).
562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 562 def (item) if item.view_group? slug = item.resolved_slug(LcpRuby.loader) return nil unless slug { url: lcp_ruby.resources_path(lcp_slug: slug), method: :get } elsif item.presenter_slug.present? LcpRuby::Metadata::MenuItemResolver.resolve_presenter(item, loader: LcpRuby.loader) elsif item.url.present? resolved = LcpRuby::Metadata::PathTemplate.resolve( item.url, current_user: LcpRuby::Current.user, request: (request if respond_to?(:request)) ) { url: resolved, method: item.http_method || :get } end rescue LcpRuby::MenuRenderError => e raise unless Rails.env.production? LcpRuby.record_error( e, subsystem: "menu_path_template", item: item.label || item.url || item.presenter_slug || "?" ) nil end |
#menu_item_visible?(item, helper: self) ⇒ Boolean
Check if a menu item should be visible to the current user. ‘helper:` is forwarded into MenuItem#meets_visibility? so the per-render permission-evaluator cache lives at the helper level (one cache per render pass, naturally scoped by view-context lifetime).
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 185 def (item, helper: self) return true if item.separator? # Conditional visibility — full ConditionEvaluator surface # (service:, all/any/not) PLUS presenter-permission gate for # `presenter:` items (can_access_presenter? + can?/can_execute_action?). # Empty visible_when + no presenter_slug → always visible. return false unless item.meets_visibility?(LcpRuby::Current.user, helper) # View group items: check presenter accessibility if item.view_group? vg = LcpRuby.loader.view_group_definitions[item.view_group_name] return false unless vg page = LcpRuby.loader.page_definitions[vg.primary_page] return false unless page&.routable? # Standalone pages (dashboards) have no presenter — skip presenter check, # visibility is controlled by visible_when in menu.yml presenter = LcpRuby.loader.presenter_definitions[page.main_presenter_name] # Standalone pages (dashboards) may have no presenter — skip check if presenter return false unless presenter_accessible?(presenter) end end true end |
#menu_layout ⇒ Object
Returns “top”, “sidebar”, or “both”
122 123 124 125 126 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 122 def return "top" unless .layout_mode end |
#menu_left_top_items(items) ⇒ Object
Split top_menu items into left (default) and right-aligned groups
684 685 686 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 684 def (items) items.reject(&:right?) end |
#menu_main_items(items) ⇒ Object
Split items into main (non-bottom) and bottom items for sidebar
675 676 677 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 675 def (items) items.reject(&:bottom?) end |
#menu_right_top_items(items) ⇒ Object
688 689 690 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 688 def (items) items.select(&:right?) end |
#navigable_entries ⇒ Object
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 78 def navigable_entries LcpRuby.loader.navigable_view_groups.filter_map do |vg| # Skip infrastructure view groups that opt out of auto-listing. # Same gate as Loader#auto_append_unreferenced_view_groups! — keeps both # auto-mode paths (with and without menu.yml) consistent. next unless vg.auto_append? page = LcpRuby.loader.page_definitions[vg.primary_page] next unless page&.routable? presenter = LcpRuby.loader.presenter_definitions[page.main_presenter_name] # Standalone pages (dashboards) have no presenter — skip presenter check, # visibility is controlled by visible_when in menu.yml or view group config if presenter next unless presenter_accessible?(presenter) end all_slugs = vg.page_names.filter_map do |name| LcpRuby.loader.page_definitions[name]&.slug end { presenter: presenter, label: presenter&.resolved_label || page.title, slug: page.slug, icon: presenter&.icon, navigation: vg., all_slugs: all_slugs } end.sort_by { |entry| entry[:navigation].is_a?(Hash) ? (entry[:navigation]["position"] || 99) : 99 } end |
#panel_items_for(item) ⇒ Object
Returns the resolved panel children for a ‘:group + render_panel:` item — `static_children + panel_provider_items`. Static children are already filtered by the walker (item.children is the post- `visible_menu_items` set). panel_provider_items are computed lazily and memoized per render pass.
363 364 365 366 367 368 369 370 371 372 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 363 def panel_items_for(item) static_part = item.children provider_part = if item.panel_provider_name.present? panel_provider_items_for(item) else [] end static_part + provider_part end |
#panel_provider_items_for(item) ⇒ Object
Lazy resolver for ‘panel_provider:` items. Memoizes the resolved children on `@panel_provider_cache` keyed by `item.object_id` so the trigger render and the panel render don’t double-call the provider class (typical pattern: render: partial calls counts / current-state methods that the panel_provider also reads — a provider author wraps the body in ‘Rails.cache.fetch` to make both safe; the helper-side cache adds dedup within a single render pass).
382 383 384 385 386 387 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 382 def panel_provider_items_for(item) return [] unless item.panel_provider_name.present? @panel_provider_cache ||= {} @panel_provider_cache[item.object_id] ||= resolve_panel_provider_items(item) end |
#permission_evaluator_for(presenter_def, user) ⇒ Object
Per-render PermissionEvaluator cache for ‘presenter:` menu items. The view context (one per render pass — request, mailer, or any explicit `render_to_string` call) gets a fresh `@permission_cache`, so two consecutive renders never share state. Cache key includes `is_impersonated` (LcpRuby::ImpersonatedUser is a SimpleDelegator whose `id` returns the real user’s id; without this flag an admin who starts impersonating a subordinate would still see admin menu items) and the resolved role list (so role changes within a single user mid-render — rare, but possible — invalidate correctly).
510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 510 def (presenter_def, user) @permission_cache ||= {} is_impersonated = user.is_a?(LcpRuby::ImpersonatedUser) cache_key = [ presenter_def.model, user&.id, is_impersonated, LcpRuby.user_roles(user).sort ] # Singular accessor handles _default fallback; the plural hash returns # nil for unconfigured models and crashes the evaluator. @permission_cache[cache_key] ||= LcpRuby::Authorization::PermissionEvaluator.new( LcpRuby.loader.(presenter_def.model), user, presenter_def.model ) end |
#presenter_description(presenter, view:) ⇒ Object
Resolves a presenter view description with i18n override path ‘lcp_ruby.presenters.<name>.<view>.description`. Returns nil when the presenter doesn’t define a description.
42 43 44 45 46 47 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 42 def presenter_description(presenter, view:) raw = presenter&.public_send("#{view}_config")&.dig("description") return nil if raw.blank? I18n.t("lcp_ruby.presenters.#{presenter.name}.#{view}.description", default: raw) end |
#render_custom_partial(partial:, locals:, context: "custom partial") ⇒ Object
49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 49 def render_custom_partial(partial:, locals:, context: "custom partial") render partial: partial, locals: locals rescue ActionView::MissingTemplate raise rescue => e raise unless Rails.env.production? Rails.logger.error( "[LcpRuby] Custom partial error in #{context}: #{e.class}: #{e.} " \ "(partial=#{partial})" ) render partial: "lcp_ruby/shared/custom_partial_error", locals: { partial_path: partial } end |
#render_menu_item(item, context: :panel) ⇒ Object
Renders a MenuItem in one of two contexts. The default ‘:panel` context wraps the item in `<li class=“lcp-panel-item”>` with `role=“menuitem”` on the inner wrapper — used by `render_panel:` partials to render their `items:` local. The `:bare` context emits the inner wrapper only (no `<li>`) — used by widgets that iterate menu items inside their own list semantics (autocomplete results in a search widget, suggestion list inside a custom dropdown).
‘:top` and `:sidebar` contexts are reserved for internal use —`_top.html.erb` / `_sidebar.html.erb` render via their own partials, not via this helper.
479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 479 def (item, context: :panel) case context when :panel render "lcp_ruby/navigation/panel_item", item: item when :bare if item. render item.render_partial_path, item: item, disabled: !item.meets_enabled?(LcpRuby::Current.user), bare: true else render "lcp_ruby/navigation/link_or_button", item: item, klass: "" end else raise ArgumentError, "render_menu_item: unknown context #{context.inspect} " \ "(supported: :panel, :bare)" end end |
#resolve_section_title(section, presenter_name = nil) ⇒ Object
Resolve section title via i18n with fallback to the YAML value. Lookup: lcp_ruby.presenters.<presenter>.sections.<section_key>
When the section declares ‘section_key:` explicitly (DSL `key:` keyword), use it verbatim — `section_key: “details”` → looks up `…sections.details`. Without an explicit key, fall back to the legacy behaviour: parameterize the literal title (`“List Details”` → `list_details`). The explicit form lets configurators decouple the i18n key from the human-readable label and keeps non-English literals out of the en locale.
785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 785 def resolve_section_title(section, presenter_name = nil) raw_title = section["section"] || section["title"] explicit_key = section["section_key"] return raw_title if presenter_name.blank? if explicit_key.present? I18n.t( "lcp_ruby.presenters.#{presenter_name}.sections.#{explicit_key}", default: raw_title || explicit_key.to_s.humanize ) elsif raw_title.present? section_key = raw_title.to_s.parameterize(separator: "_") I18n.t("lcp_ruby.presenters.#{presenter_name}.sections.#{section_key}", default: raw_title) end end |
#responsive_top_nav_data_attrs(menu_def) ⇒ Object
data-* attributes for the top <nav> element. ‘data-measuring` is the FOUC mask removed by the controller after first measurement (or by the 100ms safety timeout); for non-overflow_more modes the controller removes it on connect immediately.
727 728 729 730 731 732 733 734 735 736 737 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 727 def responsive_top_nav_data_attrs() policy = (&.responsive) || Metadata::ResponsivePolicy.new(nil) { controller: "lcp-responsive-top-nav", responsive_mode: policy., breakpoint: policy..to_s, more_label: I18n.t(policy., default: "More"), more_icon: policy., measuring: "" } end |
#sidebar_initial_rail_state(menu_def) ⇒ Object
Read the current rail state from the server-set cookie (with policy fallback). Server-side read for FOUC-free SSR — JS writes the cookie on user toggle.
707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 707 def () # Read via request.cookies (raw Rack hash) — avoids depending on # the controller's `cookies` jar being exposed to the view context. = if respond_to?(:request) && request request.["lcp_sidebar"].to_s else "" end return if == "expanded" || == "collapsed" # `auto` falls back to expanded on first paint; JS may toggle on connect. &.responsive&. == "collapsed" ? "collapsed" : "expanded" end |
#theme_switching_allowed? ⇒ Boolean
821 822 823 824 825 826 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 821 def theme_switching_allowed? theme_config = LcpRuby.configuration.theme return true unless theme_config.is_a?(Hash) theme_config.fetch(:allow_user_switch, true) end |
#top_menu_item_responsive_data_attrs(item, index:) ⇒ Object
Per-item data-* attributes for top_menu items. ‘index:` is the original 0-based position within the left zone; right-zone items never overflow and pass `index: nil`.
742 743 744 745 746 747 748 749 750 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 742 def (item, index:) { original_index: index&.to_s, priority: item.responsive_priority&.to_s, pin: item.pin, hide_below: item.hide_below&.to_s, show_below: item.show_below&.to_s }.compact end |
#visible_menu_items(items, depth: 0) ⇒ Object
Filter menu items by visibility (role + presenter access), AND expand ‘provider:` items via the menu_items registry. The walker has two destination branches:
1. `provider:` items — call the provider class once, replace
the YAML position with its returned hash(es).
2. `:group` items — recurse into static children. Drop the
group when filtered children are empty AND no
`render_panel:` is set (legacy compat: empty dropdowns
should not render). Groups with `render_panel:` always
survive — the panel partial decides what to render.
‘panel_provider:` items are NOT resolved here. They’re computed lazily at render time via ‘panel_provider_items_for(item)` (see C19) so the trigger renders even when the provider is slow.
Passes ‘self` as `helper:` so MenuItem#meets_visibility? can share the per-render @permission_cache (multiple siblings with the same presenter pay PermissionEvaluator construction once).
147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 147 def (items, depth: 0) return [] if items.nil? user = LcpRuby::Current.user request_context = items.flat_map do |item| next [] unless (item, helper: self) if item.provider_name.present? (item, user: user, request_context: request_context, depth: depth) elsif item.children.any? = (item.children, depth: depth + 1) # Drop a :group whose filtered children are empty AND that # has no render_panel: to fill the panel (preserves the # legacy `next if visible_children.empty?` behaviour). if .empty? && item.render_panel_partial.blank? [] else [ item.with(children: ) ] end elsif item.group? # children: [] — only valid when render_panel: is set # (validator enforces this). Always kept; the rendering # branch handles content via panel_provider or the custom # render_panel: partial. [ item ] else [ item ] end end end |
#visible_top_menu_items(menu_def) ⇒ Object
Wrapper around ‘visible_menu_items` for the TOP menu specifically, auto-injecting a synthesized `sidebar_toggle:` item when the responsive policy enables off-canvas mode and no explicit toggle is authored. `_sidebar.html.erb` continues to call `visible_menu_items` directly (no synthesis there). Synth runs after visibility filtering so the platform item never competes with configurator items hidden by Pundit.
699 700 701 702 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 699 def () items = (.) (items, ) end |