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 +4 -4
- data/.claude/skills/plutonium-behavior/SKILL.md +4 -0
- data/.claude/skills/plutonium-resource/SKILL.md +49 -0
- data/CHANGELOG.md +10 -0
- data/docs/.vitepress/config.ts +1 -0
- data/docs/reference/resource/actions.md +3 -0
- data/docs/reference/resource/export.md +94 -0
- data/docs/superpowers/specs/2026-06-12-export-csv-default-action-design.md +306 -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/plutonium/definition/base.rb +3 -0
- 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/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
- metadata +20 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dfbb1eb8e3b796aa9ef55595aa4fc0a475f4a5f084388f0aebd4bf76eec468ae
|
|
4
|
+
data.tar.gz: 8b15084a3c2a0ef577cd7b88fe2ebc1666d274fd0effa28227c674c722a16b09
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/docs/.vitepress/config.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
@@ -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,
|
|
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
|
data/lib/plutonium/version.rb
CHANGED
data/package.json
CHANGED
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.
|
|
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
|
+
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
|