plutonium 0.58.1 → 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/.claude/skills/plutonium-behavior/SKILL.md +4 -0
- data/.claude/skills/plutonium-resource/SKILL.md +49 -0
- data/CHANGELOG.md +16 -0
- data/app/assets/plutonium.css +1 -1
- data/docs/.vitepress/config.ts +1 -0
- data/docs/reference/auth/accounts.md +7 -0
- data/docs/reference/resource/actions.md +3 -0
- data/docs/reference/resource/definition.md +129 -0
- data/docs/reference/resource/export.md +94 -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-12-export-csv-default-action-design.md +306 -0
- data/docs/superpowers/specs/2026-06-14-form-sectioning-design.md +237 -0
- data/gemfiles/rails_7.gemfile.lock +3 -1
- data/gemfiles/rails_8.0.gemfile.lock +3 -1
- data/gemfiles/rails_8.1.gemfile.lock +3 -1
- data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +3 -3
- 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 +4 -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/query/filter.rb +4 -1
- data/lib/plutonium/query/filters/association.rb +1 -2
- data/lib/plutonium/resource/controller.rb +1 -0
- data/lib/plutonium/resource/controllers/export_csv.rb +162 -0
- data/lib/plutonium/resource/controllers/queryable.rb +1 -0
- data/lib/plutonium/resource/policy.rb +21 -0
- data/lib/plutonium/routing/mapper_extensions.rb +13 -0
- data/lib/plutonium/ui/export_button.rb +86 -0
- data/lib/plutonium/ui/form/components/section.rb +58 -0
- data/lib/plutonium/ui/form/resource.rb +85 -7
- data/lib/plutonium/ui/table/components/toolbar.rb +9 -2
- data/lib/plutonium/ui/table/resource.rb +18 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +1 -0
- data/src/css/slim_select.css +11 -2
- metadata +26 -2
|
@@ -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,306 @@
|
|
|
1
|
+
# Built-in CSV Export — Design
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-06-12
|
|
4
|
+
**Status:** Implemented
|
|
5
|
+
|
|
6
|
+
> **Revision (two exports, toolbar split button).** The shipped feature is a **split
|
|
7
|
+
> button** with two behaviours, not a single export:
|
|
8
|
+
> - **Export** (primary) — the current view (selected scope + filters + search via
|
|
9
|
+
> `?q`). Source: `filtered_resource_collection`. Filename `…_<date>.csv`.
|
|
10
|
+
> - **Export all** (dropdown) — the entire authorized scope, bypassing the query object
|
|
11
|
+
> (`?all=1`). Source: `current_authorized_scope`. Filename `…_all_<date>.csv`.
|
|
12
|
+
>
|
|
13
|
+
> The controller selects the source via `export_csv_collection` (keyed on `params[:all]`).
|
|
14
|
+
> The split button (`Plutonium::UI::ExportButton`, reusing the `resource-drop-down`
|
|
15
|
+
> Stimulus controller) is rendered in the **index table toolbar, just after the Filter
|
|
16
|
+
> button** — `Plutonium::UI::Table::Components::Toolbar` receives an `export:` config
|
|
17
|
+
> built by `Table::Resource#export_toolbar_config` (policy-gated; carries the current
|
|
18
|
+
> `?q`). It is styled with `pu-btn-outline pu-btn-sm` to match the Filter button, with
|
|
19
|
+
> the two halves joined via inline corner styles. A page-limited "export current page"
|
|
20
|
+
> variant and a scope-named primary label were both considered and dropped.
|
|
21
|
+
|
|
22
|
+
## Goal
|
|
23
|
+
|
|
24
|
+
Ship CSV export as a **built-in, auto-mounted, policy-gated capability** on every Plutonium
|
|
25
|
+
resource — disabled by default, enabled by a one-line policy override, surfaced through a
|
|
26
|
+
**custom export button** on the index page (not the action DSL — exports are special: they stream
|
|
27
|
+
and open in a new tab). This generalizes the `AdminPortal::Concerns::ExportCsv` pattern from the
|
|
28
|
+
achieve-api app into the framework, following Plutonium's existing `typeahead` design
|
|
29
|
+
(auto-mounted route + default policy method + controller concern).
|
|
30
|
+
|
|
31
|
+
The achieve app wired export per-resource by hand (a definition action, a controller concern,
|
|
32
|
+
manual `collection { get :export_csv }` per resource, raw `attribute_names` as columns). Here the
|
|
33
|
+
route auto-mounts, the column set comes from the policy/definition, the per-field output is
|
|
34
|
+
customizable via an `export` DSL, and the export **streams** so it's safe on large tables — the
|
|
35
|
+
only thing a user does to turn it on is enable the policy.
|
|
36
|
+
|
|
37
|
+
## How users enable it
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
class PostPolicy < Plutonium::Resource::Policy
|
|
41
|
+
def export_csv? = true # or `index?`
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
That's it. The route already exists, the button appears on the index page once permitted, the
|
|
46
|
+
column set defaults to the index columns. Optionally:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
class PostDefinition < Plutonium::Resource::Definition
|
|
50
|
+
# customize a single field's output + header (column set still comes from the policy)
|
|
51
|
+
export :author, label: "Author email", &->(post) { post.author.email }
|
|
52
|
+
export :total, &->(post) { post.total.format }
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
class PostPolicy < Plutonium::Resource::Policy
|
|
58
|
+
def export_csv? = true
|
|
59
|
+
# override the exported column set (defaults to permitted_attributes_for_index)
|
|
60
|
+
def permitted_attributes_for_export = [:title, :author, :total, :created_at]
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Behavior
|
|
65
|
+
|
|
66
|
+
- The export button is a plain `<a target="_blank">` to `GET /<resources>/export_csv`, carrying
|
|
67
|
+
the **current query string** (`?q[...]`). So it exports **all records matching the current
|
|
68
|
+
filters/search/scope** — not just the visible page (the index is paginated; export is not).
|
|
69
|
+
- `target="_blank"` opens the download in a new tab **and** naturally bypasses Turbo (Turbo
|
|
70
|
+
ignores links with a `target`), so the streamed file download isn't intercepted/rendered.
|
|
71
|
+
- The response **streams** (`send_stream`) — rows are written as they're read in batches, so
|
|
72
|
+
memory stays flat regardless of row count. No row cap.
|
|
73
|
+
|
|
74
|
+
## Design (5 touch-points)
|
|
75
|
+
|
|
76
|
+
### 1. Routing — auto-mount (parallels `define_collection_typeahead_actions`)
|
|
77
|
+
|
|
78
|
+
`lib/plutonium/routing/mapper_extensions.rb`. Add `define_collection_export_actions`, invoked
|
|
79
|
+
inside the `interactive_resource_actions` concern:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
def define_collection_export_actions
|
|
83
|
+
collection do
|
|
84
|
+
get "export_csv", action: :export_csv, as: :export_csv
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Every resource registered via `register_resource` gets `GET /<resources>/export_csv`. No
|
|
90
|
+
per-resource route edits. Path helper: `export_csv_<resources>_path`.
|
|
91
|
+
|
|
92
|
+
### 2. Controller concern (parallels `Typeahead`)
|
|
93
|
+
|
|
94
|
+
`lib/plutonium/resource/controllers/export_csv.rb`, included in `controller.rb` after
|
|
95
|
+
`CrudActions` (so it can reuse the private `filtered_resource_collection`).
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
require "csv"
|
|
99
|
+
|
|
100
|
+
module Plutonium::Resource::Controllers::ExportCsv
|
|
101
|
+
extend ActiveSupport::Concern
|
|
102
|
+
|
|
103
|
+
included do
|
|
104
|
+
before_action :authorize_export_csv!, only: :export_csv
|
|
105
|
+
skip_verify_current_authorized_scope only: :export_csv
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# GET /<resources>/export_csv
|
|
109
|
+
# Streams via a lazy Enumerator response body (NOT send_stream — that
|
|
110
|
+
# lives in ActionController::Live, which would turn every resource
|
|
111
|
+
# action into a threaded streaming response). The primary key is always
|
|
112
|
+
# the first column. Rows are the index's filtered collection.
|
|
113
|
+
def export_csv
|
|
114
|
+
response.headers["Content-Type"] = "text/csv; charset=utf-8"
|
|
115
|
+
response.headers["Content-Disposition"] =
|
|
116
|
+
ActionDispatch::Http::ContentDisposition.format(disposition: "attachment", filename: export_csv_filename)
|
|
117
|
+
response.headers["X-Accel-Buffering"] = "no"
|
|
118
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
119
|
+
self.response_body = export_csv_lines
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def export_csv_lines
|
|
123
|
+
columns = export_columns # [primary_key] + (exportable_attributes - [primary_key])
|
|
124
|
+
Enumerator.new do |yielder|
|
|
125
|
+
yielder << CSV.generate_line(columns.map { |c| export_csv_header(c) })
|
|
126
|
+
filtered_resource_collection.find_each do |record|
|
|
127
|
+
yielder << CSV.generate_line(columns.map { |c| export_csv_value(record, c) })
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def authorize_export_csv!
|
|
135
|
+
authorize_current! resource_class, to: :export_csv?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def exportable_attributes
|
|
139
|
+
@exportable_attributes ||= current_policy.send_with_report(:permitted_attributes_for_export)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def export_csv_value(record, name)
|
|
143
|
+
defn = current_definition.defined_exports[name]
|
|
144
|
+
(defn && defn[:block]) ? defn[:block].call(record) : record.public_send(name)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def export_csv_header(name)
|
|
148
|
+
defn = current_definition.defined_exports[name]
|
|
149
|
+
defn&.dig(:options, :label) || name.to_s.humanize
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**Streaming + ordering tradeoff:** `find_each` bounds memory (loads ~1k rows at a time, GC'd per
|
|
155
|
+
batch) but **forces primary-key order** — the file does *not* preserve the index's current sort.
|
|
156
|
+
Filters, search, and scope from `filtered_resource_collection` *are* applied. This is the
|
|
157
|
+
deliberate cost of unbounded streaming; CSV consumers re-sort downstream. (Sort-fidelity would
|
|
158
|
+
require keyset pagination — out of scope.)
|
|
159
|
+
|
|
160
|
+
`send_stream` is available on the framework's Rails floor (Appraisal `rails-7` pins `~> 7.2`;
|
|
161
|
+
`send_stream` landed in 7.2).
|
|
162
|
+
|
|
163
|
+
**Known N+1:** an `export` block that walks an association will N+1 across batches. Acceptable for
|
|
164
|
+
v1 (document it); a preload hook can come later.
|
|
165
|
+
|
|
166
|
+
### 3. Policy (parallels `permitted_attributes_for_index`)
|
|
167
|
+
|
|
168
|
+
`lib/plutonium/resource/policy.rb`, in the action-methods section:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
# Checks if CSV export is permitted.
|
|
172
|
+
# @return [Boolean] false by default — enable per-resource by overriding.
|
|
173
|
+
def export_csv?
|
|
174
|
+
false
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Returns the attributes included in an export.
|
|
178
|
+
# @return [Array<Symbol>] defaults to the index columns.
|
|
179
|
+
def permitted_attributes_for_export
|
|
180
|
+
permitted_attributes_for_index
|
|
181
|
+
end
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`export_csv?` defaults to `false` — the explicit opt-in gate. `permitted_attributes_for_export`
|
|
185
|
+
is format-agnostic (named `_export`, not `_export_csv`, so a future XLSX/JSON export reuses the
|
|
186
|
+
same column set) and defaults to the index columns: "index columns by default, policy-overridable."
|
|
187
|
+
The controller always prepends `resource_class.primary_key` as the first column (de-duplicated),
|
|
188
|
+
so the id is exported even when the policy's attribute list omits it.
|
|
189
|
+
|
|
190
|
+
`csv` is declared as a gemspec dependency — it is no longer a default gem on Ruby 3.4+.
|
|
191
|
+
|
|
192
|
+
### 4. Custom export button (NOT a definition action)
|
|
193
|
+
|
|
194
|
+
A dedicated Phlex component, `lib/plutonium/ui/export_button.rb`:
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
class Plutonium::UI::ExportButton < Plutonium::UI::Component::Base
|
|
198
|
+
def initialize(url:)
|
|
199
|
+
@url = url
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def view_template
|
|
203
|
+
a(href: @url, target: "_blank", rel: "noopener", class: "pu-btn pu-btn-outline pu-btn-sm") do
|
|
204
|
+
render Phlex::TablerIcons::Download.new(class: "w-4 h-4 shrink-0")
|
|
205
|
+
span { "Export" }
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Rendered by `Plutonium::UI::Page::Index` via the existing `render_after_page_header` hook, gated on
|
|
212
|
+
the policy:
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
def render_after_page_header
|
|
216
|
+
return unless current_policy.allowed_to?(:export_csv?)
|
|
217
|
+
url = resource_url_for(resource_class, action: :export_csv, **export_query_params)
|
|
218
|
+
div(class: "flex justify-end mb-2") { render Plutonium::UI::ExportButton.new(url:) }
|
|
219
|
+
end
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`export_query_params` forwards the current `q` (filters/search/scope/sort params) so the export
|
|
223
|
+
matches what the user is looking at. The exact `resource_url_for` signature for appending `action:`
|
|
224
|
+
+ query params is verified against the existing `route_options_to_url`/`resource_url_for` usage
|
|
225
|
+
during implementation; the button URL is the only routing detail to confirm.
|
|
226
|
+
|
|
227
|
+
A custom button (rather than a definition action) is the right call because export is not a normal
|
|
228
|
+
navigable action: it streams a file, must open in a new tab, must not be Turbo-driven, and is
|
|
229
|
+
inherently collection-level. Keeping it out of the action DSL avoids bending that DSL around those
|
|
230
|
+
quirks.
|
|
231
|
+
|
|
232
|
+
### 5. Definition DSL — `export` (parallels `display`/`column`)
|
|
233
|
+
|
|
234
|
+
`lib/plutonium/definition/base.rb`: add `:export` to the `defineable_props` list (alongside
|
|
235
|
+
`:field, :input, :display, :column`). Generates `export :name, **options, &block` and
|
|
236
|
+
`defined_exports`, exactly like `display`/`column`.
|
|
237
|
+
|
|
238
|
+
- `&block` — receives the record, returns the cell value (overrides the raw `public_send`).
|
|
239
|
+
- `label:` — overrides the column header (default `name.to_s.humanize`).
|
|
240
|
+
|
|
241
|
+
The `export` DSL **customizes output of columns**; the column *set* is driven by
|
|
242
|
+
`permitted_attributes_for_export`. An `export` entry for a name not in that set is simply unused;
|
|
243
|
+
conversely the policy method may list virtual/method names (like index columns can), with `export`
|
|
244
|
+
supplying their formatting.
|
|
245
|
+
|
|
246
|
+
## Data flow
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
[Export button on index] --target=_blank, ?q=current--> GET /posts/export_csv?q[...]
|
|
250
|
+
→ authorize_export_csv! (export_csv? — 403 if false)
|
|
251
|
+
→ exportable_attributes (policy.permitted_attributes_for_export)
|
|
252
|
+
→ filtered_resource_collection (current_authorized_scope + search/filter/scope; NOT paginated)
|
|
253
|
+
→ send_stream: header line, then find_each → one CSV line per record (PK order)
|
|
254
|
+
→ text/csv attachment "<plural>_<date>.csv", streamed, opens in new tab
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Row-level authorization is the scope itself (`current_authorized_scope`), so
|
|
258
|
+
`skip_verify_current_authorized_scope` is correct here — same as `typeahead`. Tenant/parent scoping
|
|
259
|
+
applies because `filtered_resource_collection` is reused unchanged.
|
|
260
|
+
|
|
261
|
+
## Error handling
|
|
262
|
+
|
|
263
|
+
- `export_csv?` false → `ActionPolicy::Unauthorized` (403); button is also hidden.
|
|
264
|
+
- Unknown column name in `permitted_attributes_for_export` with no `export` block →
|
|
265
|
+
`NoMethodError` from `public_send` — a definition/policy authoring error that should surface
|
|
266
|
+
loudly (same as an unknown index column).
|
|
267
|
+
- An error raised mid-stream (after headers are sent) cannot un-send the 200/headers — the
|
|
268
|
+
download ends up truncated. Acceptable for v1; the column-resolution paths above are simple.
|
|
269
|
+
|
|
270
|
+
## Out of scope (YAGNI)
|
|
271
|
+
|
|
272
|
+
- Background-job / email export for very large tables — explicitly deferred (not designed now). The
|
|
273
|
+
`permitted_attributes_for_export` naming leaves the door open for an async path and other formats
|
|
274
|
+
later.
|
|
275
|
+
- A split "export current view / export all" button — there is one export: all rows matching the
|
|
276
|
+
current query.
|
|
277
|
+
- Preserving the index sort in the file (PK order; see tradeoff above).
|
|
278
|
+
- Per-row authorization beyond the scope filter; preload/N+1 handling for `export` blocks.
|
|
279
|
+
|
|
280
|
+
## Testing
|
|
281
|
+
|
|
282
|
+
Use a dummy-app resource (created via generators per project convention) with the policy enabled:
|
|
283
|
+
|
|
284
|
+
- Route exists: `GET /<resources>/export_csv` resolves.
|
|
285
|
+
- Disabled by default: with default policy, the button is hidden and the route 403s.
|
|
286
|
+
- Enabled: returns a streamed `text/csv` attachment with the right filename.
|
|
287
|
+
- Header row = humanized column names; `export :x, label:` overrides a header.
|
|
288
|
+
- Body rows = one per matching record; `export :x, &block` formats that cell.
|
|
289
|
+
- Respects query: `?q[...]` search/filter narrows the exported rows; pagination does NOT limit them.
|
|
290
|
+
- `permitted_attributes_for_export` override changes the column set.
|
|
291
|
+
- Tenant/parent scoping: export never leaks rows outside `current_authorized_scope`.
|
|
292
|
+
|
|
293
|
+
## Files
|
|
294
|
+
|
|
295
|
+
| Action | Path |
|
|
296
|
+
|---|---|
|
|
297
|
+
| Modify | `plutonium.gemspec` (add `csv` dependency) |
|
|
298
|
+
| Modify | `lib/plutonium/routing/mapper_extensions.rb` (add `define_collection_export_actions`, call it in concern) |
|
|
299
|
+
| Create | `lib/plutonium/resource/controllers/export_csv.rb` |
|
|
300
|
+
| Modify | `lib/plutonium/resource/controller.rb` (include the concern) |
|
|
301
|
+
| Modify | `lib/plutonium/resource/policy.rb` (`export_csv?`, `permitted_attributes_for_export`) |
|
|
302
|
+
| Create | `lib/plutonium/ui/export_button.rb` |
|
|
303
|
+
| Modify | `lib/plutonium/ui/page/index.rb` (`render_after_page_header` → policy-gated button) |
|
|
304
|
+
| Modify | `lib/plutonium/definition/base.rb` (add `:export` defineable prop) |
|
|
305
|
+
| Create | tests under `test/` + dummy-app resource |
|
|
306
|
+
| Modify | docs (`docs/reference/...`) + relevant `.claude/skills/` skill |
|
|
@@ -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,8 +1,9 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: ..
|
|
3
3
|
specs:
|
|
4
|
-
plutonium (0.
|
|
4
|
+
plutonium (0.59.0)
|
|
5
5
|
action_policy (~> 0.7.0)
|
|
6
|
+
csv
|
|
6
7
|
listen (~> 3.8)
|
|
7
8
|
pagy (~> 43.0)
|
|
8
9
|
phlex (~> 2.0)
|
|
@@ -138,6 +139,7 @@ GEM
|
|
|
138
139
|
concurrent-ruby (1.3.6)
|
|
139
140
|
connection_pool (3.0.2)
|
|
140
141
|
crass (1.0.6)
|
|
142
|
+
csv (3.3.5)
|
|
141
143
|
date (3.5.1)
|
|
142
144
|
drb (2.2.3)
|
|
143
145
|
erb (6.0.2)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: ..
|
|
3
3
|
specs:
|
|
4
|
-
plutonium (0.
|
|
4
|
+
plutonium (0.59.0)
|
|
5
5
|
action_policy (~> 0.7.0)
|
|
6
|
+
csv
|
|
6
7
|
listen (~> 3.8)
|
|
7
8
|
pagy (~> 43.0)
|
|
8
9
|
phlex (~> 2.0)
|
|
@@ -135,6 +136,7 @@ GEM
|
|
|
135
136
|
concurrent-ruby (1.3.6)
|
|
136
137
|
connection_pool (3.0.2)
|
|
137
138
|
crass (1.0.6)
|
|
139
|
+
csv (3.3.5)
|
|
138
140
|
date (3.5.1)
|
|
139
141
|
drb (2.2.3)
|
|
140
142
|
erb (6.0.2)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: ..
|
|
3
3
|
specs:
|
|
4
|
-
plutonium (0.
|
|
4
|
+
plutonium (0.59.0)
|
|
5
5
|
action_policy (~> 0.7.0)
|
|
6
|
+
csv
|
|
6
7
|
listen (~> 3.8)
|
|
7
8
|
pagy (~> 43.0)
|
|
8
9
|
phlex (~> 2.0)
|
|
@@ -137,6 +138,7 @@ GEM
|
|
|
137
138
|
concurrent-ruby (1.3.6)
|
|
138
139
|
connection_pool (3.0.2)
|
|
139
140
|
crass (1.0.6)
|
|
141
|
+
csv (3.3.5)
|
|
140
142
|
date (3.5.1)
|
|
141
143
|
drb (2.2.3)
|
|
142
144
|
erb (6.0.2)
|
|
@@ -43,7 +43,7 @@ module Pu
|
|
|
43
43
|
# frozen_string_literal: true
|
|
44
44
|
|
|
45
45
|
Rails.application.configure do
|
|
46
|
-
config.solid_errors.connects_to = {database: {writing: :#{@db_name}}}
|
|
46
|
+
config.solid_errors.connects_to = {database: {writing: :#{@db_name}, reading: :#{@db_name}}}
|
|
47
47
|
config.solid_errors.email_from = ENV["SOLID_ERRORS_EMAIL_FROM"].presence
|
|
48
48
|
config.solid_errors.email_to = ENV["SOLID_ERRORS_EMAIL_TO"].presence
|
|
49
49
|
# Only deliver notifications when explicitly opted in AND both addresses are
|
|
@@ -51,8 +51,8 @@ module Pu
|
|
|
51
51
|
# attempt to deliver malformed mail.
|
|
52
52
|
config.solid_errors.send_emails = ENV["SOLID_ERRORS_SEND_EMAILS"].present? &&
|
|
53
53
|
config.solid_errors.email_from.present? && config.solid_errors.email_to.present?
|
|
54
|
-
config.solid_errors.username = ENV
|
|
55
|
-
config.solid_errors.password = ENV
|
|
54
|
+
config.solid_errors.username = ENV["SOLID_ERRORS_USERNAME"]
|
|
55
|
+
config.solid_errors.password = ENV["SOLID_ERRORS_PASSWORD"]
|
|
56
56
|
end
|
|
57
57
|
RUBY
|
|
58
58
|
end
|