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,40 @@
1
+ {
2
+ "planPath": "docs/superpowers/plans/2026-06-14-form-sectioning.md",
3
+ "tasks": [
4
+ {
5
+ "id": 1,
6
+ "subject": "Task 1: FormLayout DSL module + registry",
7
+ "status": "completed",
8
+ "description": "Add form_layout/section/ungrouped DSL + ordered inheritable registry + validations; include in Definition::Base and Interaction::Base.\n\n```json:metadata\n{\"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}\n```"
9
+ },
10
+ {
11
+ "id": 2,
12
+ "subject": "Task 2: Resolve field list into ordered sections",
13
+ "status": "completed",
14
+ "blockedBy": [1],
15
+ "description": "Add resolve_form_sections(resource_fields): assign fields (first-section-wins, order preserved), leftovers to ungrouped (default first / declared position), raise on unknown keys, keep empty sections.\n\n```json:metadata\n{\"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}\n```"
16
+ },
17
+ {
18
+ "id": 3,
19
+ "subject": "Task 3: Section chrome component + columns helper",
20
+ "status": "completed",
21
+ "blockedBy": [1],
22
+ "description": "Add Components::Section (heading/description, native <details> collapsible, grid by columns), yields field rendering to the form.\n\n```json:metadata\n{\"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}\n```"
23
+ },
24
+ {
25
+ "id": 4,
26
+ "subject": "Task 4: Render sections in resource forms + integration",
27
+ "status": "completed",
28
+ "blockedBy": [2, 3],
29
+ "description": "Form::Resource#render_fields groups via resolver + Section component, evaluates condition in form context, falls back to single grid. Register KitchenSink in admin + add form_layout to KitchenSinkDefinition; integration test.\n\n```json:metadata\n{\"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}\n```"
30
+ },
31
+ {
32
+ "id": 5,
33
+ "subject": "Task 5: Interaction forms render sections",
34
+ "status": "completed",
35
+ "blockedBy": [4],
36
+ "description": "Confirm interaction forms render form_layout sections (shared render_fields path). Add form_layout to a dummy interaction exercised by an org_portal test; assert grouped render.\n\n```json:metadata\n{\"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}\n```"
37
+ }
38
+ ],
39
+ "lastUpdated": "2026-06-14T12:55:00Z"
40
+ }
@@ -0,0 +1,237 @@
1
+ # Form Sectioning — Design
2
+
3
+ **Date:** 2026-06-14
4
+ **Status:** Approved (pending spec review)
5
+
6
+ ## Problem
7
+
8
+ Plutonium forms render every field in a single responsive grid
9
+ (`Form::Resource#render_fields` walks a flat `resource_fields` list inside one
10
+ `fields_wrapper`). There is no way to group fields under headings ("Personal
11
+ details", "Address", …). We want a clean DSL for *sectioning* forms that works
12
+ for both resource definitions and interactions, without disturbing per-field
13
+ configuration.
14
+
15
+ ## Constraints discovered in the codebase
16
+
17
+ - **Field config lives in the definition** (`input :x, ...` via
18
+ `DefineableProps`, stored as ordered hashes), but the **set and order of
19
+ fields actually rendered come from the policy** (`permitted_attributes_for(action)`,
20
+ via `submittable_attributes_for`). The form receives that flat list as
21
+ `resource_fields`.
22
+ - **Interactions reuse the resource form.** `Form::Interaction < Form::Resource`
23
+ and only swaps in `resource_fields` (from `interaction.attribute_names`) and
24
+ `resource_definition` (the interaction instance). So sectioning added to
25
+ `Form::Resource#render_fields` is inherited by interactions for free.
26
+ - The shared definition DSL is composed from `Plutonium::Definition::*` concern
27
+ modules. `StructuredInputs` is the precedent for a module mixed into **both**
28
+ `Definition::Base` and `Interaction::Base`.
29
+
30
+ Therefore: sectioning is a **presentation concern declared in the definition**
31
+ that must group a **policy-driven** field list — skipping fields the policy
32
+ filtered out and hiding sections that end up empty.
33
+
34
+ ## DSL
35
+
36
+ A new shared module `Plutonium::Definition::FormLayout` provides `form_layout`.
37
+
38
+ ```ruby
39
+ form_layout do
40
+ section :identity, :name, :date_of_birth, :grade,
41
+ label: "Your identification", description: "Basic info"
42
+
43
+ section :address, :street, :city, :country,
44
+ collapsible: true, columns: 2,
45
+ condition: -> { object.requires_address? }
46
+
47
+ ungrouped label: "Other", collapsible: true
48
+ end
49
+ ```
50
+
51
+ ### `section(key, *fields, **opts)`
52
+
53
+ - `key` — Symbol, the section's identity. Heading defaults to `key.to_s.humanize`.
54
+ `:ungrouped` is **reserved** for the macro below — `section :ungrouped, …`
55
+ raises `ArgumentError`.
56
+ - `*fields` — ordered field keys placed in this section.
57
+ - `**opts`:
58
+ - `label:` — overrides the humanized heading.
59
+ - `description:` — optional help line under the heading.
60
+ - `collapsible:` — Boolean (default `false`). Renders as a native
61
+ `<details>/<summary>` disclosure (no JS).
62
+ - `collapsed:` — Boolean (default `false`). Initial state when collapsible;
63
+ `false` ⇒ the `<details>` is `open`.
64
+ - `columns:` — Integer overriding the section's grid column count. Default
65
+ inherits the form's responsive grid.
66
+ - `condition:` — lambda evaluated in the **form instance context** (same
67
+ semantics as `input ..., condition:`; `object` and helpers available). A
68
+ falsey result renders nothing for the section.
69
+
70
+ ### `ungrouped(**opts)`
71
+
72
+ - Configures the implicit bucket that auto-collects every permitted field not
73
+ claimed by a `section`. Takes **no field list**.
74
+ - Accepts the same options as `section` (`label:`, `description:`,
75
+ `collapsible:`, `collapsed:`, `columns:`, `condition:`).
76
+ - **Position:** where the macro is called sets where leftovers render. If the
77
+ macro is omitted, leftovers render **last** (appended after all declared
78
+ sections), with **no heading**. _(Amended — was "first"; see Amendments.)_
79
+ - Calling `ungrouped` more than once in a single `form_layout` raises
80
+ `ArgumentError`.
81
+
82
+ ### Field configuration stays on `input`
83
+
84
+ `form_layout`/`section` only reference field **keys** and carry **section-level**
85
+ options. All per-field rendering config (`as:`, the field's own `label:`,
86
+ `choices:`, per-field `condition:`, `pre_submit:`, blocks) remains on the `input`
87
+ declaration. Layout never duplicates field config.
88
+
89
+ ### Inheritance / override
90
+
91
+ - Re-declaring `form_layout` in a subclass **replaces** the parent layout as a
92
+ unit. Field-level `input` config continues to inherit normally.
93
+ - The section registry is duplicated to subclasses on `inherited` (mirrors
94
+ `StructuredInputs`).
95
+
96
+ ### Backwards compatibility
97
+
98
+ No `form_layout` declared ⇒ the current single-grid behavior is used unchanged.
99
+ This is equivalent to one implicit, heading-less `ungrouped` region.
100
+
101
+ ## Rendering
102
+
103
+ `Plutonium::Definition::FormLayout` exposes an ordered, frozen registry of
104
+ section specs (and the `ungrouped` spec + its position) on definition and
105
+ interaction instances.
106
+
107
+ `Form::Resource#render_fields` becomes:
108
+
109
+ 1. **No layout** → existing path: one `fields_wrapper` over all `resource_fields`.
110
+ 2. **Layout present**:
111
+ - Assign each `section` its `fields ∩ resource_fields`, preserving the
112
+ section's declared field order.
113
+ - The `ungrouped` bucket gets `resource_fields` minus all claimed fields,
114
+ preserving `resource_fields` order.
115
+ - Build the ordered render list: sections in declared order with the
116
+ `ungrouped` bucket inserted at its declared position (default: **last**).
117
+ - For each entry: evaluate `condition` (skip if falsey); render a Section
118
+ component with its renderable fields. Empty sections are **not**
119
+ special-cased — they render with defaults (see Edge cases).
120
+
121
+ Field rendering itself still goes through the existing `render_resource_field`,
122
+ so all input types/components are unaffected.
123
+
124
+ ### Section component
125
+
126
+ New `Plutonium::UI::Form::Components::Section` (Phlex). Responsibilities:
127
+
128
+ - Wrapper + heading (`label`) + optional `description`.
129
+ - When `collapsible`, wrap in native `<details>`/`<summary>` (`open` unless
130
+ `collapsed`), styled with Tailwind/`--pu-*` tokens.
131
+ - A grid (`fields_wrapper`-style) whose column count comes from `columns:` when
132
+ given, else the existing responsive default
133
+ (`grid-cols-1 md:grid-cols-2 2xl:grid-cols-4`).
134
+ - Yields to render the section's fields via `render_resource_field`.
135
+
136
+ A small helper maps `columns:` → grid classes (e.g. `1 → "grid grid-cols-1 gap-6"`,
137
+ `2 → "grid grid-cols-1 md:grid-cols-2 gap-6"`). Theme entries added for section
138
+ heading/description/wrapper so they're themeable like the rest of the form.
139
+
140
+ ## Edge cases
141
+
142
+ - **Field permitted but in no section** → falls into `ungrouped`.
143
+ - **Section references a policy-filtered field** → that individual field is
144
+ skipped (no input is rendered for an unpermitted attribute).
145
+ - **Empty section** (all fields filtered out, or none assigned) → **not
146
+ hidden**. It renders through the normal path with defaults (its default/declared
147
+ chrome). There is no automatic empty-hiding; to hide a section conditionally,
148
+ use `condition:`.
149
+ - **Unknown field key in a `section`** (not an attribute at all) → raise at
150
+ render with a clear message (catches typos), consistent with how the form
151
+ already errors on unknown fields.
152
+ - **`condition` falsey** → section renders nothing; its fields do **not** spill
153
+ into `ungrouped` (they remain owned by the suppressed section).
154
+ - **No leftovers** → `ungrouped` renders with defaults (with no fields and no
155
+ configured heading, that is simply nothing visible; a configured `label:`
156
+ still renders).
157
+
158
+ ## Files
159
+
160
+ - **New** `lib/plutonium/definition/form_layout.rb` — DSL module: `form_layout`
161
+ block builder, ordered + inheritable section registry, `section` / `ungrouped`,
162
+ instance readers, validation (duplicate `ungrouped`, etc.).
163
+ - **Modify** `lib/plutonium/definition/base.rb` — `include FormLayout`.
164
+ - **Modify** `lib/plutonium/interaction/base.rb` — `include FormLayout`.
165
+ - **New** `lib/plutonium/ui/form/components/section.rb` — section component.
166
+ - **Modify** `lib/plutonium/ui/form/resource.rb` — `render_fields` grouping; columns→grid helper.
167
+ - **Modify** `lib/plutonium/ui/form/theme.rb` — section heading/description/wrapper tokens.
168
+
169
+ ## Testing (RSpec)
170
+
171
+ - **DSL/registry:** sections recorded in order with options; `ungrouped` spec +
172
+ position; humanized default label; `label:` override; duplicate `ungrouped`
173
+ raises; `section :ungrouped` raises; inheritance duplicates registry;
174
+ re-declaring `form_layout` replaces.
175
+ - **Assignment:** fields land in the right section in declared order; leftovers
176
+ collect into `ungrouped`; `ungrouped` default position is last; explicit
177
+ position honored.
178
+ - **Filtering:** policy-filtered field is skipped; an empty section still renders
179
+ with defaults (is **not** hidden); unknown field key raises.
180
+ - **Conditions:** falsey `condition` hides the section and withholds its fields.
181
+ - **Rendering:** headings/descriptions present; collapsible emits
182
+ `<details>`/`<summary>` with correct `open`; `columns:` changes grid classes.
183
+ - **Interactions:** an interaction with `form_layout` sections renders grouped
184
+ via `Form::Interaction`.
185
+ - **Backwards-compat:** a definition with no `form_layout` renders the single
186
+ grid exactly as before.
187
+
188
+ ## Out of scope (YAGNI)
189
+
190
+ - Applying sections to show/display pages or the index grid (forms only for now;
191
+ the same registry could later be reused by `Display::Resource`).
192
+ - Nested sections / tabs.
193
+ - Per-field layout hints (e.g. `field :notes, span: 2`) — Style 1 keeps fields
194
+ positional; this can be added later via the nested-block form if needed.
195
+ - Stimulus-driven animated collapse (native `<details>` chosen for leanness).
196
+
197
+ ## Amendments (post-implementation)
198
+
199
+ Changes made after the original plan landed:
200
+
201
+ - **Implicit `ungrouped` placement: first → last.** When no `ungrouped` macro is
202
+ declared, leftover fields are now appended **after** all declared sections
203
+ (was: prepended). This matches the convention of the explicit macro ("the
204
+ rest" trails the sections you care about) and makes "omit it" equivalent to
205
+ "declare it last." To float leftovers above your sections, declare `ungrouped`
206
+ explicitly at the top.
207
+
208
+ - **`columns:` actually lays out in a grid.** Previously every field wrapper got
209
+ `col-span-full`, so a section's `columns: N` had no visible effect. Fields in a
210
+ multi-column section now flow into single grid cells. A field that declares its
211
+ own span (`input :x, wrapper: {class: "col-span-..."}`) **always wins** — in any
212
+ section — so authors can opt a field back to full width (or wider) inside a
213
+ multi-column section.
214
+
215
+ - **Dynamic section options.** Every section option except `columns:`
216
+ (`collapsed`, `collapsible`, `label`, `description`, plus the existing
217
+ `condition`) may be a **proc**, resolved at render time in the form instance
218
+ context — the same context as input/section `condition:` (so `object`,
219
+ `current_user`, `params`, helpers are available). The whole layout is resolved
220
+ once per render in `Form::Resource#resolve_form_layout` (visibility + option
221
+ evaluation in one pass); `render_form_section` is pure presentation. `columns:`
222
+ stays a validated literal (it feeds the grid class).
223
+
224
+ - **Interactions: verified + exercised.** `Form::Interaction < Form::Resource`
225
+ already inherited the layout path; this is now covered by a dummy interaction
226
+ (`ReconfigureKitchenSink`, a record action on `KitchenSink`) and an integration
227
+ test. In an interaction form `object` is the interaction instance and
228
+ `object.resource` is the record, so record-aware dynamic options work there too
229
+ (e.g. `collapsed: -> { object.resource.archived? }`).
230
+
231
+ - **Unrelated fix surfaced while driving the dummy in `development`:** the package
232
+ engine system (`Plutonium::Package::Engine`) called `Rails.application.initializers`
233
+ from a `before_configuration` hook, prematurely memoizing `Rails.application.railties`
234
+ and dropping package engines from the autoload paths when a second
235
+ `Rails::Application` (combustion, in dev) was instantiated before the packages
236
+ glob ran — surfacing as `uninitialized constant Blogging::Post`. The view-path
237
+ neutralization was moved to a real initializer (`before: :add_view_paths`).
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.58.1)
4
+ plutonium (0.59.0)
5
5
  action_policy (~> 0.7.0)
6
6
  csv
7
7
  listen (~> 3.8)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.58.1)
