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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-auth/SKILL.md +7 -1
- data/CHANGELOG.md +6 -0
- data/app/assets/plutonium.css +1 -1
- data/docs/reference/auth/accounts.md +7 -0
- data/docs/reference/resource/definition.md +129 -0
- data/docs/reference/ui/forms.md +51 -21
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +917 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md.tasks.json +40 -0
- data/docs/superpowers/specs/2026-06-14-form-sectioning-design.md +237 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/rodauth/admin_generator.rb +5 -2
- data/lib/generators/pu/rodauth/migration_generator.rb +1 -1
- data/lib/generators/pu/rodauth/templates/app/interactions/resend_admin_interaction.rb.tt +18 -0
- data/lib/generators/pu/rodauth/views_generator.rb +1 -1
- data/lib/plutonium/definition/base.rb +1 -0
- data/lib/plutonium/definition/form_layout.rb +144 -0
- data/lib/plutonium/interaction/base.rb +1 -0
- data/lib/plutonium/package/engine.rb +17 -7
- data/lib/plutonium/ui/form/components/section.rb +58 -0
- data/lib/plutonium/ui/form/resource.rb +85 -7
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/slim_select.css +11 -2
- metadata +8 -2
|
@@ -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).
|