plutonium 0.58.0 → 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 +15 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +7 -2
- data/app/assets/plutonium.js.map +2 -2
- data/app/assets/plutonium.min.js +1 -1
- data/app/assets/plutonium.min.js.map +2 -2
- 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/layout/base.rb +10 -11
- data/lib/plutonium/ui/layout/resource_layout.rb +18 -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
- data/src/css/components.css +11 -11
- data/src/js/controllers/icon_rail_controller.js +11 -2
- metadata +20 -2
|
@@ -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
|
|
@@ -44,12 +44,10 @@ module Plutonium
|
|
|
44
44
|
# preferences read from localStorage:
|
|
45
45
|
# - Color mode: applies `dark` class on <html> so dark theme renders
|
|
46
46
|
# from the first frame instead of flashing light.
|
|
47
|
-
# - Rail-pin:
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
# after a form submit) doesn't flash the rail into its collapsed
|
|
52
|
-
# state before the icon-rail Stimulus controller can restore it.
|
|
47
|
+
# - Rail-pin: a turbo:before-render listener adds/removes `pu-rail-pinned`
|
|
48
|
+
# on <html> based on whether the incoming body contains an icon-rail,
|
|
49
|
+
# preventing layout shift on Turbo navigations between rail and non-rail
|
|
50
|
+
# pages. Initial-load rail-pinned is handled by ResourceLayout.
|
|
53
51
|
def render_pre_paint_scripts
|
|
54
52
|
script do
|
|
55
53
|
raw(safe(<<~JS))
|
|
@@ -63,11 +61,12 @@ module Plutonium
|
|
|
63
61
|
} catch (e) {}
|
|
64
62
|
|
|
65
63
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
document.addEventListener("turbo:before-render", function (e) {
|
|
65
|
+
var hasRail = !!e.detail.newBody.querySelector('[data-controller~="icon-rail"]');
|
|
66
|
+
if (hasRail && localStorage.getItem("pu_rail_pinned") !== "false") {
|
|
67
|
+
document.documentElement.classList.add("pu-rail-pinned");
|
|
68
|
+
} else {
|
|
69
|
+
document.documentElement.classList.remove("pu-rail-pinned");
|
|
71
70
|
}
|
|
72
71
|
});
|
|
73
72
|
} catch (e) {}
|
|
@@ -4,6 +4,24 @@ module Plutonium
|
|
|
4
4
|
class ResourceLayout < Base
|
|
5
5
|
private
|
|
6
6
|
|
|
7
|
+
# Sets pu-rail-pinned immediately on initial page load so the rail
|
|
8
|
+
# renders in its pinned state from the first frame. Turbo navigations
|
|
9
|
+
# are handled by the turbo:before-render listener in Base.
|
|
10
|
+
def render_pre_paint_scripts
|
|
11
|
+
super
|
|
12
|
+
script do
|
|
13
|
+
raw(safe(<<~JS))
|
|
14
|
+
(function () {
|
|
15
|
+
try {
|
|
16
|
+
if (localStorage.getItem("pu_rail_pinned") !== "false") {
|
|
17
|
+
document.documentElement.classList.add("pu-rail-pinned");
|
|
18
|
+
}
|
|
19
|
+
} catch (e) {}
|
|
20
|
+
})();
|
|
21
|
+
JS
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
7
25
|
def main_attributes
|
|
8
26
|
classes = case Plutonium.configuration.shell
|
|
9
27
|
when :modern
|
|
@@ -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
|
data/src/css/components.css
CHANGED
|
@@ -689,16 +689,16 @@ aside[data-controller~="icon-rail"] {
|
|
|
689
689
|
}
|
|
690
690
|
|
|
691
691
|
/* Pinned mode: rail expands, labels appear */
|
|
692
|
-
|
|
692
|
+
html.pu-rail-pinned aside[data-controller~="icon-rail"] {
|
|
693
693
|
width: 14rem !important; /* 224px */
|
|
694
694
|
}
|
|
695
695
|
|
|
696
|
-
|
|
696
|
+
html.pu-rail-pinned .icon-rail-label {
|
|
697
697
|
display: inline-flex !important;
|
|
698
698
|
}
|
|
699
699
|
|
|
700
|
-
|
|
701
|
-
|
|
700
|
+
html.pu-rail-pinned .icon-rail-leaf,
|
|
701
|
+
html.pu-rail-pinned .icon-rail-parent-trigger {
|
|
702
702
|
width: 100%;
|
|
703
703
|
justify-content: flex-start;
|
|
704
704
|
gap: 8px;
|
|
@@ -707,7 +707,7 @@ body.pu-rail-pinned .icon-rail-parent-trigger {
|
|
|
707
707
|
|
|
708
708
|
/* When pinned, chevron flows inline after the label (right-aligned)
|
|
709
709
|
and rotates to point down — mirrors a typical disclosure caret. */
|
|
710
|
-
|
|
710
|
+
html.pu-rail-pinned .icon-rail-chevron {
|
|
711
711
|
position: static;
|
|
712
712
|
background: transparent;
|
|
713
713
|
box-shadow: none;
|
|
@@ -720,7 +720,7 @@ body.pu-rail-pinned .icon-rail-chevron {
|
|
|
720
720
|
}
|
|
721
721
|
|
|
722
722
|
/* When pinned, items stretch full width so labels read left-to-right */
|
|
723
|
-
|
|
723
|
+
html.pu-rail-pinned #sidebar-navigation-content {
|
|
724
724
|
align-items: stretch;
|
|
725
725
|
padding-left: 8px;
|
|
726
726
|
padding-right: 8px;
|
|
@@ -728,31 +728,31 @@ body.pu-rail-pinned #sidebar-navigation-content {
|
|
|
728
728
|
|
|
729
729
|
/* When pinned, brand area aligns to the start so it reads as part of the
|
|
730
730
|
left-aligned column instead of staying centered while items go left. */
|
|
731
|
-
|
|
731
|
+
html.pu-rail-pinned aside[data-controller~="icon-rail"] > div:first-child {
|
|
732
732
|
justify-content: flex-start;
|
|
733
733
|
padding-left: 14px;
|
|
734
734
|
}
|
|
735
735
|
|
|
736
736
|
/* When pinned, pin button right-aligns to anchor the collapse affordance
|
|
737
737
|
to the rail edge it'll snap toward. */
|
|
738
|
-
|
|
738
|
+
html.pu-rail-pinned aside[data-controller~="icon-rail"] > div:last-child {
|
|
739
739
|
justify-content: flex-end;
|
|
740
740
|
padding-right: 8px;
|
|
741
741
|
}
|
|
742
742
|
|
|
743
743
|
/* Pin button icon swap */
|
|
744
|
-
|
|
744
|
+
html.pu-rail-pinned .icon-rail-pin-collapse {
|
|
745
745
|
display: inline-flex !important;
|
|
746
746
|
}
|
|
747
747
|
|
|
748
|
-
|
|
748
|
+
html.pu-rail-pinned .icon-rail-pin-expand {
|
|
749
749
|
display: none !important;
|
|
750
750
|
}
|
|
751
751
|
|
|
752
752
|
/* Main content padding when pinned: rail width + 1.5rem breathing room
|
|
753
753
|
(matches the collapsed-state lg:pl-20 = rail 3.5rem + 1.5rem gap). */
|
|
754
754
|
@media (min-width: 1024px) {
|
|
755
|
-
|
|
755
|
+
html.pu-rail-pinned main {
|
|
756
756
|
padding-left: 15.5rem !important;
|
|
757
757
|
}
|
|
758
758
|
}
|
|
@@ -11,11 +11,20 @@ export default class extends Controller {
|
|
|
11
11
|
connect() {
|
|
12
12
|
// Pinned is the default; only an explicit "false" collapses the rail.
|
|
13
13
|
const pinned = localStorage.getItem(this.storageKeyValue) !== "false"
|
|
14
|
-
document.
|
|
14
|
+
document.documentElement.classList.toggle("pu-rail-pinned", pinned)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
disconnect() {
|
|
18
|
+
// Guard: if another icon-rail is already in the DOM (Turbo swapped to a
|
|
19
|
+
// page that also has a rail), leave the class alone — the new controller's
|
|
20
|
+
// connect() will assert the correct value immediately after.
|
|
21
|
+
if (!document.querySelector('[data-controller~="icon-rail"]')) {
|
|
22
|
+
document.documentElement.classList.remove("pu-rail-pinned")
|
|
23
|
+
}
|
|
15
24
|
}
|
|
16
25
|
|
|
17
26
|
togglePin() {
|
|
18
|
-
const pinned = document.
|
|
27
|
+
const pinned = document.documentElement.classList.toggle("pu-rail-pinned")
|
|
19
28
|
localStorage.setItem(this.storageKeyValue, pinned)
|
|
20
29
|
}
|
|
21
30
|
}
|
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
|