plutonium 0.58.1 → 0.59.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4aacffd8e85ac820827b65cc219e00cded934d37d879756ea808a8f9ba86fc26
4
- data.tar.gz: bc6d63a46c46f0f01b2351590b36ee54108a29372c4f9aa1d0d3922ab014f334
3
+ metadata.gz: dfbb1eb8e3b796aa9ef55595aa4fc0a475f4a5f084388f0aebd4bf76eec468ae
4
+ data.tar.gz: 8b15084a3c2a0ef577cd7b88fe2ebc1666d274fd0effa28227c674c722a16b09
5
5
  SHA512:
6
- metadata.gz: d29f628a11fdff4163c3430d225912902d07481fef1bc23bdacb23390cda5a063a604a3432929938a1c37bf7afc04aea643f1c0400d99478be62314d3ec8be89
7
- data.tar.gz: 52b66cfe37dde6f57dcb2cda8d8ab9369e585d0f8847d4af0e35b78f3af4bd5bad20a6c0de2840c872bc37fe34b45343538001308bd822c5f3bbc399b44e2b1b
6
+ metadata.gz: e75d7e2efadc2c1ff0bb2d57d979604fff1e7ffef6c8ecbb7635f4aeafb13716852932bfd439bc444f79404f4cf106ee203dc0d525c06b5d9d13b6536c8f82fb
7
+ data.tar.gz: 548b3f3b61d032c540847982e31e7734aed74b47b4cdce1867207c49bba7b3981e0de367955d3eed2d74a679187238deb8226913b396d6714002ba6dc42b7757
@@ -375,6 +375,9 @@ end
375
375
  | `new?` | `create?` | Rarely needed |
376
376
  | `edit?` | `update?` | Rarely needed |
377
377
  | `search?` | `index?` | Search-specific rules |
378
+ | `typeahead?` | `index?` | Autocomplete-specific rules |
379
+
380
+ `export_csv?` is the exception — it defaults to `false` (not derived) so CSV export is strictly opt-in. Override it to `true` (or `index?`) to enable the built-in export. The exported column set is `permitted_attributes_for_export` (defaults to `permitted_attributes_for_index`). See [[plutonium-resource]] → CSV Export.
378
381
 
379
382
  ### Custom actions
380
383
 
@@ -423,6 +426,7 @@ end
423
426
  | `permitted_attributes_for_show` | `permitted_attributes_for_read` |
424
427
  | `permitted_attributes_for_new` | `permitted_attributes_for_create` |
425
428
  | `permitted_attributes_for_edit` | `permitted_attributes_for_update` |
429
+ | `permitted_attributes_for_export` | `permitted_attributes_for_index` (CSV export columns; primary key is always prepended) |
426
430
 
427
431
  ### Per-action override
428
432
 
@@ -1290,6 +1290,55 @@ end
1290
1290
  - **Immediate** — interaction has only `:resource` (or `:resources`) and no other inputs. Shows an auto-generated browser confirmation (`"#{label}?"`, e.g. `"Archive?"`) on click, then runs. Pass `confirmation: "Custom message"` to override, or `confirmation: false` to skip.
1291
1291
  - **Form** — interaction declares extra `attribute`/`input` beyond `:resource`/`:resources`. Renders a modal form first; no auto-confirmation (the form itself is the confirmation step).
1292
1292
 