4
+ plutonium (0.59.0)
5
5
  action_policy (~> 0.7.0)
6
6
  csv
7
7
  listen (~> 3.8)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.58.1)
4
+ plutonium (0.59.0)
5
5
  action_policy (~> 0.7.0)
6
6
  csv
7
7
  listen (~> 3.8)
@@ -95,13 +95,16 @@ module Pu
95
95
  def create_invite_interaction
96
96
  template "app/interactions/invite_admin_interaction.rb",
97
97
  "app/interactions/#{normalized_name}/invite_interaction.rb"
98
+ template "app/interactions/resend_admin_interaction.rb",
99
+ "app/interactions/#{normalized_name}/resend_invite_interaction.rb"
98
100
 
99
101
  inject_into_file "app/definitions/#{normalized_name}_definition.rb",
100
- " action :invite, interaction: #{name.classify}::InviteInteraction, collection: true, category: :primary\n",
102
+ " action :invite, interaction: #{name.classify}::InviteInteraction, collection: true, category: :primary\n" \
103
+ " action :resend_invite, interaction: #{name.classify}::ResendInviteInteraction, record_action: true, category: :secondary\n",
101
104
  after: /class #{name.classify}Definition < .+\n/
102
105
 
103
106
  inject_into_file "app/policies/#{normalized_name}_policy.rb",
