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
-
.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. -
.closed_set_field(shape, common) ⇒ Object
A
one_ofshape (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 wayadmitted_fieldhas to (a set named byadmits:may sit on a multi-field value object it doesn't itself define the members of). - .closed_set_options(value_object) ⇒ Object
- .cross_aggregate_value_object(aggregate, type_name) ⇒ Object
- .group_field(shape, aggregate, common) ⇒ Object
- .list_field(attribute, aggregate, path) ⇒ Object
- .money_field(shape, common) ⇒ Object
- .own_value_object(attribute, aggregate) ⇒ Object
- .primitive_field(attribute, common) ⇒ Object
- .reference_field(attribute, common) ⇒ Object
-
.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 resolvereference_to,admits:, and a same-chapter value object by name. - .select_or_radio(common, options) ⇒ Object
- .text_field(attribute, common) ⇒ Object
- .value_object_field(attribute, aggregate, common) ⇒ Object
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 = select_or_radio(common, (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 unless inner .path = "#{common[:path]}.#{inner.name}" 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}"), (shape)) end |
.closed_set_options(value_object) ⇒ Object
178 179 180 181 |
# File 'lib/hecks/forms/field_shape.rb', line 178 def self.(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, ) kind = .size <= 4 ? :radio : :select Field.new(**common, kind: kind, html_type: "text", 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 |