1293
+ ## CSV Export (built-in)
1294
+
1295
+ Every resource has a streamed CSV export, **disabled by default**. It is not declared
1296
+ with `action :export_csv` — it's a policy-gated capability with its own split button.
1297
+ The route (`GET /<resources>/export_csv`) is auto-mounted; the button appears on the
1298
+ index page once the policy permits it. Enable it by overriding one policy method:
1299
+
1300
+ ```ruby
1301
+ class PostPolicy < ResourcePolicy
1302
+ def export_csv? = true # or `index?` to mirror list access
1303
+ end
1304
+ ```
1305
+
1306
+ **Two exports** (split button in the index toolbar, after Filter):
1307
+ - **Export** (primary) — the current view: selected scope + filters + search (the
1308
+ index's `?q`), all matching rows (not just the visible page). File: `posts_<date>.csv`.
1309
+ - **Export all** (dropdown) — the entire authorized scope, ignoring scope/filters/
1310
+ search (`?all=1`). File: `posts_all_<date>.csv`.
1311
+
1312
+ Both stream via `find_each` (memory-safe on large tables; primary-key order, so the
1313
+ file does not preserve the index sort).
1314
+
1315
+ - **Columns** = `permitted_attributes_for_export` (defaults to the index columns),
1316
+ with the **primary key always first**. Override to tailor:
1317
+
1318
+ ```ruby
1319
+ def permitted_attributes_for_export = [:title, :author, :total, :created_at]
1320
+ ```
1321
+
1322
+ - **Per-field output** — customize a cell's value and header in the definition with
1323
+ the `export` DSL (parallels `display`/`column`):
1324
+
1325
+ ```ruby
1326
+ class PostDefinition < ResourceDefinition
1327
+ export :author, label: "Author email", &->(post) { post.author.email }
1328
+ export :total, &->(post) { post.total.format }
1329
+ end
1330
+ ```
1331
+
1332
+ - **Without** an `export` block a column is read off the record: scalars as-is,
1333
+ associations as their `display_name_of` label (e.g. `User #5`, not `#<User:…>`). A
1334
+ computed/virtual column with no real method **needs** an `export` block (a `label:`-only
1335
+ `export` doesn't supply a value) — otherwise the cell renders `<<invalid column>>`.
1336
+ - **CSV/formula injection** is neutralized automatically (cells starting with `= + - @` or
1337
+ tab/CR get a leading `'`).
1338
+
1339
+ The button opens in a new tab (so the streamed download bypasses Turbo). Full reference:
1340
+ `docs/reference/resource/export.md`.
1341
+
1293
1342
  ---
1294
1343
 
1295
1344
  ## Related Skills
data/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## [0.59.0] - 2026-06-13
2
+
3
+ ### 🚀 Features
4
+
5
+ - *(resource)* Add built-in policy-gated CSV export
6
+
7
+ ### 🐛 Bug Fixes
8
+
9
+ - *(generators)* Configure solid_errors reading connection and env lookup
10
+ - *(query)* Resolve association filter class via resource_class reflection
1
11
  ## [0.58.1] - 2026-06-10
2
12
 
3
13
  ### 🐛 Bug Fixes
@@ -131,6 +131,7 @@ export default defineConfig(withMermaid({
131
131
  { text: "Definition", link: "/reference/resource/definition" },
132
132
  { text: "Query", link: "/reference/resource/query" },
133
133
  { text: "Actions", link: "/reference/resource/actions" },
134
+ { text: "CSV Export", link: "/reference/resource/export" },
134
135
  ]
135
136
  },
136
137
  {
@@ -360,6 +360,9 @@ class PostDefinition < ResourceDefinition
360
360
  end
361
361
  ```
362
362
 
363
+ > **CSV export is not an action.** It's a built-in, policy-gated capability with its own
364
+ > button — see [CSV Export](./export.md). Don't declare it with `action :export_csv`.
365
+
363
366
  ## Interaction responses
364
367
 
365
368
  ```ruby
@@ -0,0 +1,94 @@
1
+ # CSV Export
2
+
3
+ Every resource ships with a streamed CSV export, **disabled by default**. It is *not* an
4
+ [action](./actions.md) — it streams a file and opens in a new tab — so it is enabled
5
+ through the policy rather than declared with `action :export_csv`. The route
6
+ (`GET /<resources>/export_csv`) is auto-mounted on every resource; a split "Export"
7
+ button appears on the index page once the policy permits it.
8
+
9
+ ## Enabling
10
+
11
+ Override one policy method:
12
+
13
+ ```ruby
14
+ class PostPolicy < ResourcePolicy
15
+ def export_csv? = true # or `index?` to mirror list access
16
+ end
17
+ ```
18
+
19
+ `export_csv?` defaults to `false`, so export is strictly opt-in. While it returns false
20
+ the button is hidden and the route returns `403`.
21
+
22
+ ## The two exports
23
+
24
+ The control is a split button with two behaviours:
25
+
26
+ | | Source | Filename |
27
+ |---|---|---|
28
+ | **Export** (primary) | The current view — selected scope + filters + search (the index's `?q`), **all** matching rows (not just the visible page) | `posts_<date>.csv` |
29
+ | **Export all** (dropdown) | The entire authorized scope — ignores scope, filters, search, and default scope | `posts_all_<date>.csv` |
30
+
31
+ "Export all" always exports everything the user is authorized to read, regardless of the
32
+ current scope/filters.
33
+
34
+ Both stream via `find_each`, so memory stays flat regardless of row count. `find_each`
35
+ iterates in **primary-key order**, so the file does not preserve the index's current
36
+ sort (filters/search/scope still apply to the primary export).
37
+
38
+ ## Columns
39
+
40
+ The exported columns come from `permitted_attributes_for_export` on the policy (defaults
41
+ to `permitted_attributes_for_index`), with the **primary key always prepended as the
42
+ first column**.
43
+
44
+ ```ruby
45
+ class PostPolicy < ResourcePolicy
46
+ def export_csv? = true
47
+ def permitted_attributes_for_export = [:title, :author, :total, :created_at]
48
+ end
49
+ ```
50
+
51
+ The method is named `_export` (not `_export_csv`) on purpose, so a future export format
52
+ could reuse the same column set.
53
+
54
+ ## Customizing a field's output
55
+
56
+ The `export` definition DSL parallels `display` and `column`. The block receives the
57
+ record and returns the cell value; `label:` overrides the header (default: the humanized
58
+ attribute name).
59
+
60
+ ```ruby
61
+ class PostDefinition < ResourceDefinition
62
+ export :author, label: "Author email", &->(post) { post.author.email }
63
+ export :total, &->(post) { post.total.format }
64
+ end
65
+ ```
66
+
67
+ The column *set* still comes from `permitted_attributes_for_export`; `export` only
68
+ customizes how a listed column is rendered.
69
+
70
+ ## Value resolution
71
+
72
+ For a column **without** an `export` block, the value is read straight off the record
73
+ (`record.public_send(name)`):
74
+
75
+ - **Scalars** (strings, numbers, booleans, dates) are written as-is.
76
+ - **Associations** render as their display label — the same `display_name_of` the index
77
+ uses (e.g. `User #5`, or the record's `to_label`/`name`/`title` if defined) — never
78
+ `#<User:0x…>`. Add an `export` block to export a specific field instead (e.g. the email).
79
+ - A name that is **neither** an `export` block **nor** a real method on the record renders
80
+ the placeholder `<<invalid column>>` rather than aborting the (already-streaming) download.
81
+ To export a computed or virtual column, give it an `export` block — a `label:`-only
82
+ `export` does **not** supply a value, so it too renders the placeholder.
83
+
84
+ ## Notes & limits
85
+
86
+ - Exports run **synchronously** and hold the request (and a DB connection) while
87
+ streaming. A background job that emails a download link is intentionally out of scope
88
+ for now.
89
+ - The export button opens in a new tab (`target="_blank"`), which also keeps Turbo from
90
+ intercepting the streamed download.
91
+ - **CSV/formula injection** is neutralized: any cell whose value begins with `=`, `+`,
92
+ `-`, `@`, or a leading tab/CR is prefixed with a single quote so spreadsheet apps import
93
+ it as literal text instead of executing it as a formula.
94
+ - `csv` is a runtime dependency of Plutonium (it is no longer a Ruby default gem on 3.4+).
@@ -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 |
@@ -1,8 +1,9 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.57.0)
4
+ plutonium (0.58.1)
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.57.0)
4
+ plutonium (0.58.1)
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.57.0)
4
+ plutonium (0.58.1)
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.fetch("SOLID_ERRORS_USERNAME", nil)
55
- config.solid_errors.password = ENV.fetch("SOLID_ERRORS_PASSWORD", nil)
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
@@ -62,6 +62,9 @@ module Plutonium
62
62
  # fields
63
63
  defineable_props :field, :input, :display, :column
64
64
 
65
+ # export
66
+ defineable_prop :export
67
+
65
68
  # queries
66
69
  defineable_props :filter, :scope
67
70
 
@@ -17,9 +17,12 @@ module Plutonium
17
17
  end
18
18
  end
19
19
 
20
- def initialize(key:)
20
+ attr_reader :resource_class
21
+
22
+ def initialize(key:, resource_class: nil)
21
23
  super()
22
24
  @key = key
25
+ @resource_class = resource_class
23
26
  end
24
27
  end
25
28
  end
@@ -16,10 +16,9 @@ module Plutonium
16
16
  # filter :user, with: :association, class_name: User, scope: ->(s) { s.active }
17
17
  #
18
18
  class Association < Filter
19
- def initialize(class_name: nil, resource_class: nil, scope: nil, multiple: true, **)
19
+ def initialize(class_name: nil, scope: nil, multiple: true, **)
20
20
  super(**)
21
21
  @class_name = class_name
22
- @resource_class = resource_class
23
22
  @scope_proc = scope
24
23
  @multiple = multiple
25
24
  end
@@ -16,6 +16,7 @@ module Plutonium
16
16
  include Plutonium::Resource::Controllers::CrudActions
17
17
  include Plutonium::Resource::Controllers::InteractiveActions
18
18
  include Plutonium::Resource::Controllers::Typeahead
19
+ include Plutonium::Resource::Controllers::ExportCsv
19
20
  include Plutonium::StructuredInputs::ParamsConcern
20
21
 
21
22
  included do
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Plutonium
6
+ module Resource
7
+ module Controllers
8
+ # Streams the current resource collection as a CSV download.
9
+ #
10
+ # Auto-mounted on every Plutonium resource via the
11
+ # `interactive_resource_actions` routing concern (see
12
+ # Plutonium::Routing::MapperExtensions). Gated by the `export_csv?`
13
+ # policy method, which defaults to `false` — export is strictly
14
+ # opt-in (enable it by overriding `export_csv?` to return true).
15
+ #
16
+ # The exported rows are exactly the index's filtered collection
17
+ # (`filtered_resource_collection`) — same search, filters, scope, and
18
+ # tenant/parent scoping — but NOT paginated: every matching record is
19
+ # exported. Rows are streamed (a lazy Enumerator body + `find_each`) so
20
+ # memory stays flat regardless of row count.
21
+ #
22
+ # Columns come from `policy.permitted_attributes_for_export` (defaults
23
+ # to the index columns), with the primary key always prepended as the
24
+ # first column. Per-field output and headers are customizable through
25
+ # the definition's `export` DSL.
26
+ #
27
+ # `find_each` iterates in primary-key order, so the file does not
28
+ # preserve the index's current sort (filters/search/scope still apply).
29
+ #
30
+ # Streaming uses a lazy Enumerator response body rather than
31
+ # `send_stream` — the latter lives in ActionController::Live, which
32
+ # would turn *every* resource action into a threaded streaming
33
+ # response. The Enumerator body streams through Rack on its own.
34
+ module ExportCsv
35
+ extend ActiveSupport::Concern
36
+
37
+ # Placeholder written when a column is neither an `export` block nor a
38
+ # real attribute on the record, so the export degrades to a usable file
39
+ # instead of a mid-stream NoMethodError (which would truncate the
40
+ # already-committed download).
41
+ INVALID_COLUMN = "<<invalid column>>"
42
+
43
+ included do
44
+ before_action :authorize_export_csv!, only: :export_csv
45
+ # Row-level authorization is the scope itself
46
+ # (current_authorized_scope via filtered_resource_collection), so
47
+ # the after_action scope verifier is redundant here.
48
+ skip_verify_current_authorized_scope only: :export_csv
49
+ end
50
+
51
+ # GET /<resources>/export_csv
52
+ def export_csv
53
+ response.headers["Content-Type"] = "text/csv; charset=utf-8"
54
+ response.headers["Content-Disposition"] =
55
+ ActionDispatch::Http::ContentDisposition.format(disposition: "attachment", filename: export_csv_filename)
56
+ # Defeat proxy/`Rack::ETag` buffering so rows flush as they're read.
57
+ response.headers["X-Accel-Buffering"] = "no"
58
+ response.headers["Cache-Control"] = "no-cache"
59
+
60
+ self.response_body = export_csv_lines
61
+ end
62
+
63
+ private
64
+
65
+ def authorize_export_csv!
66
+ authorize_current! resource_class, to: :export_csv?
67
+ end
68
+
69
+ def export_csv_filename
70
+ suffix = export_all_requested? ? "_all" : ""
71
+ "#{export_csv_basename}#{suffix}_#{Date.current}.csv"
72
+ end
73
+
74
+ # The human resource name, slugified for a filesystem-friendly file
75
+ # (Blogging::Post → "posts", not the route key "blogging_posts").
76
+ def export_csv_basename
77
+ helpers.resource_name_plural(resource_class).parameterize(separator: "_")
78
+ end
79
+
80
+ # Which records to export. Two modes:
81
+ # - default — the index's filtered collection (current scope,
82
+ # filters, and search via `?q`).
83
+ # - `?all=1` — the entire authorized scope, bypassing the query
84
+ # object entirely (no scope/filter/search/default-scope).
85
+ # Both still respect tenant/parent scoping (current_authorized_scope).
86
+ def export_csv_collection
87
+ export_all_requested? ? current_authorized_scope : filtered_resource_collection
88
+ end
89
+
90
+ def export_all_requested?
91
+ ActiveModel::Type::Boolean.new.cast(params[:all])
92
+ end
93
+
94
+ # A lazy line enumerator: the header row, then one CSV line per
95
+ # record streamed via `find_each` (bounded memory). Pure with
96
+ # respect to the response, so it's unit-testable on its own.
97
+ def export_csv_lines
98
+ columns = export_columns
99
+ Enumerator.new do |yielder|
100
+ yielder << export_csv_row(columns.map { |name| export_csv_header(name) })
101
+ export_csv_collection.find_each do |record|
102
+ yielder << export_csv_row(columns.map { |name| export_csv_value(record, name) })
103
+ end
104
+ end
105
+ end
106
+
107
+ # Serializes one row, neutralizing spreadsheet formula injection per cell.
108
+ def export_csv_row(cells)
109
+ CSV.generate_line(cells.map { |cell| neutralize_csv_formula(cell) })
110
+ end
111
+
112
+ # A cell beginning with = + - @ (or a leading tab/CR) is executed as a
113
+ # formula by Excel/Sheets. Prefix it with a single quote so the value
114
+ # imports as literal text (CSV/formula injection).
115
+ def neutralize_csv_formula(value)
116
+ string = value.to_s
117
+ /\A[=+\-@\t\r]/.match?(string) ? "'#{string}" : string
118
+ end
119
+
120
+ # The primary key is always the first column, followed by the
121
+ # policy's exportable attributes (de-duplicated so an explicitly
122
+ # listed primary key isn't repeated).
123
+ def export_columns
124
+ primary_key = resource_class.primary_key.to_sym
125
+ [primary_key] + (exportable_attributes.map(&:to_sym) - [primary_key])
126
+ end
127
+
128
+ def exportable_attributes
129
+ @exportable_attributes ||= current_policy.send_with_report(:permitted_attributes_for_export)
130
+ end
131
+
132
+ # Resolves a cell's value. An `export` block (definition DSL) takes
133
+ # precedence; otherwise the attribute is read off the record.
134
+ # Associations render as their display label — the same as the index —
135
+ # instead of "#<User:0x…>"; scalars pass through untouched. A name that
136
+ # is neither an `export` block nor a real attribute renders the
137
+ # INVALID_COLUMN placeholder rather than aborting the stream.
138
+ def export_csv_value(record, name)
139
+ definition = current_definition.defined_exports[name]
140
+ return definition[:block].call(record) if definition && definition[:block]
141
+
142
+ begin
143
+ value = record.public_send(name)
144
+ rescue NoMethodError
145
+ return INVALID_COLUMN
146
+ end
147
+
148
+ case value
149
+ when ActiveRecord::Base then helpers.display_name_of(value)
150
+ when ActiveRecord::Relation then helpers.display_name_of(value.to_a)
151
+ else value
152
+ end
153
+ end
154
+
155
+ def export_csv_header(name)
156
+ definition = current_definition.defined_exports[name]
157
+ definition&.dig(:options, :label) || name.to_s.humanize
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -40,6 +40,7 @@ module Plutonium
40
40
  filter_class = Plutonium::Query::Filter.lookup(with)
41
41
  options = value[:options].except(:with)
42
42
  options[:key] ||= key
43
+ options[:resource_class] ||= resource_class
43
44
  with = filter_class.new(**options)
44
45
  end
45
46
  query_object.define_filter key, with, &value[:block]
@@ -186,6 +186,16 @@ module Plutonium
186
186
  index?
187
187
  end
188
188
 
189
+ # Checks if CSV export is permitted.
190
+ #
191
+ # Defaults to false so export is strictly opt-in. Enable it per
192
+ # resource by overriding to return true (or delegating to index?).
193
+ #
194
+ # @return [Boolean] false by default.
195
+ def export_csv?
196
+ false
197
+ end
198
+
189
199
  # Core attributes
190
200
 
191
201
  # Returns the permitted attributes for the create action.
@@ -228,6 +238,17 @@ module Plutonium
228
238
  permitted_attributes_for_read
229
239
  end
230
240
 
241
+ # Returns the attributes included in an export (e.g. CSV columns).
242
+ #
243
+ # Format-agnostic on purpose (named `_export`, not `_export_csv`) so a
244
+ # future export format can reuse the same column set. Defaults to the
245
+ # index columns; override to tailor the exported columns.
246
+ #
247
+ # @return [Array<Symbol>] Delegates to permitted_attributes_for_index.
248
+ def permitted_attributes_for_export
249
+ permitted_attributes_for_index
250
+ end
251
+
231
252
  # Returns the permitted attributes for the new action.
232
253
  #
233
254
  # @return [Array<Symbol>] Delegates to permitted_attributes_for_create.
@@ -42,6 +42,7 @@ module Plutonium
42
42
  define_member_interactive_actions
43
43
  define_collection_interactive_actions
44
44
  define_collection_typeahead_actions
45
+ define_collection_export_actions
45
46
  end
46
47
  end
47
48
 
@@ -181,6 +182,18 @@ module Plutonium
181
182
  as: :typeahead_filter
182
183
  end
183
184
  end
185
+
186
+ # Defines the collection-level CSV export action. Auto-mounted on
187
+ # every Plutonium resource alongside typeahead and bulk actions.
188
+ # The action itself is gated by the `export_csv?` policy (default
189
+ # false), so the route is harmless until a resource opts in.
190
+ #
191
+ # @return [void]
192
+ def define_collection_export_actions
193
+ collection do
194
+ get "export_csv", action: :export_csv, as: :export_csv
195
+ end
196
+ end
184
197
  end
185
198
  end
186
199
  end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ # Connected split "Export" control for the index toolbar (sits beside
6
+ # the Filter button and shares its `pu-btn-outline pu-btn-sm` styling).
7
+ #
8
+ # The primary button exports the current view (selected scope + filters
9
+ # + search). The caret opens a menu with "Export all", which exports the
10
+ # entire authorized scope.
11
+ #
12
+ # Both links carry `target="_blank"`, which opens the streamed download
13
+ # in a new tab and bypasses Turbo (Turbo ignores links with a `target`).
14
+ #
15
+ # The two halves are joined into one control by flattening their shared
16
+ # inner corners via inline styles (`rounded-*-none` utilities aren't in
17
+ # the packaged stylesheet), so it reads as a single button rather than
18
+ # two pills.
19
+ class ExportButton < Plutonium::UI::Component::Base
20
+ # Inline corner/border tweaks that join the two halves seamlessly.
21
+ PRIMARY_STYLE = "border-top-right-radius:0;border-bottom-right-radius:0"
22
+ CARET_STYLE = "border-top-left-radius:0;border-bottom-left-radius:0;border-left-width:0;padding-left:0.375rem;padding-right:0.375rem"
23
+
24
+ def initialize(scoped_url:, all_url:)
25
+ @scoped_url = scoped_url
26
+ @all_url = all_url
27
+ end
28
+
29
+ def view_template
30
+ div(class: "relative inline-flex", data: {controller: "resource-drop-down"}) do
31
+ render_primary
32
+ render_caret
33
+ render_menu
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def render_primary
40
+ a(
41
+ href: @scoped_url,
42
+ target: "_blank",
43
+ rel: "noopener",
44
+ class: "pu-btn pu-btn-outline pu-btn-sm",
45
+ style: PRIMARY_STYLE
46
+ ) do
47
+ render Phlex::TablerIcons::Download.new(class: "w-4 h-4 shrink-0")
48
+ span { "Export" }
49
+ end
50
+ end
51
+
52
+ def render_caret
53
+ button(
54
+ type: "button",
55
+ class: "pu-btn pu-btn-outline pu-btn-sm",
56
+ style: CARET_STYLE,
57
+ aria: {expanded: "false", haspopup: "menu", label: "More export options"},
58
+ data: {resource_drop_down_target: "trigger"}
59
+ ) do
60
+ render Phlex::TablerIcons::ChevronDown.new(class: "w-4 h-4")
61
+ end
62
+ end
63
+
64
+ def render_menu
65
+ div(
66
+ class: "hidden absolute right-0 top-full z-50 mt-1 w-48 origin-top-right bg-[var(--pu-surface)] " \
67
+ "border border-[var(--pu-border)] rounded-[var(--pu-radius-lg)] overflow-hidden",
68
+ style: "box-shadow: var(--pu-shadow-lg)",
69
+ data: {resource_drop_down_target: "menu"}
70
+ ) do
71
+ div(class: "py-1") do
72
+ a(
73
+ href: @all_url,
74
+ target: "_blank",
75
+ rel: "noopener",
76
+ class: "flex items-center gap-2 px-4 py-2 text-sm text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] transition-colors"
77
+ ) do
78
+ render Phlex::TablerIcons::Download.new(class: "w-4 h-4")
79
+ span { "Export all" }
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -8,7 +8,7 @@ module Plutonium
8
8
  # inline search, and column config / overflow icon buttons into a single
9
9
  # tight strip rendered above the table when shell == :modern.
10
10
  class Toolbar < Plutonium::UI::Component::Base
11
- def initialize(query:, search_url:, search_param: :q, search_value: nil, views: [:table], current_view: :table, view_cookie_name: nil, view_cookie_path: "/")
11
+ def initialize(query:, search_url:, search_param: :q, search_value: nil, views: [:table], current_view: :table, view_cookie_name: nil, view_cookie_path: "/", export: nil)
12
12
  @query = query
13
13
  @search_url = search_url
14
14
  @search_param = search_param
@@ -17,10 +17,11 @@ module Plutonium
17
17
  @current_view = current_view
18
18
  @view_cookie_name = view_cookie_name
19
19
  @view_cookie_path = view_cookie_path
20
+ @export = export
20
21
  end
21
22
 
22
23
  def render?
23
- @views.size > 1 || has_filters? || has_search?
24
+ @views.size > 1 || has_filters? || has_search? || @export.present?
24
25
  end
25
26
 
26
27
  def view_template
@@ -29,11 +30,17 @@ module Plutonium
29
30
  render switcher
30
31
  render_divider if switcher.render?
31
32
  render_filter_button
33
+ render_export_button if @export
32
34
  div(class: "flex-1")
33
35
  render_search if has_search?
34
36
  end
35
37
  end
36
38
 
39
+ # Export split button, rendered just after the Filter button.
40
+ def render_export_button
41
+ render Plutonium::UI::ExportButton.new(**@export)
42
+ end
43
+
37
44
  private
38
45
 
39
46
  def has_filters?
@@ -47,10 +47,27 @@ module Plutonium
47
47
  views: resource_definition.defined_index_views,
48
48
  current_view: :table,
49
49
  view_cookie_name: Plutonium::UI::Page::Index.view_cookie_name(resource_class),
50
- view_cookie_path: Plutonium::UI::Page::Index.view_cookie_path(request)
50
+ view_cookie_path: Plutonium::UI::Page::Index.view_cookie_path(request),
51
+ export: export_toolbar_config
51
52
  )
52
53
  end
53
54
 
55
+ # Export split-button config, or nil when the policy forbids export.
56
+ # The primary link carries the current query (selected scope + filters
57
+ # + search); "Export all" carries `?all=1`.
58
+ def export_toolbar_config
59
+ return nil unless current_policy.allowed_to?(:export_csv?)
60
+
61
+ {
62
+ scoped_url: resource_url_for(resource_class, action: :export_csv, **export_query_params),
63
+ all_url: resource_url_for(resource_class, action: :export_csv, all: 1)
64
+ }
65
+ end
66
+
67
+ def export_query_params
68
+ params[:q].present? ? {q: params[:q].to_unsafe_h} : {}
69
+ end
70
+
54
71
  def render_filter_pills
55
72
  TableFilterPills(query: current_query_object, total_count: pagy_instance&.count)
56
73
  end
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.58.1"
2
+ VERSION = "0.59.0"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.58.1",
3
+ "version": "0.59.0",
4
4
  "description": "Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
data/plutonium.gemspec CHANGED
@@ -51,6 +51,7 @@ Gem::Specification.new do |spec|
51
51
 
52
52
  spec.add_dependency "zeitwerk"
53
53
  spec.add_dependency "rails", ">= 7.2"
54
+ spec.add_dependency "csv" # CSV export; no longer a default gem on Ruby 3.4+
54
55
  spec.add_dependency "listen", "~> 3.8"
55
56
  spec.add_dependency "pagy", "~> 43.0"
56
57
  spec.add_dependency "rabl", "~> 0.17.0" # TODO: what to do with RABL
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plutonium
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.58.1
4
+ version: 0.59.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-06-10 00:00:00.000000000 Z
10
+ date: 2026-06-13 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: zeitwerk
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '7.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: csv
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: listen
42
56
  requirement: !ruby/object:Gem::Requirement
@@ -641,6 +655,7 @@ files:
641
655
  - docs/reference/index.md
642
656
  - docs/reference/resource/actions.md
643
657
  - docs/reference/resource/definition.md
658
+ - docs/reference/resource/export.md
644
659
  - docs/reference/resource/index.md
645
660
  - docs/reference/resource/model.md
646
661
  - docs/reference/resource/query.md
@@ -680,6 +695,7 @@ files:
680
695
  - docs/superpowers/specs/2026-05-29-avatar-component-design.md
681
696
  - docs/superpowers/specs/2026-06-01-structured-inputs-design.md
682
697
  - docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md
698
+ - docs/superpowers/specs/2026-06-12-export-csv-default-action-design.md
683
699
  - esbuild.config.js
684
700
  - exe/pug
685
701
  - gemfiles/rails_7.gemfile
@@ -1045,6 +1061,7 @@ files:
1045
1061
  - lib/plutonium/resource/controllers/crud_actions.rb
1046
1062
  - lib/plutonium/resource/controllers/crud_actions/index_action.rb
1047
1063
  - lib/plutonium/resource/controllers/defineable.rb
1064
+ - lib/plutonium/resource/controllers/export_csv.rb
1048
1065
  - lib/plutonium/resource/controllers/interactive_actions.rb
1049
1066
  - lib/plutonium/resource/controllers/presentable.rb
1050
1067
  - lib/plutonium/resource/controllers/queryable.rb
@@ -1105,6 +1122,7 @@ files:
1105
1122
  - lib/plutonium/ui/dyna_frame/content.rb
1106
1123
  - lib/plutonium/ui/dyna_frame/host.rb
1107
1124
  - lib/plutonium/ui/empty_card.rb
1125
+ - lib/plutonium/ui/export_button.rb
1108
1126
  - lib/plutonium/ui/form/base.rb
1109
1127
  - lib/plutonium/ui/form/components/easymde.rb
1110
1128
  - lib/plutonium/ui/form/components/flatpickr.rb