104
- "def invite?\n true\n end\n\n ",
107
+ "def invite?\n true\n end\n\n def resend_invite?\n record.unverified?\n end\n\n ",
105
108
  before: "# Core attributes"
106
109
  end
107
110
 
@@ -20,7 +20,7 @@ module Pu
20
20
  desc "Generate migrations for supported features\n\n" \
21
21
  "Supported Features\n" \
22
22
  "=========================================\n" \
23
- "#{MIGRATION_CONFIG.keys.sort.map(&:to_s).join "\n"}\n\n\n\n"
23
+ "#{MIGRATION_CONFIG.keys.sort.join "\n"}\n\n\n\n"
24
24
 
25
25
  class_option :features, required: true, type: :array,
26
26
  desc: "Rodauth features to create tables for (otp, sms_codes, single_session, account_expiration etc.)"
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= name.classify %>::ResendInviteInteraction < Plutonium::Resource::Interaction
4
+ presents label: "Resend Invitation", icon: Phlex::TablerIcons::MailForward
5
+
6
+ attribute :resource
7
+
8
+ def execute
9
+ unless resource.unverified?
10
+ return failed("Can only resend invitations to unverified accounts")
11
+ end
12
+
13
+ RodauthApp.rodauth(:<%= normalized_name %>).verify_account_resend(login: resource.email)
14
+ succeed(resource).with_message("Invitation resent to #{resource.email}")
15
+ rescue ::Rodauth::InternalRequestError => e
16
+ failed(e.message)
17
+ end
18
+ end
@@ -13,7 +13,7 @@ module Pu
13
13
  desc "Generate views for selected features\n\n" \
