Module: Hecks::Forms::FieldShape

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

Overview

Attribute -> Field. The one mapping every renderer in this directory reads instead of re-deriving its own — see docs/command-form-and-query-form-bluebook.md's survey of the gap this closes (the prior generic-console attempt fell back to type="text" for anything that wasn't a number or an enum; a pattern naming an email shape, or a closed set another aggregate declares, went unread).

Constant Summary collapse

PRIMITIVES =
Bluebook::Attribute::PRIMITIVES
EMAIL_HINT =
/email/i
URL_HINT =
/\b(url|uri|website|link)\b/i
TEL_HINT =
/phone|\btel(ephone)?\b/i
TEXTAREA_HINT =
/\b(text|body|note|notes|description|message|comment)\b/i

Class Method Summary collapse

Class Method Details

.admitted_field(attribute, aggregate, common) ⇒ Object

admits: names a closed set declared ELSEWHERE ("Account:: LedgerDirection") that the value must belong to — see Runtime::Value::Admission#admitted_members, which this mirrors exactly (same split, same chapter walk, same discriminant rule) so a rendered <select> never offers a member the runtime would then refuse.



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/hecks/forms/field_shape.rb', line 104

def self.admitted_field(attribute, aggregate, common)
  set_aggregate_name, set_name = attribute.admits.to_s.split("::", 2)
  chapter = aggregate.hecks_owner
  set = set_name && chapter&.aggregate(set_aggregate_name)&.value_object(set_name)
  return primitive_field(attribute, common) unless set # undeclared — refuse-at-dispatch stays the backstop

  options = select_or_radio(common, closed_set_options(set))
  # The attribute's OWN type still has to be built the shape coercion
  # expects (`Value::Coercion#fields_for` refuses anything that
  # isn't a Hash or a Value for a value-object-typed attribute) — a
  # plain String attribute stays a bare scalar, but a value object
  # like `MovementDirection { value }` still needs the ".value" hop
  # even though the SET it's checked against (`admits:`) is declared
  # somewhere else entirely. Same unwrap `value_object_field` does,
  # kept separate because an admitted set changes the OPTIONS, not
  # which field the hop lands on.
  own_shape = own_value_object(attribute, aggregate)
  inner = own_shape && ValueObjectShape.sole_attribute(own_shape)
  return options unless inner

  options.path = "#{common[:path]}.#{inner.name}"
  options
end

.closed_set_field(shape, common) ⇒ Object

A one_of shape (AccountKind{name}, LedgerDirection{value}) is ALWAYS single-attribute in this language — the discriminant IS the whole value object — so the field's own path always gains that one hop; nothing to branch on the way admitted_field has to (a set named by admits: may sit on a multi-field value object it doesn't itself define the members of).



189
190
191
192
# File 'lib/hecks/forms/field_shape.rb', line 189

def self.closed_set_field(shape, common)
  discriminant = shape.attributes.first.name
  select_or_radio(common.merge(path: "#{common[:path]}.#{discriminant}"), closed_set_options(shape))
end

.closed_set_options(value_object) ⇒ Object



178
179
180
181
# File 'lib/hecks/forms/field_shape.rb', line 178

def self.closed_set_options(value_object)
  discriminant = value_object.attributes.first.name
  value_object.members.map { |member| member[discriminant].to_s }
end

.cross_aggregate_value_object(aggregate, type_name) ⇒ Object



154
155
156
157
158
159
160
# File 'lib/hecks/forms/field_shape.rb', line 154

def self.cross_aggregate_value_object(aggregate, type_name)
  aggregate.hecks_owner&.aggregates&.each do |sibling|
    found = sibling.value_object(type_name)
    return found if found
  end
  nil
end

.group_field(shape, aggregate, common) ⇒ Object



162
163
164
165
# File 'lib/hecks/forms/field_shape.rb', line 162

def self.group_field(shape, aggregate, common)
  children = shape.attributes.map { |inner| resolve(inner, aggregate: aggregate, path: "#{common[:path]}.#{inner.name}") }
  Field.new(path: common[:path], label: common[:label], kind: :group, optional: common[:optional], children: children)
end

.list_field(attribute, aggregate, path) ⇒ Object



79
80
81
82
83
84
85
86
87
88
89
# File 'lib/hecks/forms/field_shape.rb', line 79

def self.list_field(attribute, aggregate, path)
  # The scalar shape one element of this list would take, so a
  # renderer can say what belongs on each line without a second
  # mapping table. `list:` is the only thing that differs.
  scalar = Bluebook::Attribute.new(
    name: attribute.name, type: attribute.type, list: false,
    default: nil, optional: true, pattern: attribute.pattern, admits: attribute.admits
  )
  Field.new(path: path, label: Humanize.label(path), kind: :list, optional: attribute.optional?,
            children: [resolve(scalar, aggregate: aggregate, path: path)])
end

.money_field(shape, common) ⇒ Object



167
168
169
170
171
172
173
174
175
176
# File 'lib/hecks/forms/field_shape.rb', line 167

