Module: LcpRuby::LayoutHelper
- Defined in:
- app/helpers/lcp_ruby/layout_helper.rb
Constant Summary collapse
- PROVIDER_RETURN_ALLOWED_KEYS =
MenuItem-shaped keys allowed in provider returns. ‘provider:` and `panel_provider:` are stripped (chains are banned — each YAML position resolves to one source, never a chain).
%w[ type 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 —.
-
#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_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.
568 569 570 571 572 573 574 575 576 577 578 579 580 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 568 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.
309 310 311 312 313 314 315 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 309 def if respond_to?(:request) && request { path: request.path, params: request.filtered_parameters } else { path: nil, params: {} } end end |
#current_density_default ⇒ Object
772 773 774 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 772 def current_density_default _preference_default("density", %w[comfortable compact], :density, :default) end |
#current_radius_default ⇒ Object
776 777 778 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 776 def current_radius_default _preference_default("border_radius", %w[rounded sharp], :border_radius, :default) end |
#current_theme_default ⇒ Object
— Theme helpers —
768 769 770 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 768 def current_theme_default _preference_default("theme", %w[auto light dark], :default) 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.
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 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 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 221 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
12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 12 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. Drops ‘provider:` and `panel_provider:` (chains are banned). Drops anything else with the canonical idiom — raise in dev/test, record_error in production.
283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 283 def filter_provider_keys(hash, provider_name) stringified = 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
52 53 54 55 56 57 58 59 60 61 62 63 64 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 52 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).
715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 715 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_page_title_tag ⇒ Object
760 761 762 763 764 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 760 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
638 639 640 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 638 def (items) items.select(&:bottom?) end |
#menu_defined? ⇒ Boolean
— Menu system helpers —
101 102 103 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 101 def LcpRuby.loader. end |
#menu_definition ⇒ Object
105 106 107 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 105 def LcpRuby.loader. end |
#menu_item_active?(item, current_slug) ⇒ Boolean
Check if a menu item is active for the current slug
488 489 490 491 492 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 488 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
614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 614 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.
590 591 592 593 594 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 590 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.).
608 609 610 611 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 608 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).
550 551 552 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 550 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).
559 560 561 562 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 559 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).
498 499 500 501 502 503 504 505 506 507 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 498 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).
521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 521 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).
173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 173 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”
110 111 112 113 114 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 110 def return "top" unless .layout_mode end |
#menu_left_top_items(items) ⇒ Object
Split top_menu items into left (default) and right-aligned groups
643 644 645 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 643 def (items) items.reject(&:right?) end |
#menu_main_items(items) ⇒ Object
Split items into main (non-bottom) and bottom items for sidebar
634 635 636 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 634 def (items) items.reject(&:bottom?) end |
#menu_right_top_items(items) ⇒ Object
647 648 649 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 647 def (items) items.select(&:right?) end |
#navigable_entries ⇒ Object
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 66 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.
322 323 324 325 326 327 328 329 330 331 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 322 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).
341 342 343 344 345 346 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 341 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).
469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 469 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.
30 31 32 33 34 35 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 30 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
37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 37 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.
438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 438 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.
744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 744 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.
686 687 688 689 690 691 692 693 694 695 696 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 686 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.
666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 666 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
780 781 782 783 784 785 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 780 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`.
701 702 703 704 705 706 707 708 709 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 701 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).
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 135 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.
658 659 660 661 |
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 658 def () items = (.) (items, ) end |