14
14
  "Supported Features\n" \
15
15
  "=========================================\n" \
16
- "#{VIEW_CONFIG.keys.sort.map(&:to_s).join "\n"}\n\n\n\n"
16
+ "#{VIEW_CONFIG.keys.sort.join "\n"}\n\n\n\n"
17
17
 
18
18
  argument :plugin_name, type: :string, optional: true,
19
19
  desc: "[CONFIG] Name of the configured rodauth app. Leave blank to use the primary account."
@@ -34,6 +34,7 @@ module Plutonium
34
34
  include Search
35
35
  include NestedInputs
36
36
  include StructuredInputs
37
+ include FormLayout
37
38
  include IndexViews
38
39
  include Metadata
39
40
 
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Definition
5
+ # Declarative form sectioning. Mixed into both resource definitions and
6
+ # interactions (mirrors StructuredInputs). The layout references field KEYS
7
+ # only and carries section-level options; per-field config stays on `input`.
8
+ #
9
+ # @example
10
+ # form_layout do
11
+ # section :identity, :name, :email, label: "Your identification"
12
+ # section :address, :street, :city, collapsible: true, columns: 2,
13
+ # condition: -> { object.requires_address? }
14
+ # ungrouped label: "Other"
15
+ # end
16
+ module FormLayout
17
+ extend ActiveSupport::Concern
18
+
19
+ UNGROUPED_KEY = :ungrouped
20
+
21
+ # One declared section, or the implicit `ungrouped` bucket (empty `fields`).
22
+ Section = Struct.new(:key, :fields, :options) do
23
+ def ungrouped? = key == UNGROUPED_KEY
24
+ def label = options[:label] || key.to_s.humanize
25
+ def description = options[:description]
26
+ def collapsible? = !!options[:collapsible]
27
+ def collapsed? = !!options[:collapsed]
28
+ def columns = options[:columns]
29
+ def condition = options[:condition]
30
+ end
31
+
32
+ # A section paired with the concrete fields it will render (after policy
33
+ # filtering). Produced by #resolve_form_sections (a later task).
34
+ ResolvedSection = Struct.new(:section, :fields)
35
+
36
+ # Collects section/ungrouped calls from a form_layout block in order.
37
+ class Builder
38
+ attr_reader :sections
39
+
40
+ def initialize
41
+ @sections = []
42
+ @ungrouped_seen = false
43
+ end
44
+
45
+ def section(key, *fields, **options)
46
+ if key == UNGROUPED_KEY
47
+ raise ArgumentError,
48
+ "`section :#{UNGROUPED_KEY}` is reserved — use the `ungrouped` macro"
49
+ end
50
+ validate_columns!(options)
51
+ @sections << Section.new(key:, fields: fields.freeze, options: options.freeze)
52
+ end
53
+
54
+ def ungrouped(**options)
55
+ raise ArgumentError, "`ungrouped` may only be declared once" if @ungrouped_seen
56
+ @ungrouped_seen = true
57
+ validate_columns!(options)
58
+ @sections << Section.new(key: UNGROUPED_KEY, fields: [].freeze, options: options.freeze)
59
+ end
60
+
61
+ private
62
+
63
+ def validate_columns!(options)
64
+ return unless options.key?(:columns)
65
+ value = options[:columns]
66
+ unless Integer === value && value > 0
67
+ raise ArgumentError,
68
+ "form_layout :columns must be a positive Integer, got #{value.inspect}"
69
+ end
70
+ end
71
+ end
72
+
73
+ class_methods do
74
+ # Declare the form layout. Re-declaring replaces it as a unit.
75
+ def form_layout(&block)
76
+ raise ArgumentError, "`form_layout` requires a block" unless block
77
+ builder = Builder.new
78
+ builder.instance_exec(&block)
79
+ @defined_form_layout = builder.sections.freeze
80
+ end
81
+
82
+ # Ordered Array<Section>, or nil when no layout was declared.
83
+ def defined_form_layout
84
+ @defined_form_layout
85
+ end
86
+
87
+ def inherited(subclass)
88
+ super
89
+ subclass.instance_variable_set(:@defined_form_layout, defined_form_layout&.dup)
90
+ end
91
+ end
92
+
93
+ # Instance access — the form render path holds a definition/interaction
94
+ # instance (mirrors the defineable_prop convention).
95
+ def defined_form_layout
96
+ self.class.defined_form_layout
97
+ end
98
+
99
+ # Resolve the policy-filtered field list into ordered ResolvedSections.
100
+ # Returns nil when no layout is declared (caller falls back to one grid).
101
+ def resolve_form_sections(resource_fields)
102
+ layout = defined_form_layout
103
+ return nil unless layout
104
+
105
+ resource_fields = resource_fields.map(&:to_sym)
106
+ known = resource_fields.to_set
107
+
108
+ # First-section-wins assignment: map each field to the first section key.
109
+ owner = {}
110
+ layout.each do |section|
111
+ next if section.ungrouped?
112
+ section.fields.each do |f|
113
+ unless known.include?(f)
114
+ raise ArgumentError,
115
+ "form_layout section :#{section.key} references unknown field :#{f}"
116
+ end
117
+ owner[f] ||= section.key
118
+ end
119
+ end
120
+ leftovers = resource_fields.reject { |f| owner.key?(f) }
121
+
122
+ resolved = layout.map do |section|
123
+ fields =
124
+ if section.ungrouped?
125
+ leftovers
126
+ else
127
+ section.fields.select { |f| owner[f] == section.key }
128
+ end
129
+ ResolvedSection.new(section:, fields:)
130
+ end
131
+
132
+ unless layout.any?(&:ungrouped?)
133
+ implicit = ResolvedSection.new(
134
+ section: Section.new(key: UNGROUPED_KEY, fields: [].freeze, options: {}.freeze),
135
+ fields: leftovers
136
+ )
137
+ resolved.push(implicit)
138
+ end
139
+
140
+ resolved
141
+ end
142
+ end
143
+ end
144
+ end
@@ -26,6 +26,7 @@ module Plutonium
26
26
  include Plutonium::Definition::ConfigAttr