def self.money_field(shape, common)
  cents = Field.new(path: "#{common[:path]}.cents", label: "Amount (cents)", kind: :number,
                    html_type: "number", step: "1", optional: common[:optional],
                    default: shape.attribute(:cents)&.default, help: "Whole cents — 1050 is $10.50.")
  currency = Field.new(path: "#{common[:path]}.currency", label: "Currency", kind: :text, html_type: "text",
                       optional: true, default: shape.attribute(:currency)&.default || "USD",
                       help: "Three-letter code.")
  Field.new(path: common[:path], label: common[:label], kind: :money, optional: common[:optional],
            children: [cents, currency])
end

.own_value_object(attribute, aggregate) ⇒ Object



128
129
130
# File 'lib/hecks/forms/field_shape.rb', line 128

def self.own_value_object(attribute, aggregate)
  aggregate.value_object(attribute.type) || cross_aggregate_value_object(aggregate, attribute.type)
end

.primitive_field(attribute, common) ⇒ Object



199
200
201
202
203
204
205
206
207
208
# File 'lib/hecks/forms/field_shape.rb', line 199

def self.primitive_field(attribute, common)
  case attribute.type.to_s
  when "Integer" then Field.new(**common, kind: :number, html_type: "number", step: "1")
  when "Float"   then Field.new(**common, kind: :number, html_type: "number", step: "any")
  when "TrueClass", "FalseClass"
    Field.new(**common, kind: :boolean, html_type: "checkbox")
  else
    text_field(attribute, common)
  end
end

.reference_field(attribute, common) ⇒ Object



91
92
93
94
95
96
# File 'lib/hecks/forms/field_shape.rb', line 91

def self.reference_field(attribute, common)
  target = attribute.type.resolve
  Field.new(**common, kind: :reference, html_type: "text", target_aggregate: target,
                      help: target ? "References an existing #{target.hecks_name} by id." :
                            "References an aggregate in another domain — enter its id.")
end

.resolve(attribute, aggregate:, path: attribute.name.to_s) ⇒ Object

aggregate: is the Aggregate that OWNS this attribute (a command's, a query's, or — recursively — a value object's) — needed to resolve reference_to, admits:, and a same-chapter value object by name. path: defaults to the attribute's own name; a caller resolving a nested value object's attribute passes the dotted path so far.



66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/hecks/forms/field_shape.rb', line 66

def self.resolve(attribute, aggregate:, path: attribute.name.to_s)
  return list_field(attribute, aggregate, path) if attribute.list?

  common = { path: path, label: Humanize.label(path), default: attribute.default,
             optional: attribute.optional?, pattern: attribute.pattern }

  return reference_field(attribute, common) if attribute.reference?
  return admitted_field(attribute, aggregate, common) if attribute.admits
  return value_object_field(attribute, aggregate, common) unless PRIMITIVES.include?(attribute.type)

  primitive_field(attribute, common)
end

.select_or_radio(common, options) ⇒ Object



194
195
196
197
# File 'lib/hecks/forms/field_shape.rb', line 194

def self.select_or_radio(common, options)
  kind = options.size <= 4 ? :radio : :select
  Field.new(**common, kind: kind, html_type: "text", options: options.map { |value| [value, value] })
end

.text_field(attribute, common) ⇒ Object



215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/hecks/forms/field_shape.rb', line 215

def self.text_field(attribute, common)
  name = attribute.name.to_s
  pattern = attribute.pattern.to_s
  html_type = if pattern.include?("@") || name.match?(EMAIL_HINT)
                "email"
              elsif pattern.match?(/https?/i) || name.match?(URL_HINT)
                "url"
              elsif name.match?(TEL_HINT)
                "tel"
              else
                "text"
              end
  kind = html_type == "text" && name.match?(TEXTAREA_HINT) ? :textarea : :text
  Field.new(**common, kind: kind, html_type: html_type)
end

.value_object_field(attribute, aggregate, common) ⇒ Object



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/hecks/forms/field_shape.rb', line 132

def self.value_object_field(attribute, aggregate, common)
  shape = own_value_object(attribute, aggregate)
  return primitive_field(attribute, common) unless shape

  return closed_set_field(shape, common) if shape.closed_set?
  return money_field(shape, common) if ValueObjectShape.money?(shape)

  # A single-attribute value object (EmailAddress{address}, CustomerNumber{value})
  # is a NAME for a scalar, not a genuine group — unwrap it so the form
  # asks for one thing ("Email address") instead of a one-item fieldset,
  # and so the inner attribute's OWN pattern (the real email regex,
  # declared on `address`, not on the outer `email` attribute) drives
  # the input type. [[feedback_name_the_scalar_field]] says the same
  # thing about Ruby call sites; a form asks the identical question.
  if (inner = ValueObjectShape.sole_attribute(shape))
    return resolve(inner, aggregate: aggregate, path: "#{common[:path]}.#{inner.name}")
           .tap { |field| field.optional = common[:optional] || field.optional }
  end

  group_field(shape, aggregate, common)
end