Module: LcpRuby::Display::CardHelper

Defined in:
app/helpers/lcp_ruby/display/card_helper.rb

Overview

Shared zone helpers for card-based layouts (tiles, kanban, future).

Each helper resolves one zone end-to-end: field resolution, permission check, renderer invocation, empty-value placeholder, a11y attributes, HTML escaping. Helpers return either an HTML-safe String (non-blank) or “” (zone suppressed — permission, blank value, or zone not configured).

Per-layout body partials (‘_tile_card_body.html.erb`, `_kanban_card_body.html.erb`) compose these helpers in the order and wrapping HTML each layout needs. Zone resolution is shared; zone arrangement stays with each layout.

Constant Summary collapse

ACCENT_TOKENS =
{
  "success" => "var(--lcp-success)",
  "danger"  => "var(--lcp-danger)",
  "warning" => "var(--lcp-warning)",
  "info"    => "var(--lcp-info, var(--lcp-text-muted))",
  "muted"   => "var(--lcp-text-muted)",
  "primary" => "var(--lcp-primary, var(--lcp-link))"
}.freeze

Instance Method Summary collapse

Instance Method Details

#card_accent_style(record, card_config) ⇒ Object

Returns: inline style string “–lcp-card-accent: var(–lcp-<token>);” or “”. Value matched against whitelist; raw field values never interpolated.



193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 193

def card_accent_style(record, card_config)
  field = card_config["color_field"]
  return "" if field.blank?
  return "" unless @column_set.readable_named_slot?(field)

  raw = @field_resolver.resolve(record, field, fk_map: @fk_map)
  return "" if raw.blank?

  token =
    card_config.dig("color_map", raw.to_s) ||
    workflow_state_color(record, field) ||
    raw.to_s

  css_var = ACCENT_TOKENS[token.to_s]
  unless css_var
    Rails.logger&.warn(
      "[LcpRuby] card.color_field='#{field}' value '#{raw}' is not a whitelisted palette token; no accent emitted"
    )
    return ""
  end

  "--lcp-card-accent: #{css_var};"
end

#card_actions(record, action_set, card_config) ⇒ Object

Returns: <div class=“lcp-card-actions”>…</div> or “”. Respects actions: dropdown|inline|none.



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 167

def card_actions(record, action_set, card_config)
  mode = card_config["actions"] || "dropdown"
  return "" if mode == "none"

  actions = action_set.single_actions(record)
  return "" if actions.empty?

  body =
    if mode == "dropdown"
      (:div, class: "lcp-actions-dropdown") do
        concat((:button, "".html_safe, type: "button", class: "btn lcp-dropdown-toggle"))
        concat(
          (:div, class: "lcp-dropdown-menu") do
            safe_join(actions.map { |a| render(partial: "lcp_ruby/resources/action_button", locals: { action: a, record: record, css_class: "lcp-dropdown-item" }) })
          end
        )
      end
    else
      safe_join(actions.map { |a| render(partial: "lcp_ruby/resources/action_button", locals: { action: a, record: record, css_class: "lcp-tile-action-btn" }) })
    end

  (:div, body, class: "lcp-card-actions")
end

#card_aria_attributes(record, card_config, presenter) ⇒ Object

Returns: Hash of ARIA attributes for the card root element. Always includes role: “article” plus exactly one of aria-labelledby (when title is readable + non-blank) or aria-label.



219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 219

def card_aria_attributes(record, card_config, presenter)
  attrs = { role: "article" }
  title_field = card_config["title_field"]
  title_value = title_field.present? ? resolve_title_value(record, title_field) : nil

  if title_value.present?
    attrs["aria-labelledby"] = card_title_id(record, presenter.name)
  elsif record.respond_to?(:to_label) && record.to_label.present?
    attrs["aria-label"] = record.to_label
  else
    attrs["aria-label"] = I18n.t("lcp_ruby.card.record_fallback", id: record.id, default: "Record ##{record.id}")
  end

  attrs
end

#card_avatar(record, card_config) ⇒ Object

Returns: <div class=“lcp-card-avatar”><img …/></div> or “”. Avatar alt defaults to “” (decorative) unless avatar_alt_field is set.



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 114

def card_avatar(record, card_config)
  field = card_config["avatar_field"]
  return "" if field.blank?
  return "" unless @column_set.readable_named_slot?(field)

  value = @field_resolver.resolve(record, field, fk_map: @fk_map)
  return "" if value.blank?

  unless image_renderable?(value)
    if Rails.logger
      Rails.logger.warn(
        "[LcpRuby] card.avatar_field='#{field}' value is not a renderable image; zone omitted"
      )
    end
    return ""
  end

  alt = card_avatar_alt(record, card_config) || ""
  (:div, image_tag(value, alt: alt), class: "lcp-card-avatar")
end

#card_description(record, card_config) ⇒ Object

Returns: <div class=“lcp-card-description”>…</div> or “” when suppressed.



72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 72

def card_description(record, card_config)
  field = card_config["description_field"]
  return "" if field.blank?
  return "" unless @column_set.readable_named_slot?(field)

  value = @field_resolver.resolve(record, field, fk_map: @fk_map)
  return "" if value.blank?

  max_lines = card_config["description_max_lines"]
  style = ("--lcp-card-desc-lines: #{max_lines.to_i}" if max_lines)

  (:div, value, class: "lcp-card-description", style: style)
end

#card_field_list(record, card_config, column_set:) ⇒ Object

Returns: <div class=“lcp-card-fields”>…</div> with 2-column label/value grid, or “” when no fields visible after permission filtering.



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
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 137

def card_field_list(record, card_config, column_set:)
  fields = column_set.visible_card_fields(card_config)
  return "" if fields.empty?

  items = fields.map do |fc|
    value = @field_resolver.resolve(record, fc["field"], fk_map: @fk_map)
    field_def = card_field_def(fc["field"])
    label = field_label_for(fc, field_def: field_def, model_name: current_model_name)

    rendered_value =
      if fc["partial"].present?
        render_custom_partial(
          partial: fc["partial"],
          locals: { value: value, record: record, options: fc["options"] || {} },
          context: "card.fields[].partial '#{fc['partial']}' for record_id=#{record.id}"
        )
      elsif fc["renderer"].present?
        render_display_value(value, fc["renderer"], fc["options"] || {}, field_def, record: record)
      else
        empty_value_placeholder(format_enum_display(value, field_def, current_model_name), current_presenter)
      end

    (:div, label, class: "lcp-card-field-label") +
      (:div, rendered_value, class: "lcp-card-field-value")
  end

  (:div, safe_join(items), class: "lcp-card-fields")
end

#card_image(record, card_config, max_height: nil) ⇒ Object

Returns: <div class=“lcp-card-image”><img …/></div> or “”. Alt-text fallback chain: image_alt_field → title_field → “”.



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 88

def card_image(record, card_config, max_height: nil)
  field = card_config["image_field"]
  return "" if field.blank?
  return "" unless @column_set.readable_named_slot?(field)

  value = @field_resolver.resolve(record, field, fk_map: @fk_map)
  return "" if value.blank?

  unless image_renderable?(value)
    if Rails.logger
      Rails.logger.warn(
        "[LcpRuby] card.image_field='#{field}' value is not a renderable image " \
        "(not Active Storage attached and not http(s) URL); zone omitted"
      )
    end
    return ""
  end

  alt = card_image_alt(record, card_config) || ""
  style = ("max-height: #{max_height}" if max_height)

  (:div, image_tag(value, alt: alt), class: "lcp-card-image", style: style)
end

#card_subtitle(record, card_config) ⇒ Object

Returns: rendered subtitle div or “” when not configured / unauthorized / blank.



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 52

def card_subtitle(record, card_config)
  field = card_config["subtitle_field"]
  return "" if field.blank?
  return "" unless @column_set.readable_named_slot?(field)

  value = @field_resolver.resolve(record, field, fk_map: @fk_map)
  field_def = card_field_def(field)

  rendered =
    if (renderer_key = card_config["subtitle_renderer"]).present?
      render_display_value(value, renderer_key, card_config["subtitle_options"] || {}, field_def, record: record)
    else
      # Parity with table column rendering — humanize enum values before placeholder fallback.
      empty_value_placeholder(format_enum_display(value, field_def, current_model_name), current_presenter)
    end

  (:div, rendered, class: "lcp-card-subtitle")
end

#card_title(record, card_config, row_click: nil, title_id: nil) ⇒ Object

Returns: <a> link (when row_click configured + title readable) or <span> (plain) or empty_value_placeholder (unauthorized/blank). Always HTML-safe.



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 26

def card_title(record, card_config, row_click: nil, title_id: nil)
  field = card_config["title_field"]
  return "" if field.blank?

  value = resolve_title_value(record, field)
  rendered = empty_value_placeholder(value, current_presenter)

  # Blank/unauthorized title is the placeholder "—" — never a nav target.
  return rendered if value.blank?

  case row_click
  when "show"
    link_to(rendered, resource_path(record), data: { turbo_frame: "_top" })
  when Hash
    if row_click["mode"] == "filter" && record.respond_to?(:group_values)
      url = grouped_row_click_url(record, current_presenter)
      url ? link_to(rendered, url, data: { turbo_frame: "_top" }) : rendered
    else
      rendered
    end
  else
    rendered
  end
end

#card_title_id(record, presenter_name) ⇒ Object

Deterministic DOM id for the title element (used by aria-labelledby). Keyed by presenter name (not model) so multiple presenters of the same model on one page (e.g., a composite dashboard) produce unique ids.



238
239
240
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 238

def card_title_id(record, presenter_name)
  "card-#{presenter_name}-#{record.id}-title"
end