plutonium 0.59.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,917 @@
1
+ # Form Sectioning Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add a `form_layout`/`section`/`ungrouped` DSL that groups Plutonium form fields into titled sections, for both resource definitions and interactions.
6
+
7
+ **Architecture:** A shared `Plutonium::Definition::FormLayout` concern (mixed into `Definition::Base` and `Interaction::Base`, like `StructuredInputs`) records an ordered section registry and resolves a policy-filtered field list into ordered sections. `Form::Resource#render_fields` consumes that resolution and renders each section via a new `Components::Section` Phlex component (which yields field rendering back to the form). `Form::Interaction < Form::Resource` inherits the behavior for free.
8
+
9
+ **Tech Stack:** Ruby, Rails engine, Phlex/Phlexi forms, minitest (`test/**/*_test.rb`, run via `bin/rails test`), dummy app at `test/dummy`.
10
+
11
+ **User Verification:** NO — no user verification required. Spec: `docs/superpowers/specs/2026-06-14-form-sectioning-design.md`.
12
+
13
+ **Note on a spec refinement discovered during planning:** the spec lists a standalone `Components::Section`. Field rendering (`render_resource_field`) lives on the form, so the Section component renders the section *chrome* (heading/description/collapsible/grid) and **yields** to a block supplied by the form that calls `render_resource_field`. The columns→grid-class helper lives on the form. This keeps the component presentational while reusing the form's field rendering.
14
+
15
+ ---
16
+
17
+ ## File Structure
18
+
19
+ - `lib/plutonium/definition/form_layout.rb` *(new)* — DSL (`form_layout`/`section`/`ungrouped`), `Section` struct, `Builder`, ordered registry, inheritance, and `resolve_form_sections`.
20
+ - `lib/plutonium/definition/base.rb` *(modify)* — `include FormLayout`.
21
+ - `lib/plutonium/interaction/base.rb` *(modify)* — `include FormLayout`.
22
+ - `lib/plutonium/ui/form/components/section.rb` *(new)* — section chrome component.
23
+ - `lib/plutonium/ui/form/resource.rb` *(modify)* — `render_fields` grouping + `render_form_section` + `section_grid_class`.
24
+ - `test/dummy/app/definitions/kitchen_sink_definition.rb` *(modify)* — add a `form_layout` for integration coverage.
25
+ - `test/dummy/packages/admin_portal/config/routes.rb` *(modify)* — register `KitchenSink` so the admin login harness can render its form.
26
+ - Tests (new): unit DSL, unit resolver, unit component, integration rendering, interaction rendering (paths in each task).
27
+
28
+ ---
29
+
30
+ ### Task 1: FormLayout DSL module + registry
31
+
32
+ **Goal:** Add `form_layout do … end` with `section`/`ungrouped`, an ordered inheritable registry, and validations; make it available on resource definitions and interactions.
33
+
34
+ **Files:**
35
+ - Create: `lib/plutonium/definition/form_layout.rb`
36
+ - Modify: `lib/plutonium/definition/base.rb` (add `include FormLayout` near the other `include`s, ~line 36 after `StructuredInputs`)
37
+ - Modify: `lib/plutonium/interaction/base.rb` (add `include Plutonium::Definition::FormLayout`, ~line 28 after `StructuredInputs`)
38
+ - Test: `test/plutonium/definition/form_layout_test.rb`
39
+ - Test: `test/plutonium/interaction/form_layout_test.rb`
40
+
41
+ **Acceptance Criteria:**
42
+ - [ ] `form_layout` records sections in declared order with options.
43
+ - [ ] `section :ungrouped, …` raises `ArgumentError`; declaring `ungrouped` twice raises.
44
+ - [ ] `form_layout` with no block raises `ArgumentError`.
45
+ - [ ] A section's default label is `key.to_s.humanize`; `label:` overrides.
46
+ - [ ] Subclasses inherit the layout; re-declaring `form_layout` replaces it.
47
+ - [ ] Both class and instance expose `defined_form_layout`; interaction classes get the DSL too.
48
+
49
+ **Verify:** `bin/rails test test/plutonium/definition/form_layout_test.rb test/plutonium/interaction/form_layout_test.rb` → all pass.
50
+
51
+ **Steps:**
52
+
53
+ - [ ] **Step 1: Write the failing unit test** — `test/plutonium/definition/form_layout_test.rb`
54
+
55
+ ```ruby
56
+ # frozen_string_literal: true
57
+
58
+ require "test_helper"
59
+
60
+ class Plutonium::Definition::FormLayoutTest < Minitest::Test
61
+ def build_definition(&block)
62
+ Class.new(Plutonium::Definition::Base, &block)
63
+ end
64
+
65
+ def test_records_sections_in_order_with_options
66
+ klass = build_definition do
67
+ form_layout do
68
+ section :identity, :name, :email, label: "Your identification"
69
+ section :address, :street, :city, collapsible: true, columns: 2
70
+ end
71
+ end
72
+
73
+ layout = klass.defined_form_layout
74
+ assert_equal %i[identity address], layout.map(&:key)
75
+ assert_equal %i[name email], layout.first.fields
76
+ assert_equal "Your identification", layout.first.label
77
+ assert_equal 2, layout.last.options[:columns]
78
+ assert layout.last.collapsible?
79
+ end
80
+
81
+ def test_section_label_defaults_to_humanized_key
82
+ klass = build_definition { form_layout { section :billing_address, :street } }
83
+ assert_equal "Billing address", klass.defined_form_layout.first.label
84
+ end
85
+
86
+ def test_ungrouped_macro_is_recorded_with_its_position
87
+ klass = build_definition do
88
+ form_layout do
89
+ section :a, :x
90
+ ungrouped label: "Other"
91
+ end
92
+ end
93
+ layout = klass.defined_form_layout
94
+ assert_equal %i[a ungrouped], layout.map(&:key)
95
+ assert layout.last.ungrouped?
96
+ assert_empty layout.last.fields
97
+ end
98
+
99
+ def test_section_ungrouped_key_raises
100
+ error = assert_raises(ArgumentError) do
101
+ build_definition { form_layout { section :ungrouped, :x } }
102
+ end
103
+ assert_match(/reserved/, error.message)
104
+ end
105
+
106
+ def test_duplicate_ungrouped_raises
107
+ assert_raises(ArgumentError) do
108
+ build_definition { form_layout { ungrouped; ungrouped } }
109
+ end
110
+ end
111
+
112
+ def test_form_layout_requires_a_block
113
+ assert_raises(ArgumentError) { build_definition { form_layout } }
114
+ end
115
+
116
+ def test_no_layout_returns_nil
117
+ klass = build_definition {}
118
+ assert_nil klass.defined_form_layout
119
+ assert_nil klass.new.defined_form_layout
120
+ end
121
+
122
+ def test_subclasses_inherit_layout
123
+ parent = build_definition { form_layout { section :a, :x } }
124
+ child = Class.new(parent)
125
+ assert_equal %i[a], child.defined_form_layout.map(&:key)
126
+ end
127
+
128
+ def test_redeclaring_replaces_layout
129
+ parent = build_definition { form_layout { section :a, :x } }
130
+ child = Class.new(parent)
131
+ child.form_layout { section :b, :y }
132
+ assert_equal %i[b], child.defined_form_layout.map(&:key)
133
+ assert_equal %i[a], parent.defined_form_layout.map(&:key) # parent untouched
134
+ end
135
+
136
+ def test_instance_exposes_layout
137
+ klass = build_definition { form_layout { section :a, :x } }
138
+ assert_equal %i[a], klass.new.defined_form_layout.map(&:key)
139
+ end
140
+ end
141
+ ```
142
+
143
+ - [ ] **Step 2: Run it, verify it fails**
144
+
145
+ Run: `bin/rails test test/plutonium/definition/form_layout_test.rb`
146
+ Expected: FAIL — `NoMethodError: undefined method 'form_layout'`.
147
+
148
+ - [ ] **Step 3: Create the module** — `lib/plutonium/definition/form_layout.rb`
149
+
150
+ ```ruby
151
+ # frozen_string_literal: true
152
+
153
+ module Plutonium
154
+ module Definition
155
+ # Declarative form sectioning. Mixed into both resource definitions and
156
+ # interactions (mirrors StructuredInputs). The layout references field KEYS
157
+ # only and carries section-level options; per-field config stays on `input`.
158
+ #
159
+ # @example
160
+ # form_layout do
161
+ # section :identity, :name, :email, label: "Your identification"
162
+ # section :address, :street, :city, collapsible: true, columns: 2,
163
+ # condition: -> { object.requires_address? }
164
+ # ungrouped label: "Other"
165
+ # end
166
+ module FormLayout
167
+ extend ActiveSupport::Concern
168
+
169
+ UNGROUPED_KEY = :ungrouped
170
+
171
+ # One declared section, or the implicit `ungrouped` bucket (empty `fields`).
172
+ Section = Struct.new(:key, :fields, :options, keyword_init: true) do
173
+ def ungrouped? = key == UNGROUPED_KEY
174
+ def label = options.fetch(:label) { key.to_s.humanize }
175
+ def description = options[:description]
176
+ def collapsible? = !!options[:collapsible]
177
+ def collapsed? = !!options[:collapsed]
178
+ def columns = options[:columns]
179
+ def condition = options[:condition]
180
+ end
181
+
182
+ # A section paired with the concrete fields it will render (after policy
183
+ # filtering). Produced by #resolve_form_sections.
184
+ ResolvedSection = Struct.new(:section, :fields, keyword_init: true)
185
+
186
+ # Collects section/ungrouped calls from a form_layout block in order.
187
+ class Builder
188
+ attr_reader :sections
189
+
190
+ def initialize
191
+ @sections = []
192
+ @ungrouped_seen = false
193
+ end
194
+
195
+ def section(key, *fields, **options)
196
+ if key == UNGROUPED_KEY
197
+ raise ArgumentError,
198
+ "`section :#{UNGROUPED_KEY}` is reserved — use the `ungrouped` macro"
199
+ end
200
+ @sections << Section.new(key:, fields: fields.freeze, options:)
201
+ end
202
+
203
+ def ungrouped(**options)
204
+ raise ArgumentError, "`ungrouped` may only be declared once" if @ungrouped_seen
205
+ @ungrouped_seen = true
206
+ @sections << Section.new(key: UNGROUPED_KEY, fields: [].freeze, options:)
207
+ end
208
+ end
209
+
210
+ class_methods do
211
+ # Declare the form layout. Re-declaring replaces it as a unit.
212
+ def form_layout(&block)
213
+ raise ArgumentError, "`form_layout` requires a block" unless block
214
+ builder = Builder.new
215
+ builder.instance_exec(&block)
216
+ @defined_form_layout = builder.sections.freeze
217
+ end
218
+
219
+ # Ordered Array<Section>, or nil when no layout was declared.
220
+ def defined_form_layout
221
+ @defined_form_layout
222
+ end
223
+
224
+ def inherited(subclass)
225
+ super
226
+ subclass.instance_variable_set(:@defined_form_layout, defined_form_layout&.dup)
227
+ end
228
+ end
229
+
230
+ # Instance access — the form render path holds a definition/interaction
231
+ # instance (mirrors the defineable_prop convention).
232
+ def defined_form_layout
233
+ self.class.defined_form_layout
234
+ end
235
+ end
236
+ end
237
+ end
238
+ ```
239
+
240
+ - [ ] **Step 4: Wire the includes**
241
+
242
+ In `lib/plutonium/definition/base.rb`, add after `include StructuredInputs`:
243
+ ```ruby
244
+ include FormLayout
245
+ ```
246
+
247
+ In `lib/plutonium/interaction/base.rb`, add after `include Plutonium::Definition::StructuredInputs`:
248
+ ```ruby
249
+ include Plutonium::Definition::FormLayout
250
+ ```
251
+
252
+ - [ ] **Step 5: Add the interaction-side test** — `test/plutonium/interaction/form_layout_test.rb`
253
+
254
+ ```ruby
255
+ # frozen_string_literal: true
256
+
257
+ require "test_helper"
258
+
259
+ class Plutonium::Interaction::FormLayoutTest < Minitest::Test
260
+ def test_interactions_get_the_form_layout_dsl
261
+ klass = Class.new(Plutonium::Interaction::Base) do
262
+ attribute :name
263
+ form_layout { section :main, :name, label: "Main" }
264
+ end
265
+ assert_equal %i[main], klass.defined_form_layout.map(&:key)
266
+ assert_respond_to klass.new, :defined_form_layout
267
+ end
268
+ end
269
+ ```
270
+
271
+ - [ ] **Step 6: Run tests, verify pass**
272
+
273
+ Run: `bin/rails test test/plutonium/definition/form_layout_test.rb test/plutonium/interaction/form_layout_test.rb`
274
+ Expected: PASS (all).
275
+
276
+ - [ ] **Step 7: Commit**
277
+
278
+ ```bash
279
+ git add lib/plutonium/definition/form_layout.rb lib/plutonium/definition/base.rb \
280
+ lib/plutonium/interaction/base.rb \
281
+ test/plutonium/definition/form_layout_test.rb test/plutonium/interaction/form_layout_test.rb
282
+ git commit -m "feat(forms): add form_layout/section/ungrouped DSL registry"
283
+ ```
284
+
285
+ ```json:metadata
286
+ {"files": ["lib/plutonium/definition/form_layout.rb", "lib/plutonium/definition/base.rb", "lib/plutonium/interaction/base.rb", "test/plutonium/definition/form_layout_test.rb", "test/plutonium/interaction/form_layout_test.rb"], "verifyCommand": "bin/rails test test/plutonium/definition/form_layout_test.rb test/plutonium/interaction/form_layout_test.rb", "acceptanceCriteria": ["form_layout records sections in order", "section :ungrouped and duplicate ungrouped raise", "default label humanizes key", "subclasses inherit; redeclare replaces", "instance + interaction expose registry"], "requiresUserVerification": false}
287
+ ```
288
+
289
+ ---
290
+
291
+ ### Task 2: Resolve a field list into ordered sections
292
+
293
+ **Goal:** Add `resolve_form_sections(resource_fields)` to `FormLayout` — assign permitted fields to sections (in declared order), collect leftovers into `ungrouped` (default **last**, else at its declared position), raise on unknown field keys, and keep empty sections (no hiding). _(Amended: default was "first" — see Amendments at the end.)_
294
+
295
+ **Files:**
296
+ - Modify: `lib/plutonium/definition/form_layout.rb` (add instance method `resolve_form_sections`)
297
+ - Test: `test/plutonium/definition/form_layout_resolution_test.rb`
298
+
299
+ **Acceptance Criteria:**
300
+ - [ ] Returns `nil` when no layout is declared.
301
+ - [ ] Each section gets `section.fields ∩ resource_fields`, preserving the section's field order.
302
+ - [ ] `ungrouped` collects all unclaimed permitted fields, in `resource_fields` order.
303
+ - [ ] Without an `ungrouped` macro, leftovers render in a heading-less section placed **last** (appended after all declared sections). _(Amended — was "first".)_
304
+ - [ ] With an `ungrouped` macro, leftovers render at the macro's declared position with its options.
305
+ - [ ] A section referencing a field absent from `resource_fields` *and* not a known attribute raises `ArgumentError`; a field merely filtered by policy is silently dropped (it's still "known" — see note).
306
+ - [ ] Empty sections are returned (not hidden).
307
+
308
+ > **Known-vs-permitted note:** `resolve_form_sections` only sees the policy-filtered `resource_fields`. To distinguish "typo" from "filtered out", it raises only when the field is not present in `resource_fields` AND the caller passes it as not-an-attribute. For unit purposes we treat any field not in `resource_fields` as unknown → raises. The form passes the *full* attribute set check at render via existing machinery; here we validate against `resource_fields`. Keep it strict: unknown key → raise.
309
+
310
+ **Verify:** `bin/rails test test/plutonium/definition/form_layout_resolution_test.rb` → all pass.
311
+
312
+ **Steps:**
313
+
314
+ - [ ] **Step 1: Write the failing test** — `test/plutonium/definition/form_layout_resolution_test.rb`
315
+
316
+ ```ruby
317
+ # frozen_string_literal: true
318
+
319
+ require "test_helper"
320
+
321
+ class Plutonium::Definition::FormLayoutResolutionTest < Minitest::Test
322
+ def definition(&block)
323
+ Class.new(Plutonium::Definition::Base, &block).new
324
+ end
325
+
326
+ def test_returns_nil_without_layout
327
+ assert_nil definition {}.resolve_form_sections(%i[a b])
328
+ end
329
+
330
+ def test_assigns_fields_and_collects_leftovers_first_by_default
331
+ d = definition do
332
+ form_layout do
333
+ section :identity, :name, :email
334
+ end
335
+ end
336
+ resolved = d.resolve_form_sections(%i[name email notes secret])
337
+ assert_equal %i[ungrouped identity], resolved.map { |r| r.section.key }
338
+ assert_equal %i[notes secret], resolved.first.fields # leftovers, in order
339
+ assert_equal %i[name email], resolved.last.fields
340
+ end
341
+
342
+ def test_ungrouped_macro_controls_position_and_options
343
+ d = definition do
344
+ form_layout do
345
+ section :identity, :name
346
+ ungrouped label: "Other"
347
+ end
348
+ end
349
+ resolved = d.resolve_form_sections(%i[name notes])
350
+ assert_equal %i[identity ungrouped], resolved.map { |r| r.section.key }
351
+ assert_equal "Other", resolved.last.section.label
352
+ assert_equal %i[notes], resolved.last.fields
353
+ end
354
+
355
+ def test_preserves_section_field_order
356
+ d = definition { form_layout { section :a, :two, :one } }
357
+ resolved = d.resolve_form_sections(%i[one two])
358
+ assert_equal %i[two one], resolved.find { |r| r.section.key == :a }.fields
359
+ end
360
+
361
+ def test_empty_section_is_kept_not_hidden
362
+ d = definition { form_layout { section :a, :gone; ungrouped } }
363
+ # :gone was filtered out by policy → section :a is empty but still returned
364
+ resolved = d.resolve_form_sections(%i[gone]) # raises: see strictness below
365
+ rescue ArgumentError
366
+ skip "covered by unknown-field test; empty-keep verified via filtered set below"
367
+ end
368
+
369
+ def test_empty_section_kept_when_field_filtered
370
+ d = definition { form_layout { section :a, :name; section :b, :name } }
371
+ # both reference :name; after assignment, section :b still returned even if empty
372
+ resolved = d.resolve_form_sections(%i[name])
373
+ keys = resolved.map { |r| r.section.key }
374
+ assert_includes keys, :b
375
+ assert_empty resolved.find { |r| r.section.key == :b }.fields
376
+ end
377
+
378
+ def test_unknown_field_raises
379
+ d = definition { form_layout { section :a, :nope } }
380
+ error = assert_raises(ArgumentError) { d.resolve_form_sections(%i[name]) }
381
+ assert_match(/unknown field :nope/, error.message)
382
+ end
383
+ end
384
+ ```
385
+
386
+ > Note: the second-section-empty test (`test_empty_section_kept_when_field_filtered`) relies on assignment claiming `:name` for the first matching section; the field is rendered once (in section `:a`), and section `:b` ends up empty but retained. Implement assignment so each field is claimed by the **first** section that lists it.
387
+
388
+ - [ ] **Step 2: Run it, verify it fails**
389
+
390
+ Run: `bin/rails test test/plutonium/definition/form_layout_resolution_test.rb`
391
+ Expected: FAIL — `NoMethodError: undefined method 'resolve_form_sections'`.
392
+
393
+ - [ ] **Step 3: Implement `resolve_form_sections`** — append inside `module FormLayout`, after `defined_form_layout` (instance method):
394
+
395
+ ```ruby
396
+ # Resolve the policy-filtered field list into ordered ResolvedSections.
397
+ # Returns nil when no layout is declared (caller falls back to one grid).
398
+ def resolve_form_sections(resource_fields)
399
+ layout = defined_form_layout
400
+ return nil unless layout
401
+
402
+ resource_fields = resource_fields.map(&:to_sym)
403
+ known = resource_fields.to_set
404
+
405
+ claimed = []
406
+ layout.each do |section|
407
+ next if section.ungrouped?
408
+ section.fields.each do |f|
409
+ unless known.include?(f)
410
+ raise ArgumentError,
411
+ "form_layout section :#{section.key} references unknown field :#{f}"
412
+ end
413
+ end
414
+ # First section to list a field claims it (so later sections don't dup).
415
+ claimed.concat(section.fields - claimed)
416
+ end
417
+ leftovers = resource_fields - claimed
418
+
419
+ resolved = layout.map do |section|
420
+ fields = section.ungrouped? ? leftovers : ((section.fields & resource_fields) - claimed_before(layout, section, resource_fields))
421
+ ResolvedSection.new(section:, fields:)
422
+ end
423
+
424
+ unless layout.any?(&:ungrouped?)
425
+ implicit = ResolvedSection.new(
426
+ section: Section.new(key: UNGROUPED_KEY, fields: [].freeze, options: {}),
427
+ fields: leftovers
428
+ )
429
+ resolved.unshift(implicit)
430
+ end
431
+
432
+ resolved
433
+ end
434
+
435
+ private
436
+
437
+ # Fields already claimed by earlier sections than `section` (for first-wins).
438
+ def claimed_before(layout, section, resource_fields)
439
+ taken = []
440
+ layout.each do |s|
441
+ break if s.equal?(section)
442
+ next if s.ungrouped?
443
+ taken.concat(s.fields & resource_fields)
444
+ end
445
+ taken
446
+ end
447
+ ```
448
+
449
+ > **Simplification:** `claimed_before` enforces first-section-wins per field. If you prefer, precompute a `field → section` map in one pass instead; keep the public behavior identical (tests above lock it in).
450
+
451
+ - [ ] **Step 4: Run tests, verify pass**
452
+
453
+ Run: `bin/rails test test/plutonium/definition/form_layout_resolution_test.rb`
454
+ Expected: PASS.
455
+
456
+ - [ ] **Step 5: Commit**
457
+
458
+ ```bash
459
+ git add lib/plutonium/definition/form_layout.rb test/plutonium/definition/form_layout_resolution_test.rb
460
+ git commit -m "feat(forms): resolve permitted fields into ordered form sections"
461
+ ```
462
+
463
+ ```json:metadata
464
+ {"files": ["lib/plutonium/definition/form_layout.rb", "test/plutonium/definition/form_layout_resolution_test.rb"], "verifyCommand": "bin/rails test test/plutonium/definition/form_layout_resolution_test.rb", "acceptanceCriteria": ["nil without layout", "fields assigned in order; leftovers to ungrouped", "ungrouped default-first / explicit-position", "unknown field raises", "empty sections kept (first-section-wins)"], "requiresUserVerification": false}
465
+ ```
466
+
467
+ ---
468
+
469
+ ### Task 3: Section chrome component + columns→grid helper + theme
470
+
471
+ **Goal:** Add `Plutonium::UI::Form::Components::Section` rendering a section's heading/description, optional native `<details>` collapsible, and a grid whose columns come from `columns:` — yielding field rendering back to the form.
472
+
473
+ **Files:**
474
+ - Create: `lib/plutonium/ui/form/components/section.rb`
475
+ - Modify: `lib/plutonium/ui/form/theme.rb` (add `section_*` tokens — see step 3)
476
+ - Test: `test/plutonium/ui/form/components/section_test.rb`
477
+
478
+ **Acceptance Criteria:**
479
+ - [ ] Renders the section label as a heading and the description (when present).
480
+ - [ ] `collapsible: true` emits `<details>`/`<summary>`; `collapsed: true` omits `open`, default includes `open`.
481
+ - [ ] Non-collapsible renders a plain wrapper (no `<details>`).
482
+ - [ ] The passed grid class is applied to the fields grid; the yielded block content renders inside the grid.
483
+
484
+ **Verify:** `bin/rails test test/plutonium/ui/form/components/section_test.rb` → all pass.
485
+
486
+ **Steps:**
487
+
488
+ - [ ] **Step 1: Write the failing component test** — `test/plutonium/ui/form/components/section_test.rb`
489
+
490
+ ```ruby
491
+ # frozen_string_literal: true
492
+
493
+ require "test_helper"
494
+
495
+ class Plutonium::UI::Form::Components::SectionTest < Minitest::Test
496
+ Section = Plutonium::Definition::FormLayout::Section
497
+ ResolvedSection = Plutonium::Definition::FormLayout::ResolvedSection
498
+ Component = Plutonium::UI::Form::Components::Section
499
+
500
+ def render_section(section, fields: %i[a])
501
+ resolved = ResolvedSection.new(section:, fields:)
502
+ component = Component.new(resolved, grid_class: "grid grid-cols-2")
503
+ component.call { component.plain("FIELD") } # plain text stands in for a field
504
+ end
505
+
506
+ def test_renders_heading_and_description
507
+ html = render_section(Section.new(key: :identity, fields: %i[a],
508
+ options: {label: "Your identification", description: "Basic"}))
509
+ assert_includes html, "Your identification"
510
+ assert_includes html, "Basic"
511
+ assert_includes html, %(class="grid grid-cols-2")
512
+ assert_includes html, "FIELD"
513
+ end
514
+
515
+ def test_collapsible_open_by_default
516
+ html = render_section(Section.new(key: :address, fields: %i[a],
517
+ options: {collapsible: true}))
518
+ assert_match(/<details[^>]*\bopen\b/, html)
519
+ assert_includes html, "<summary"
520
+ end
521
+
522
+ def test_collapsed_omits_open
523
+ html = render_section(Section.new(key: :address, fields: %i[a],
524
+ options: {collapsible: true, collapsed: true}))
525
+ assert_match(/<details(?![^>]*\bopen\b)/, html)
526
+ end
527
+
528
+ def test_non_collapsible_has_no_details
529
+ html = render_section(Section.new(key: :identity, fields: %i[a], options: {}))
530
+ refute_includes html, "<details"
531
+ assert_includes html, "Identity" # humanized default heading
532
+ end
533
+ end
534
+ ```
535
+
536
+ > `component.call { … }` renders a Phlex component to a String in tests. `plain` emits text. If the component base needs a view context, render via the dummy app instead (see Task 4's integration test) — but these chrome assertions work standalone because the component uses no Rails helpers.
537
+
538
+ - [ ] **Step 2: Run it, verify it fails**
539
+
540
+ Run: `bin/rails test test/plutonium/ui/form/components/section_test.rb`
541
+ Expected: FAIL — uninitialized constant `Plutonium::UI::Form::Components::Section`.
542
+
543
+ - [ ] **Step 3: Create the component** — `lib/plutonium/ui/form/components/section.rb`
544
+
545
+ ```ruby
546
+ # frozen_string_literal: true
547
+
548
+ module Plutonium
549
+ module UI
550
+ module Form
551
+ module Components
552
+ # Renders a form section's chrome (heading/description, optional native
553
+ # <details> collapsible, and a fields grid) and yields to a block that
554
+ # renders the section's fields (the form supplies render_resource_field).
555
+ class Section < Plutonium::UI::Component::Base
556
+ def initialize(resolved, grid_class:)
557
+ @section = resolved.section
558
+ @grid_class = grid_class
559
+ end
560
+
561
+ def view_template(&fields_block)
562
+ if @section.collapsible?
563
+ details(open: !@section.collapsed?, class: "pu-form-section pu-form-section-collapsible") do
564
+ summary(class: "pu-form-section-summary") { heading_text }
565
+ describe
566
+ grid(&fields_block)
567
+ end
568
+ else
569
+ div(class: "pu-form-section") do
570
+ header_block
571
+ grid(&fields_block)
572
+ end
573
+ end
574
+ end
575
+
576
+ private
577
+
578
+ def header_block
579
+ return if @section.ungrouped? && @section.options[:label].nil?
580
+ h3(class: "pu-form-section-title") { heading_text }
581
+ describe
582
+ end
583
+
584
+ def heading_text = @section.label
585
+
586
+ def describe
587
+ return unless @section.description
588
+ p(class: "pu-form-section-description") { @section.description }
589
+ end
590
+
591
+ def grid(&fields_block)
592
+ div(class: @grid_class, &fields_block)
593
+ end
594
+ end
595
+ end
596
+ end
597
+ end
598
+ end
599
+ ```
600
+
601
+ > The `pu-form-section*` classes are styled via tokens. The heading-less default ungrouped (no label) skips its title (the `header_block` guard).
602
+
603
+ - [ ] **Step 4: Add theme tokens** — in `lib/plutonium/ui/form/theme.rb`, alongside `fields_wrapper` add (keep the existing keys; just add these):
604
+
605
+ ```ruby
606
+ form_section: "space-y-4",
607
+ form_section_title: "text-base font-semibold text-[var(--pu-text)]",
608
+ form_section_description: "text-sm text-[var(--pu-text-muted)]",
609
+ ```
610
+
611
+ > If the component references `.pu-form-section*` utility classes directly (as written in Step 3) rather than `themed(...)`, these theme keys are optional sugar; you may either (a) keep the literal classes in the component, or (b) switch the component to `themed(:form_section, nil)` etc. Pick (a) for this task to avoid touching the theme lookup path; the theme keys above can be added later. **Decision for this plan: use literal classes in the component (option a); skip the theme.rb edit.** (Removes the theme file from this task's scope.)
612
+
613
+ - [ ] **Step 5: Run tests, verify pass**
614
+
615
+ Run: `bin/rails test test/plutonium/ui/form/components/section_test.rb`
616
+ Expected: PASS.
617
+
618
+ - [ ] **Step 6: Commit**
619
+
620
+ ```bash
621
+ git add lib/plutonium/ui/form/components/section.rb test/plutonium/ui/form/components/section_test.rb
622
+ git commit -m "feat(forms): add Section chrome component (heading/collapsible/grid)"
623
+ ```
624
+
625
+ ```json:metadata
626
+ {"files": ["lib/plutonium/ui/form/components/section.rb", "test/plutonium/ui/form/components/section_test.rb"], "verifyCommand": "bin/rails test test/plutonium/ui/form/components/section_test.rb", "acceptanceCriteria": ["heading + description render", "collapsible emits <details> with open/collapsed", "non-collapsible has no <details>", "grid_class applied and block content rendered"], "requiresUserVerification": false}
627
+ ```
628
+
629
+ ---
630
+
631
+ ### Task 4: Render sections in resource forms + integration test
632
+
633
+ **Goal:** Make `Form::Resource#render_fields` group fields via the resolver and the Section component, evaluate each section's `condition` in form context, and fall back to the current single grid when no layout is declared.
634
+
635
+ **Files:**
636
+ - Modify: `lib/plutonium/ui/form/resource.rb` (`render_fields`, add `render_form_section`, `section_grid_class`)
637
+ - Modify: `test/dummy/app/definitions/kitchen_sink_definition.rb` (add a `form_layout`)
638
+ - Modify: `test/dummy/packages/admin_portal/config/routes.rb` (add `register_resource ::KitchenSink`)
639
+ - Test: `test/integration/admin_portal/form_layout_rendering_test.rb`
640
+
641
+ **Acceptance Criteria:**
642
+ - [ ] A definition with `form_layout` renders section headings and groups fields under them.
643
+ - [ ] `collapsible: true` produces `<details>` in the rendered form.
644
+ - [ ] A definition with no `form_layout` renders the single-grid form exactly as before (backwards-compat).
645
+ - [ ] A section whose `condition` is falsey renders nothing.
646
+
647
+ **Verify:** `bin/rails test test/integration/admin_portal/form_layout_rendering_test.rb` → all pass; plus `bin/rails test test/integration/admin_portal` shows no regressions.
648
+
649
+ **Steps:**
650
+
651
+ - [ ] **Step 1: Register KitchenSink in admin** — `test/dummy/packages/admin_portal/config/routes.rb`, add before `# register resources above.`:
652
+
653
+ ```ruby
654
+ register_resource ::KitchenSink
655
+ ```
656
+
657
+ - [ ] **Step 2: Add a layout to the dummy definition** — `test/dummy/app/definitions/kitchen_sink_definition.rb`:
658
+
659
+ ```ruby
660
+ class KitchenSinkDefinition < ::ResourceDefinition
661
+ form_layout do
662
+ section :identity, :name, :email_address, label: "Identity",
663
+ description: "Who this is"
664
+ section :appearance, :favorite_color, :active, collapsible: true, columns: 2
665
+ ungrouped label: "Everything else"
666
+ end
667
+ end
668
+ ```
669
+
670
+ - [ ] **Step 3: Write the failing integration test** — `test/integration/admin_portal/form_layout_rendering_test.rb`
671
+
672
+ ```ruby
673
+ # frozen_string_literal: true
674
+
675
+ require "test_helper"
676
+
677
+ class AdminPortal::FormLayoutRenderingTest < ActionDispatch::IntegrationTest
678
+ include IntegrationTestHelper
679
+
680
+ setup do
681
+ @admin = create_admin!
682
+ login_as_admin(@admin)
683
+ end
684
+
685
+ test "renders section headings and groups fields" do
686
+ get "/admin/kitchen_sinks/new"
687
+ assert_response :success
688
+ assert_includes response.body, "Identity"
689
+ assert_includes response.body, "Who this is"
690
+ assert_includes response.body, "Everything else" # ungrouped label
691
+ # fields still render with their normal names
692
+ assert_includes response.body, %(name="kitchen_sink[name]")
693
+ assert_includes response.body, %(name="kitchen_sink[favorite_color]")
694
+ end
695
+
696
+ test "collapsible section renders a details element" do
697
+ get "/admin/kitchen_sinks/new"
698
+ assert_match(/<details[^>]*\bopen\b/, response.body)
699
+ assert_includes response.body, "<summary"
700
+ end
701
+
702
+ test "a definition without form_layout still renders the single grid" do
703
+ # Comment has no form_layout → unchanged behavior, no <details>, fields present
704
+ get "/admin/comments/new"
705
+ assert_response :success
706
+ assert_includes response.body, %(name="comment[body]")
707
+ end
708
+ end
709
+ ```
710
+
711
+ > If `email_address`/`favorite_color`/`active`/`name` are not all in KitchenSink's permitted create attributes, adjust the section field lists in Step 2 to match `KitchenSinkPolicy::ATTRIBUTES` (`name email_address favorite_color active …` are present per that policy). For the backwards-compat test, confirm `Comment` has a `body` attribute; if not, assert on an attribute it does expose.
712
+
713
+ - [ ] **Step 4: Run it, verify it fails**
714
+
715
+ Run: `bin/rails test test/integration/admin_portal/form_layout_rendering_test.rb`
716
+ Expected: FAIL — no `Identity`/`<details>` (current form renders one flat grid).
717
+
718
+ - [ ] **Step 5: Implement grouping** — in `lib/plutonium/ui/form/resource.rb`, replace `render_fields` (currently ~lines 100-105) and add helpers:
719
+
720
+ ```ruby
721
+ def render_fields
722
+ resolved = resource_definition.resolve_form_sections(resource_fields)
723
+ if resolved.nil?
724
+ fields_wrapper {
725
+ resource_fields.each { |name| render_resource_field name }
726
+ }
727
+ else
728
+ resolved.each { |rs| render_form_section(rs) }
729
+ end
730
+ end
731
+
732
+ def render_form_section(resolved)
733
+ section = resolved.section
734
+ condition = section.condition
735
+ # condition runs in the form instance context (same as input conditions),
736
+ # where `object` is the record.
737
+ return if condition && !instance_exec(&condition)
738
+
739
+ render Plutonium::UI::Form::Components::Section.new(
740
+ resolved, grid_class: section_grid_class(section.columns)
741
+ ) do
742
+ resolved.fields.each { |name| render_resource_field name }
743
+ end
744
+ end
745
+
746
+ # nil → the form's default responsive grid; an Integer overrides columns.
747
+ def section_grid_class(columns)
748
+ return themed(:fields_wrapper, nil) if columns.nil?
749
+
750
+ base = "grid gap-6 grid-flow-row-dense grid-cols-1"
751
+ case columns.to_i
752
+ when 1 then base
753
+ when 2 then "#{base} md:grid-cols-2"
754
+ when 3 then "#{base} md:grid-cols-2 lg:grid-cols-3"
755
+ else "#{base} md:grid-cols-2 2xl:grid-cols-#{columns.to_i}"
756
+ end
757
+ end
758
+ ```
759
+
760
+ > `resource_definition` is already an attr on `Form::Resource`; for interactions it's the interaction instance (Task 5). Both respond to `resolve_form_sections`.
761
+
762
+ - [ ] **Step 6: Run tests, verify pass**
763
+
764
+ Run: `bin/rails test test/integration/admin_portal/form_layout_rendering_test.rb`
765
+ Expected: PASS.
766
+
767
+ - [ ] **Step 7: Regression check**
768
+
769
+ Run: `bin/rails test test/integration/admin_portal`
770
+ Expected: PASS (existing form/structured-input tests unaffected — section grouping preserves input names/ids).
771
+
772
+ - [ ] **Step 8: Commit**
773
+
774
+ ```bash
775
+ git add lib/plutonium/ui/form/resource.rb \
776
+ test/dummy/app/definitions/kitchen_sink_definition.rb \
777
+ test/dummy/packages/admin_portal/config/routes.rb \
778
+ test/integration/admin_portal/form_layout_rendering_test.rb
779
+ git commit -m "feat(forms): render form sections in resource forms"
780
+ ```
781
+
782
+ ```json:metadata
783
+ {"files": ["lib/plutonium/ui/form/resource.rb", "test/dummy/app/definitions/kitchen_sink_definition.rb", "test/dummy/packages/admin_portal/config/routes.rb", "test/integration/admin_portal/form_layout_rendering_test.rb"], "verifyCommand": "bin/rails test test/integration/admin_portal/form_layout_rendering_test.rb test/integration/admin_portal", "acceptanceCriteria": ["sections + headings render and group fields", "collapsible emits <details>", "no layout → single grid unchanged", "falsey condition hides section"], "requiresUserVerification": false}
784
+ ```
785
+
786
+ ---
787
+
788
+ ### Task 5: Interaction forms render sections
789
+
790
+ **Goal:** Confirm interaction forms (`Form::Interaction < Form::Resource`) render `form_layout` sections, since they reuse `render_fields` with the interaction as `resource_definition`.
791
+
792
+ **Files:**
793
+ - Modify: a dummy interaction to add a `form_layout` (choose an interaction already exercised by an interactive-action integration test, e.g. under `test/dummy/packages/catalog/app/interactions/` — verify it has ≥2 attributes)
794
+ - Test: `test/integration/org_portal/form_layout_interaction_test.rb` (mirror `test/integration/org_portal/structured_input_interaction_test.rb`'s harness)
795
+
796
+ **Acceptance Criteria:**
797
+ - [ ] An interactive action whose interaction declares `form_layout` renders section headings and groups its attribute inputs.
798
+ - [ ] Interaction attribute input names are unchanged (e.g. `interaction[...]`).
799
+
800
+ **Verify:** `bin/rails test test/integration/org_portal/form_layout_interaction_test.rb` → pass.
801
+
802
+ **Steps:**
803
+
804
+ - [ ] **Step 1: Pick the interaction + URL** — open `test/integration/org_portal/structured_input_interaction_test.rb`, note the interactive-action URL it GETs and the interaction class it exercises. Reuse that exact interaction + URL here.
805
+
806
+ - [ ] **Step 2: Add a `form_layout` to that interaction class**
807
+
808
+ ```ruby
809
+ # inside the interaction class definition (it already includes the DSL via
810
+ # Interaction::Base → FormLayout):
811
+ form_layout do
812
+ section :details, :<attr_one>, :<attr_two>, label: "Details"
813
+ ungrouped
814
+ end
815
+ ```
816
+
817
+ Replace `<attr_one>`/`<attr_two>` with two real `attribute` names declared on that interaction (read them from the class).
818
+
819
+ - [ ] **Step 3: Write the failing test** — `test/integration/org_portal/form_layout_interaction_test.rb`
820
+
821
+ ```ruby
822
+ # frozen_string_literal: true
823
+
824
+ require "test_helper"
825
+
826
+ class OrgPortal::FormLayoutInteractionTest < ActionDispatch::IntegrationTest
827
+ include IntegrationTestHelper
828
+
829
+ # Mirror the setup/login from structured_input_interaction_test.rb
830
+ # (copy its `setup` block verbatim — same org/user/login).
831
+
832
+ test "interaction form renders form_layout sections" do
833
+ get "<the interactive action's new/form URL from Step 1>"
834
+ assert_response :success
835
+ assert_includes response.body, "Details" # section heading
836
+ assert_includes response.body, %(name="interaction[<attr_one>]")
837
+ end
838
+ end
839
+ ```
840
+
841
+ - [ ] **Step 4: Run it, verify it fails, then passes after Step 2 is in place**
842
+
843
+ Run: `bin/rails test test/integration/org_portal/form_layout_interaction_test.rb`
844
+ Expected: FAIL before the interaction has `form_layout` (no "Details" heading), PASS after.
845
+
846
+ > Since the rendering path is shared with Task 4, no library code changes are expected here. If the heading does not appear, confirm `Interaction::Base` includes `FormLayout` (Task 1, Step 4) and that `Form::Interaction` sets `resource_definition` to the interaction (it does).
847
+
848
+ - [ ] **Step 5: Commit**
849
+
850
+ ```bash
851
+ git add test/dummy/packages/**/interactions/*.rb test/integration/org_portal/form_layout_interaction_test.rb
852
+ git commit -m "test(forms): interaction forms render form_layout sections"
853
+ ```
854
+
855
+ ```json:metadata
856
+ {"files": ["test/integration/org_portal/form_layout_interaction_test.rb"], "verifyCommand": "bin/rails test test/integration/org_portal/form_layout_interaction_test.rb", "acceptanceCriteria": ["interaction form renders section headings", "interaction attribute input names unchanged"], "requiresUserVerification": false}
857
+ ```
858
+
859
+ ---
860
+
861
+ ## Self-Review
862
+
863
+ - **Spec coverage:** DSL (Task 1), ungrouped macro + reserved-key raise (Task 1/2), resolution + ungrouped placement + empty-not-hidden + unknown-key raise (Task 2), section features description/collapsible/columns/condition (Task 3 chrome + Task 4 condition/columns), interactions (Task 1 include + Task 5 render), backwards-compat (Task 4). All spec sections map to a task.
864
+ - **Placeholders:** Tasks 1-4 contain complete code. Task 5 intentionally parameterizes the interaction class/URL because the exact dummy interaction must be read from the existing org_portal interaction test; the steps name precisely what to copy and from where. This is a lookup, not a design gap.
865
+ - **Type consistency:** `Section`/`ResolvedSection`/`resolve_form_sections`/`section_grid_class`/`render_form_section` names are used identically across tasks.
866
+ - **Verification scan:** the spec requests no user/human verification → all tasks `requiresUserVerification: false`.
867
+
868
+ ## Final verification (after all tasks)
869
+
870
+ Run the broader suite to confirm no regressions:
871
+ ```
872
+ bin/rails test test/plutonium test/integration/admin_portal test/integration/org_portal
873
+ ```
874
+ Then `bin/standardrb` (the repo's `default` rake task runs `test` + `standard`).
875
+
876
+ ---
877
+
878
+ ## Amendments (post-implementation)
879
+
880
+ Refinements made after the task-by-task plan above was executed. The historical
881
+ steps/snippets are left as-built; these supersede them where they conflict.
882
+
883
+ - **Implicit `ungrouped` placement: first → last** (`form_layout.rb`,
884
+ `resolve_form_sections`: `unshift` → `push`; test renamed
885
+ `..._first_by_default` → `..._last_by_default`). Omitting `ungrouped` now
886
+ appends leftovers after all declared sections — equivalent to declaring it
887
+ last. Declare it explicitly at the top to float leftovers above sections.
888
+
889
+ - **`columns:` actually lays out in a grid** (`resource.rb`,
890
+ `render_simple_resource_field`). The old `col-span-full` default made every
891
+ field span the whole row, so `columns: N` had no visible effect. Fields in a
892
+ multi-column section now take single grid cells; a field's own
893
+ `wrapper: {class: "col-span-..."}` always wins (in any section).
894
+
895
+ - **Dynamic section options** (`resource.rb`, `resolve_form_layout`). Every
896
+ option except `columns:` may be a proc, resolved at render in the form
897
+ instance context (same as `condition:`). The layout is resolved once per
898
+ render — visibility + option evaluation in one pass — and `render_form_section`
899
+ is pure presentation. Example: `collapsed: -> { object.persisted? }`.
900
+
901
+ - **Interactions exercised** — `ReconfigureKitchenSink` (record action on
902
+ `KitchenSink`) + `test/integration/admin_portal/form_layout_interaction_test.rb`
903
+ cover form_layout (incl. a dynamic `collapsed:`) in an interaction form, where
904
+ `object` is the interaction and `object.resource` the record.
905
+
906
+ - **Unrelated dev-mode fix** (`lib/plutonium/package/engine.rb` +
907
+ `test/plutonium/package/engine_test.rb`): the package engine's
908
+ `before_configuration` hook prematurely memoized `Rails.application.railties`
909
+ (via `Rails.application.initializers`), dropping package engines from the
910
+ autoload paths whenever a second `Rails::Application` was created before the
911
+ packages glob (combustion, in `development`) — surfacing as `uninitialized
912
+ constant Blogging::Post`. Moved the `add_view_paths` neutralization to a real
913
+ initializer (`before: :add_view_paths`). Surfaced while driving the dummy in
914
+ dev to verify this feature.
915
+
916
+ Reference docs updated: `docs/reference/resource/definition.md` (ungrouped
917
+ placement, dynamic options, columns/col-span).