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

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_contextObject

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 build_menu_request_context
  if respond_to?(:request) && request
    { path: request.path, params: request.filtered_parameters }
  else
    { path: nil, params: {} }
  end
end

#current_density_defaultObject



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_defaultObject



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_defaultObject

— 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 expand_menu_provider(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.provider_options || {},
    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: visible_menu_items(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 inject_sidebar_toggle_if_needed(items, menu_def)
  policy = menu_def&.responsive
  return items unless policy
  return items unless menu_def&.has_sidebar_menu?
  return items unless policy.sidebar_below_breakpoint == "off_canvas"
  return items unless policy.sidebar_auto_inject_toggle?
  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.

Returns:

  • (Boolean)


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_tagObject



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
  (:title, [ content_for(:title).presence, app_title ].compact.join(" \u2014 "))
end


679
680
681
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 679

def menu_bottom_items(items)
  items.select(&:bottom?)
end

— Menu system helpers —

Returns:

  • (Boolean)


113
114
115
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 113

def menu_defined?
  LcpRuby.loader.menu_defined?
end


117
118
119
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 117

def menu_definition
  LcpRuby.loader.menu_definition
end

Check if a menu item is active for the current slug

Returns:

  • (Boolean)


529
530
531
532
533
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 529

def menu_item_active?(item, current_slug)
  return false if current_slug.blank?

  item.contains_slug?(current_slug, LcpRuby.loader)
end

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 menu_item_badge(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?

  render_menu_badge(item, data)
rescue => e
  raise unless Rails.env.production?
  LcpRuby.record_error(e, subsystem: "menu_badge", badge_provider: item.badge_provider)
  nil
end

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 menu_item_icon_html(item)
  icon = item.resolved_icon(LcpRuby.loader)
  return nil if icon.blank?
  (:i, "", "data-lucide" => icon, class: "lcp-menu-icon", "aria-hidden" => "true")
end

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.).

Returns:

  • (Boolean)


649
650
651
652
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 649

def menu_item_icon_only?(item)
  return false if item.render_partial.present?
  menu_item_label(item).blank?
end

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 menu_item_label(item)
  apply_path_template(item.resolved_label(LcpRuby.loader))
end

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 menu_item_link_aria_label(item)
  return nil if menu_item_label(item).present?
  apply_path_template(item.resolved_aria_label(LcpRuby.loader))
end

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 menu_item_path(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

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 menu_item_url(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

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).

Returns:

  • (Boolean)


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 menu_item_visible?(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

Returns “top”, “sidebar”, or “both”



122
123
124
125
126
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 122

def menu_layout
  return "top" unless menu_defined?

  menu_definition.layout_mode
end

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 menu_left_top_items(items)
  items.reject(&:right?)
end

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 menu_main_items(items)
  items.reject(&:bottom?)
end


688
689
690
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 688

def menu_right_top_items(items)
  items.select(&:right?)
end


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.navigation_config,
      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 permission_evaluator_for(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.permission_definition(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.message} " \
    "(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 render_menu_item(item, context: :panel)
  case context
  when :panel
    render "lcp_ruby/navigation/panel_item", item: item
  when :bare
    if item.widget?
      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(menu_def)
  policy = (menu_def&.responsive) || Metadata::ResponsivePolicy.new(nil)
  {
    controller: "lcp-responsive-top-nav",
    responsive_mode: policy.top_menu_mode,
    breakpoint: policy.top_menu_breakpoint.to_s,
    more_label: I18n.t(policy.top_menu_more_label_key, default: "More"),
    more_icon: policy.top_menu_more_icon,
    measuring: ""
  }
end

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 sidebar_initial_rail_state(menu_def)
  # Read via request.cookies (raw Rack hash) — avoids depending on
  # the controller's `cookies` jar being exposed to the view context.
  cookie_value =
    if respond_to?(:request) && request
      request.cookies["lcp_sidebar"].to_s
    else
      ""
    end

  return cookie_value if cookie_value == "expanded" || cookie_value == "collapsed"

  # `auto` falls back to expanded on first paint; JS may toggle on connect.
  menu_def&.responsive&.sidebar_initial == "collapsed" ? "collapsed" : "expanded"
end

#theme_switching_allowed?Boolean

Returns:

  • (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 top_menu_item_responsive_data_attrs(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 visible_menu_items(items, depth: 0)
  return [] if items.nil?

  user = LcpRuby::Current.user
  request_context = build_menu_request_context

  items.flat_map do |item|
    next [] unless menu_item_visible?(item, helper: self)

    if item.provider_name.present?
      expand_menu_provider(item, user: user, request_context: request_context, depth: depth)
    elsif item.children.any?
      expanded = visible_menu_items(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 expanded.empty? && item.render_panel_partial.blank?
        []
      else
        [ item.with(children: expanded) ]
      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 visible_top_menu_items(menu_def)
  items = visible_menu_items(menu_def.top_menu)
  inject_sidebar_toggle_if_needed(items, menu_def)
end