Module: Hecks::Forms::FieldRenderer

Defined in:
lib/hecks/forms/field_renderer.rb

Overview

A Field (field_shape.rb) -> the <div class="field">...</div> or <fieldset>...</fieldset> markup for it. One renderer, called recursively for a group's children, shared by CommandFormRenderer (POST) and QueryFormRenderer (GET) — the same attribute shape asks for the same input either way; only the surrounding <form>'s method differs.

Class Method Summary collapse

Class Method Details

.aria(field) ⇒ Object



138
139
140
# File 'lib/hecks/forms/field_renderer.rb', line 138

def self.aria(field)
  Tag.attrs(required: field.required?, aria_describedby: field.help ? "#{dom_id(field.path)}-help" : nil)
end

.aria_attrs(field) ⇒ Object



142
143
144
# File 'lib/hecks/forms/field_renderer.rb', line 142

def self.aria_attrs(field)
  { required: field.required?, aria_describedby: field.help ? "#{dom_id(field.path)}-help" : nil }
end

.checkbox(field, value) ⇒ Object



91
92
93
94
95
96
97
98
99
100
# File 'lib/hecks/forms/field_renderer.rb', line 91

def self.checkbox(field, value)
  checked = value.nil? ? field.default == true : [true, "true", "on", "1"].include?(value)
  <<~HTML
    <div class="checkbox-row">
      <input type="hidden" name="#{Escape.attr(field.path)}" value="0">
      #{Tag.void('input', id: dom_id(field.path), name: field.path, type: 'checkbox', value: '1', checked: checked)}
      <label for="#{dom_id(field.path)}">#{Escape.html(field.label)}</label>
    </div>
  HTML
end

.dig(values, path) ⇒ Object

values is a nested hash; path a dotted string using the SAME segment spelling the hash keys are built from (leaf_key in params.rb) — symbols one level down from a group, strings at the flat top when a sticky POST re-render hands raw params back untouched.



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
# File 'lib/hecks/forms/field_renderer.rb', line 150

def self.dig(values, path)
  return nil unless values.is_a?(Hash)

  # A sticky re-render after a refused submission hands back the RAW
  # flat params (`{"amount.cents"=>"1050"}` — the same shape the form
  # posted, string values and all, dotted key intact) ; a prefill from
  # an existing record's own state hands back a NESTED hash instead
  # (`{amount: {cents: 1050}}`). Flat wins when both would answer,
  # since only the raw form is ever what the caller actually typed.
  # `key?` decides which spelling answers, at every step below —
  # never `||`, which would treat a genuinely-held `false` the
  # same as an absent key and fall through to `nil`.
  str = path.to_s
  return values[str] if values.key?(str)

  sym = path.to_sym
  return values[sym] if values.key?(sym)

  str.split(".").reduce(values) do |acc, segment|
    break nil unless acc.is_a?(Hash)

    seg_sym = segment.to_sym
    acc.key?(seg_sym) ? acc[seg_sym] : acc[segment]
  end
end

.dom_id(path) ⇒ Object



134
# File 'lib/hecks/forms/field_renderer.rb', line 134

def self.dom_id(path) = "f-#{path.to_s.tr('.', '-')}"

.group(field, values, errors, reference_options, tag:, css: nil) ⇒ Object



28
29
30
31
32
33
34
35
36
# File 'lib/hecks/forms/field_renderer.rb', line 28

def self.group(field, values, errors, reference_options, tag:, css: nil)
  inner = field.children.map { |child| render(child, values: values, errors: errors, reference_options: reference_options) }
  <<~HTML
    <#{tag}#{css ? %( class="#{css}") : ""}>
      <legend>#{Escape.html(field.label)}#{required_mark(field)}</legend>
      #{inner.join("\n")}
    </#{tag}>
  HTML
end

.input(field, value) ⇒ Object



76
77
78
79
80
81
82
83
84
85
# File 'lib/hecks/forms/field_renderer.rb', line 76