27
27
  include Plutonium::Definition::Presentable
28
28
  include Plutonium::Definition::StructuredInputs
29
+ include Plutonium::Definition::FormLayout
29
30
 
30
31
  # On interactions, declaring a structured input also declares the backing
31
32
  # ActiveModel attribute so the value survives `attributes=` and appears in
@@ -4,14 +4,24 @@ module Plutonium
4
4
  extend ActiveSupport::Concern
5
5
 
6
6
  included do
7
- # prevent this package from being added to the view lookup
8
- # since we need finer control over how views are resolved.
9
- # view lookup configuration is handled at the controller level
10
- config.before_configuration do
11
- # this touches the internals of rails, but I could not find a good way of doing this
12
- # we get the initializer instance and set the block property to a noop
7
+ # Prevent this package's app/views from being appended to the global
8
+ # ActionController/ActionMailer view lookup Plutonium resolves package
9
+ # views at the controller level (see Plutonium::Core::Controllers::Bootable,
10
+ # which reads current_engine.paths["app/views"]). We neutralize the
11
+ # engine's built-in `add_view_paths` initializer rather than clearing
12
+ # config.paths["app/views"], which that controller-level resolver needs.
13
+ #
14
+ # This MUST run as a real initializer (before :add_view_paths), NOT in
15
+ # before_configuration: that hook can fire before sibling package engines
16
+ # are loaded (it does in development, where :before_configuration has
17
+ # already run by the time config/packages.rb loads). Touching
18
+ # Rails.application.initializers there forces Rails.application.railties
19
+ # to memoize early — with only the packages loaded so far — permanently
20
+ # dropping the rest from the autoload paths (e.g. `uninitialized constant
21
+ # Blogging::Post`). By initializer-run time, railties is fully populated.
22
+ initializer :plutonium_neutralize_add_view_paths, before: :add_view_paths do
13
23
  add_view_paths_initializer = Rails.application.initializers.find do |a|
