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.



208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 208

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.



182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 182

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.



234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 234

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.



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 128

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.



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 80

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?

  # Humanize when description_field points to an enum (uncommon but
  # supported). Non-enum fields pass through unchanged; has_many Arrays
  # render comma-joined via the collection renderer.
  fd, fd_model = card_field_meta(field)
  display = card_display_value(value, fd, fd_model || current_model_name)

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

  (:div, display, 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.



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

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, fd_model = card_field_meta(fc["field"])
    fd_model ||= current_model_name
    label = field_label_for(fc, field_def: field_def, model_name: fd_model)

    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, model_name: fd_model)
      else
        empty_value_placeholder(card_display_value(value, field_def, fd_model), 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 → “”.



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 102

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.



58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 58

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, fd_model = card_field_meta(field)
  fd_model ||= current_model_name

  rendered =
    if (renderer_key = card_config["subtitle_renderer"]).present?
      render_display_value(value, renderer_key, card_config["subtitle_options"] || {}, field_def, record: record, model_name: fd_model)
    else
      # Parity with table column rendering — humanize enum values
      # (scalar or has_many Array) before placeholder fallback.
      empty_value_placeholder(card_display_value(value, field_def, fd_model), 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
50
51
52
53
54
55
# 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)
  # Humanize when title_field points to an enum (issue #18 — resolver
  # now returns raw enum keys). Non-enum fields pass through unchanged;
  # a has_many dot-path Array renders comma-joined via the collection
  # renderer (card_display_value), same as subtitle/description — a bare
  # format_enum_display would print the Ruby array literal.
  fd, model_name = card_field_meta(field)
  rendered = empty_value_placeholder(card_display_value(value, fd, model_name || current_model_name), 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.



253
254
255
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 253

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