Module: Hecks::Forms::Params

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

Overview

The two directions between a flat, dotted, ALL-STRINGS web payload ({"amount.cents"=>"1050", "amount.currency"=>"USD"}, whether it came off a POST form body or a GET query string — Rack hands back the same flat shape for both as long as nothing uses [] bracket names) and the nested, typed hash Dispatcher#dispatch/#query actually take ({amount: {cents: 1050, currency: "USD"}}).

extract NEEDS the Field tree, not just the raw params — a numeric leaf's own runtime check (Value::Coercion#check_numeric_fields) requires an actual Integer/Float, not a String that merely looks like one (given.is_a?(expected), no coercion attempted there); a web form can only ever hand back strings, so the cast has to happen here, once, using the SAME shape FieldShape already resolved for rendering the input in the first place — one reading of the IR, not two that could disagree.

Class Method Summary collapse

Class Method Details

.cast_scalar(field, text) ⇒ Object



113
114
115
116
117
118
# File 'lib/hecks/forms/params.rb', line 113

def self.cast_scalar(field, text)
  case field.kind
  when :number then field.step == "1" ? Integer(text) : Float(text)
  else text
  end
end

.checkbox(raw_value) ⇒ Object



109
110
111
# File 'lib/hecks/forms/params.rb', line 109

def self.checkbox(raw_value)
  %w[on 1 true].include?(raw_value.to_s.downcase)
end

.collect(field, raw, pairs) ⇒ Object



36
37
38
39
40
41
42
# File 'lib/hecks/forms/params.rb', line 36

def self.collect(field, raw, pairs)
  case field.kind
  when :group, :money then field.children.each { |child| collect(child, raw, pairs) }
  when :list then (value = extract_list(field, raw)) == SKIP || pairs[field.path] = value
  else (value = extract_leaf(field, raw)) == SKIP || pairs[field.path] = value
  end
end

.extract(fields, raw) ⇒ Object

Every LEAF field carries its own full dotted path regardless of how deep FieldShape nested it to get there — a single-attribute value object unwraps to a leaf sitting at the TOP of the fields array with a two-segment path ("reference.value"), the exact same shape a :group's own child carries. So this collects every leaf as (full path -> value) FIRST, and nests by the path's OWN segments last — one nesting rule, blind to how a field arrived at its path.



30
31
32
33
34
# File 'lib/hecks/forms/params.rb', line 30

def self.extract(fields, raw)
  pairs = {}
  fields.each { |field| collect(field, raw, pairs) }
  nest(pairs)
end

.extract_leaf(field, raw) ⇒ Object



100
101
102
103
104
105
106
107
# File 'lib/hecks/forms/params.rb', line 100

def self.extract_leaf(field, raw)
  return checkbox(raw[field.path]) if field.kind == :boolean

  text = raw[field.path]
  return SKIP if (text.nil? || text.empty?) && field.optional?

  cast_scalar(field, text)
end

.extract_list(field, raw) ⇒ Object

One line of the textarea per element. A line that itself needs several fields (a multi-attribute value object as a list element) is read as JSON on that one line — the honest fallback documented in docs/command-form-and-query-form-bluebook.md rather than a second widget this prototype doesn't build yet.



90
91
92
93
94
95
96
97
98
# File 'lib/hecks/forms/params.rb', line 90

def self.extract_list(field, raw)
  text = raw[field.path]
  return SKIP if text.nil? || text.strip.empty?

  item = field.children.first
  text.each_line.map(&:strip).reject(&:empty?).map do |line|
    item.leaf? ? cast_scalar(item, line) : JSON.parse(line, symbolize_names: true)
  end
end

.flatten(fields, values, into: {}) ⇒ Object

The other direction — a nested value hash back to the flat dotted pairs a GET link's query string carries, so a query view's "shareable link" and its filter FORM stay two renderings of the SAME data rather than two formats that can drift. Reads with FieldRenderer.dig — the identical full-path lookup a re-rendered input's own value comes from — for the same reason extract above nests by full path rather than by tree shape.



127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/hecks/forms/params.rb', line 127

def self.flatten(fields, values, into: {})
  fields.each do |field|
    case field.kind
    when :group, :money then flatten(field.children, values, into: into)
    when :list then into[field.path] = Array(FieldRenderer.dig(values, field.path)).join("\n")
    else
      value = FieldRenderer.dig(values, field.path)
      into[field.path] = value.to_s unless value.nil?
    end
  end
  into
end

.nest(pairs) ⇒ Object

A path-prefix collision: one field named (say) "price" alongside another named "price.cents" implies "price" is BOTH a scalar leaf and the parent of a nested group — the two can never coexist in the same result hash. Depending on which pair each_with_object reaches first, the naive walk below used to fail in one of two ways: a scalar planted first left acc[segment] ||= {} seeing a truthy non-Hash and reusing IT as node, so the next node[leaf] = value blew up with a raw TypeError from calling String#[]= with a Symbol key; a scalar planted AFTER the nested group instead sailed through node[leaf] = value and silently clobbered the entire nested hash with the scalar, losing every sibling under it with no error at all. Both directions are checked explicitly here so either order raises the SAME clear ArgumentError instead of a confusing crash or silent data loss — this is the family of error every command/query submission path in app.rb already rescues into a 422 (ArgumentError sits right alongside the domain refusals in every one of those rescue clauses).



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/hecks/forms/params.rb', line 61

def self.nest(pairs)
  pairs.each_with_object({}) do |(path, value), result|
    segments = path.to_s.split(".").map(&:to_sym)
    leaf = segments.pop
    node = segments.reduce(result) do |acc, segment|
      existing = acc[segment]
      raise nesting_collision(path) if existing && !existing.is_a?(Hash)

      acc[segment] ||= {}
    end
    raise nesting_collision(path) if node[leaf].is_a?(Hash)

    node[leaf] = value
  end
end

.nesting_collision(path) ⇒ Object



77
78
79
80
# File 'lib/hecks/forms/params.rb', line 77

def self.nesting_collision(path)
  ArgumentError.new("#{path.inspect} conflicts with another field at the same path — " \
                    "one names it as a plain value and another as a nested group")
end

.paths(fields, into: []) ⇒ Object

Every leaf/list path a field tree carries, independent of any values — what command_form_renderer.rb's inspect panel wants ("which fields does this command take"), where flatten above wants "what does THIS submission look like" and returns nothing for a field nothing was entered for.



145
146
147
148
149
150
151
152
153
# File 'lib/hecks/forms/params.rb', line 145

def self.paths(fields, into: [])
  fields.each do |field|
    case field.kind
    when :group, :money then paths(field.children, into: into)
    else into << field.path
    end
  end
  into
end

.to_query_string(fields, values) ⇒ Object



155
156
157
158
# File 'lib/hecks/forms/params.rb', line 155

def self.to_query_string(fields, values)
  pairs = flatten(fields, values)
  pairs.map { |key, value| "#{URI.encode_www_form_component(key)}=#{URI.encode_www_form_component(value)}" }.join("&")
end