14
- a.context_class == self && a.name.to_s == "add_view_paths"
24
+ a.context_class == self.class && a.name.to_s == "add_view_paths"
15
25
  end
16
26
  add_view_paths_initializer&.instance_variable_set(:@block, ->(app) {})
17
27
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # Renders a form section's chrome (heading/description, optional native
8
+ # <details> collapsible, and a fields grid) and yields to a block that
9
+ # renders the section's fields (the form supplies render_resource_field).
10
+ class Section < Plutonium::UI::Component::Base
11
+ def initialize(resolved, grid_class:)
12
+ @section = resolved.section
13
+ @grid_class = grid_class
14
+ end
15
+
16
+ SECTION_CLASS = "space-y-4 border-t border-[var(--pu-border-muted)] pt-6 first:border-t-0 first:pt-0"
17
+ HEADING_CLASS = "text-base font-semibold text-[var(--pu-text)]"
18
+ SUMMARY_CLASS = "#{HEADING_CLASS} cursor-pointer select-none"
19
+ DESCRIPTION_CLASS = "text-sm text-[var(--pu-text-muted)]"
20
+
21
+ def view_template(&fields_block)
22
+ if @section.collapsible?
23
+ details(open: !@section.collapsed?, class: SECTION_CLASS) do
24
+ summary(class: SUMMARY_CLASS) { heading_text }
25
+ describe
26
+ grid(&fields_block)
27
+ end
28
+ else
29
+ div(class: SECTION_CLASS) do
30
+ header_block
31
+ grid(&fields_block)
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def header_block
39
+ return if @section.ungrouped? && @section.options[:label].nil?
40
+ h3(class: HEADING_CLASS) { heading_text }
41
+ describe
42
+ end
43
+
44
+ def heading_text = @section.label
45
+
46
+ def describe
47
+ return unless @section.description
48
+ p(class: DESCRIPTION_CLASS) { @section.description }
49
+ end
50
+
51
+ def grid(&fields_block)
52
+ div(class: @grid_class, &fields_block)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end