def self.input(field, value)
  money = field.kind == :number && field.help.to_s.include?("cents")
  tag = Tag.void("input", id: dom_id(field.path), name: field.path, type: field.html_type,
                  value: value || field.default, step: field.step, pattern: leaf_pattern(field),
                  placeholder: field.default, **aria_attrs(field),
                  **(money ? { data_money_cents: money_preview_target(field) } : {}))
  return tag unless money

  %(#{tag} <span id="#{dom_id(field.path)}-preview" class="mono help" aria-live="polite"></span>)
end

.leaf(field, values, errors, reference_options) ⇒ Object



51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/hecks/forms/field_renderer.rb', line 51

def self.leaf(field, values, errors, reference_options)
  value = dig(values, field.path)
  error = errors && errors[field.path]
  body = case field.kind
         when :boolean  then checkbox(field, value)
         when :radio    then radio_group(field, value)
         when :select   then select(field, value)
         when :textarea then textarea(field, value)
         when :reference then reference_select(field, value, reference_options[field.path])
         else input(field, value)
         end
  wrap(field, body, error)
end

.leaf_pattern(field) ⇒ Object



135
# File 'lib/hecks/forms/field_renderer.rb', line 135

def self.leaf_pattern(field) = field.kind == :reference ? nil : field.pattern

.list(field, values, _errors) ⇒ Object



38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/hecks/forms/field_renderer.rb', line 38

def self.list(field, values, _errors)
  item = field.children.first
  current = Array(dig(values, field.path)).join("\n")
  <<~HTML
    <div class="field">
      <label for="#{dom_id(field.path)}">#{Escape.html(field.label)}#{required_mark(field)}</label>
      <textarea id="#{dom_id(field.path)}" name="#{Escape.attr(field.path)}"
        #{aria(field)} placeholder="one #{Escape.attr(item.label.downcase)} per line">#{Escape.html(current)}</textarea>
      <span class="help" id="#{dom_id(field.path)}-help">One #{Escape.html(item.label.downcase)} per line#{item.leaf? ? '' : ' — each line is one JSON object'}.</span>
    </div>
  HTML
end

.money_preview_target(field) ⇒ Object



136
# File 'lib/hecks/forms/field_renderer.rb', line 136

def self.money_preview_target(field) = "##{dom_id(field.path)}-preview"

.radio_group(field, value) ⇒ Object



102
103
104
105
106
107
108
109
110
111
112
# File 'lib/hecks/forms/field_renderer.rb', line 102

def self.radio_group(field, value)
  selected = value || field.default
  options = field.options.map do |option_value, option_label|
    checked = option_value.to_s == selected.to_s
    id = "#{dom_id(field.path)}-#{Naming.snake(option_value)}"
    <<~HTML
      <label>#{Tag.void('input', id: id, type: 'radio', name: field.path, value: option_value, checked: checked)} #{Escape.html(option_label)}</label>
    HTML
  end
  %(<div class="radio-group" role="radiogroup">#{options.join}</div>)
end

.reference_select(field, value, options) ⇒ Object



123
124
125
126
127
128
129
130
131
# File 'lib/hecks/forms/field_renderer.rb', line 123

def self.reference_select(field, value, options)
  return input(field.tap { |f| f.html_type = "text" }, value) unless options && !options.empty?

  rendered = options.map do |id, label|
    %(<option value="#{Escape.attr(id)}"#{id.to_s == value.to_s ? ' selected' : ''}>#{Escape.html(label)}</option>)
  end
  blank = field.optional? ? %(<option value="">—</option>) : %(<option value="" disabled#{value ? '' : ' selected'}>choose one…</option>)
  %(<select id="#{dom_id(field.path)}" name="#{Escape.attr(field.path)}" #{aria(field)}>#{blank}#{rendered.join}</select>)
end

.render(field, values: {}, errors: nil, reference_options: {}) ⇒ Object

values is the nested hash a sticky re-render (a rejected command, a submitted query) carries back — read with the SAME dotted path a Field already carries, via dig. reference_options maps a :reference field's own path to [[id, label], ...], built by the caller (it needs a repository; this module stays pure markup).



19
20
21
22
23
24
25
26
# File 'lib/hecks/forms/field_renderer.rb', line 19

def self.render(field, values: {}, errors: nil, reference_options: {})
  case field.kind
  when :group  then group(field, values, errors, reference_options, tag: "fieldset")
  when :money  then group(field, values, errors, reference_options, tag: "fieldset", css: "money")
  when :list   then list(field, values, errors)
  else              leaf(field, values, errors, reference_options)
  end
end

.required_mark(field) ⇒ Object



133
# File 'lib/hecks/forms/field_renderer.rb', line 133

def self.required_mark(field) = field.required? ? %(<span class="required-mark" title="required">*</span>) : ""

.select(field, value) ⇒ Object



114
115
116
117
118
119
120
121
# File 'lib/hecks/forms/field_renderer.rb', line 114

def self.select(field, value)
  selected = value || field.default
  options = field.options.map do |option_value, option_label|
    %(<option value="#{Escape.attr(option_value)}"#{option_value.to_s == selected.to_s ? ' selected' : ''}>#{Escape.html(option_label)}</option>)
  end
  blank = field.optional? ? %(<option value="">—</option>) : ""
  %(<select id="#{dom_id(field.path)}" name="#{Escape.attr(field.path)}" #{aria(field)}>#{blank}#{options.join}</select>)
end

.textarea(field, value) ⇒ Object



87
88
89
# File 'lib/hecks/forms/field_renderer.rb', line 87

def self.textarea(field, value)
  %(<textarea id="#{dom_id(field.path)}" name="#{Escape.attr(field.path)}" #{aria(field)}>#{Escape.html(value)}</textarea>)
end

.wrap(field, body, error) ⇒ Object



65
66
67
68
69
70
71
72
73
74
# File 'lib/hecks/forms/field_renderer.rb', line 65

def self.wrap(field, body, error)
  <<~HTML
    <div class="field#{error ? ' has-error' : ''}">
      #{field.kind == :boolean ? '' : %(<label for="#{dom_id(field.path)}">#{Escape.html(field.label)}#{required_mark(field)}</label>)}
      #{body}
      #{field.help ? %(<span class="help" id="#{dom_id(field.path)}-help">#{Escape.html(field.help)}</span>) : ''}
      #{error ? %(<span class="help" role="alert">#{Escape.html(error)}</span>) : ''}
    </div>
  HTML
end