plutonium 0.58.1 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-auth/SKILL.md +7 -1
  3. data/.claude/skills/plutonium-behavior/SKILL.md +4 -0
  4. data/.claude/skills/plutonium-resource/SKILL.md +49 -0
  5. data/CHANGELOG.md +16 -0
  6. data/app/assets/plutonium.css +1 -1
  7. data/docs/.vitepress/config.ts +1 -0
  8. data/docs/reference/auth/accounts.md +7 -0
  9. data/docs/reference/resource/actions.md +3 -0
  10. data/docs/reference/resource/definition.md +129 -0
  11. data/docs/reference/resource/export.md +94 -0
  12. data/docs/reference/ui/forms.md +51 -21
  13. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +917 -0
  14. data/docs/superpowers/plans/2026-06-14-form-sectioning.md.tasks.json +40 -0
  15. data/docs/superpowers/specs/2026-06-12-export-csv-default-action-design.md +306 -0
  16. data/docs/superpowers/specs/2026-06-14-form-sectioning-design.md +237 -0
  17. data/gemfiles/rails_7.gemfile.lock +3 -1
  18. data/gemfiles/rails_8.0.gemfile.lock +3 -1
  19. data/gemfiles/rails_8.1.gemfile.lock +3 -1
  20. data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +3 -3
  21. data/lib/generators/pu/rodauth/admin_generator.rb +5 -2
  22. data/lib/generators/pu/rodauth/migration_generator.rb +1 -1
  23. data/lib/generators/pu/rodauth/templates/app/interactions/resend_admin_interaction.rb.tt +18 -0
  24. data/lib/generators/pu/rodauth/views_generator.rb +1 -1
  25. data/lib/plutonium/definition/base.rb +4 -0
  26. data/lib/plutonium/definition/form_layout.rb +144 -0
  27. data/lib/plutonium/interaction/base.rb +1 -0
  28. data/lib/plutonium/package/engine.rb +17 -7
  29. data/lib/plutonium/query/filter.rb +4 -1
  30. data/lib/plutonium/query/filters/association.rb +1 -2
  31. data/lib/plutonium/resource/controller.rb +1 -0
  32. data/lib/plutonium/resource/controllers/export_csv.rb +162 -0
  33. data/lib/plutonium/resource/controllers/queryable.rb +1 -0
  34. data/lib/plutonium/resource/policy.rb +21 -0
  35. data/lib/plutonium/routing/mapper_extensions.rb +13 -0
  36. data/lib/plutonium/ui/export_button.rb +86 -0
  37. data/lib/plutonium/ui/form/components/section.rb +58 -0
  38. data/lib/plutonium/ui/form/resource.rb +85 -7
  39. data/lib/plutonium/ui/table/components/toolbar.rb +9 -2
  40. data/lib/plutonium/ui/table/resource.rb +18 -1
  41. data/lib/plutonium/version.rb +1 -1
  42. data/package.json +1 -1
  43. data/plutonium.gemspec +1 -0
  44. data/src/css/slim_select.css +11 -2
  45. metadata +26 -2
@@ -95,13 +95,16 @@ module Pu
95
95
  def create_invite_interaction
96
96
  template "app/interactions/invite_admin_interaction.rb",
97
97
  "app/interactions/#{normalized_name}/invite_interaction.rb"
98
+ template "app/interactions/resend_admin_interaction.rb",
99
+ "app/interactions/#{normalized_name}/resend_invite_interaction.rb"
98
100
 
99
101
  inject_into_file "app/definitions/#{normalized_name}_definition.rb",
100
- " action :invite, interaction: #{name.classify}::InviteInteraction, collection: true, category: :primary\n",
102
+ " action :invite, interaction: #{name.classify}::InviteInteraction, collection: true, category: :primary\n" \
103
+ " action :resend_invite, interaction: #{name.classify}::ResendInviteInteraction, record_action: true, category: :secondary\n",
101
104
  after: /class #{name.classify}Definition < .+\n/
102
105
 
103
106
  inject_into_file "app/policies/#{normalized_name}_policy.rb",
104
- "def invite?\n true\n end\n\n ",
107
+ "def invite?\n true\n end\n\n def resend_invite?\n record.unverified?\n end\n\n ",
105
108
  before: "# Core attributes"
106
109
  end
107
110
 
@@ -20,7 +20,7 @@ module Pu
20
20
  desc "Generate migrations for supported features\n\n" \
21
21
  "Supported Features\n" \
22
22
  "=========================================\n" \
23
- "#{MIGRATION_CONFIG.keys.sort.map(&:to_s).join "\n"}\n\n\n\n"
23
+ "#{MIGRATION_CONFIG.keys.sort.join "\n"}\n\n\n\n"
24
24
 
25
25
  class_option :features, required: true, type: :array,
26
26
  desc: "Rodauth features to create tables for (otp, sms_codes, single_session, account_expiration etc.)"
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= name.classify %>::ResendInviteInteraction < Plutonium::Resource::Interaction
4
+ presents label: "Resend Invitation", icon: Phlex::TablerIcons::MailForward
5
+
6
+ attribute :resource
7
+
8
+ def execute
9
+ unless resource.unverified?
10
+ return failed("Can only resend invitations to unverified accounts")
11
+ end
12
+
13
+ RodauthApp.rodauth(:<%= normalized_name %>).verify_account_resend(login: resource.email)
14
+ succeed(resource).with_message("Invitation resent to #{resource.email}")
15
+ rescue ::Rodauth::InternalRequestError => e
16
+ failed(e.message)
17
+ end
18
+ end
@@ -13,7 +13,7 @@ module Pu
13
13
  desc "Generate views for selected features\n\n" \
14
14
  "Supported Features\n" \
15
15
  "=========================================\n" \
16
- "#{VIEW_CONFIG.keys.sort.map(&:to_s).join "\n"}\n\n\n\n"
16
+ "#{VIEW_CONFIG.keys.sort.join "\n"}\n\n\n\n"
17
17
 
18
18
  argument :plugin_name, type: :string, optional: true,
19
19
  desc: "[CONFIG] Name of the configured rodauth app. Leave blank to use the primary account."
@@ -34,6 +34,7 @@ module Plutonium
34
34
  include Search
35
35
  include NestedInputs
36
36
  include StructuredInputs
37
+ include FormLayout
37
38
  include IndexViews
38
39
  include Metadata
39
40
 
@@ -62,6 +63,9 @@ module Plutonium
62
63
  # fields
63
64
  defineable_props :field, :input, :display, :column
64
65
 
66
+ # export
67
+ defineable_prop :export
68
+
65
69
  # queries
66
70
  defineable_props :filter, :scope
67
71
 
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Definition
5
+ # Declarative form sectioning. Mixed into both resource definitions and
6
+ # interactions (mirrors StructuredInputs). The layout references field KEYS
7
+ # only and carries section-level options; per-field config stays on `input`.
8
+ #
9
+ # @example
10
+ # form_layout do
11
+ # section :identity, :name, :email, label: "Your identification"
12
+ # section :address, :street, :city, collapsible: true, columns: 2,
13
+ # condition: -> { object.requires_address? }
14
+ # ungrouped label: "Other"
15
+ # end
16
+ module FormLayout
17
+ extend ActiveSupport::Concern
18
+
19
+ UNGROUPED_KEY = :ungrouped
20
+
21
+ # One declared section, or the implicit `ungrouped` bucket (empty `fields`).
22
+ Section = Struct.new(:key, :fields, :options) do
23
+ def ungrouped? = key == UNGROUPED_KEY
24
+ def label = options[:label] || key.to_s.humanize
25
+ def description = options[:description]
26
+ def collapsible? = !!options[:collapsible]
27
+ def collapsed? = !!options[:collapsed]
28
+ def columns = options[:columns]
29
+ def condition = options[:condition]
30
+ end
31
+
32
+ # A section paired with the concrete fields it will render (after policy
33
+ # filtering). Produced by #resolve_form_sections (a later task).
34
+ ResolvedSection = Struct.new(:section, :fields)
35
+
36
+ # Collects section/ungrouped calls from a form_layout block in order.
37
+ class Builder
38
+ attr_reader :sections
39
+
40
+ def initialize
41
+ @sections = []
42
+ @ungrouped_seen = false
43
+ end
44
+
45
+ def section(key, *fields, **options)
46
+ if key == UNGROUPED_KEY
47
+ raise ArgumentError,
48
+ "`section :#{UNGROUPED_KEY}` is reserved — use the `ungrouped` macro"
49
+ end
50
+ validate_columns!(options)
51
+ @sections << Section.new(key:, fields: fields.freeze, options: options.freeze)
52
+ end
53
+
54
+ def ungrouped(**options)
55
+ raise ArgumentError, "`ungrouped` may only be declared once" if @ungrouped_seen
56
+ @ungrouped_seen = true
57
+ validate_columns!(options)
58
+ @sections << Section.new(key: UNGROUPED_KEY, fields: [].freeze, options: options.freeze)
59
+ end
60
+
61
+ private
62
+
63
+ def validate_columns!(options)
64
+ return unless options.key?(:columns)
65
+ value = options[:columns]
66
+ unless Integer === value && value > 0
67
+ raise ArgumentError,
68
+ "form_layout :columns must be a positive Integer, got #{value.inspect}"
69
+ end
70
+ end
71
+ end
72
+
73
+ class_methods do
74
+ # Declare the form layout. Re-declaring replaces it as a unit.
75
+ def form_layout(&block)
76
+ raise ArgumentError, "`form_layout` requires a block" unless block
77
+ builder = Builder.new
78
+ builder.instance_exec(&block)
79
+ @defined_form_layout = builder.sections.freeze
80
+ end
81
+
82
+ # Ordered Array<Section>, or nil when no layout was declared.
83
+ def defined_form_layout
84
+ @defined_form_layout
85
+ end
86
+
87
+ def inherited(subclass)
88
+ super
89
+ subclass.instance_variable_set(:@defined_form_layout, defined_form_layout&.dup)
90
+ end
91
+ end
92
+
93
+ # Instance access — the form render path holds a definition/interaction
94
+ # instance (mirrors the defineable_prop convention).
95
+ def defined_form_layout
96
+ self.class.defined_form_layout
97
+ end
98
+
99
+ # Resolve the policy-filtered field list into ordered ResolvedSections.
100
+ # Returns nil when no layout is declared (caller falls back to one grid).
101
+ def resolve_form_sections(resource_fields)
102
+ layout = defined_form_layout
103
+ return nil unless layout
104
+
105
+ resource_fields = resource_fields.map(&:to_sym)
106
+ known = resource_fields.to_set
107
+
108
+ # First-section-wins assignment: map each field to the first section key.
109
+ owner = {}
110
+ layout.each do |section|
111
+ next if section.ungrouped?
112
+ section.fields.each do |f|
113
+ unless known.include?(f)
114
+ raise ArgumentError,
115
+ "form_layout section :#{section.key} references unknown field :#{f}"
116
+ end
117
+ owner[f] ||= section.key
118
+ end
119
+ end
120
+ leftovers = resource_fields.reject { |f| owner.key?(f) }
121
+
122
+ resolved = layout.map do |section|
123
+ fields =
124
+ if section.ungrouped?
125
+ leftovers
126
+ else
127
+ section.fields.select { |f| owner[f] == section.key }
128
+ end
129
+ ResolvedSection.new(section:, fields:)
130
+ end
131
+
132
+ unless layout.any?(&:ungrouped?)
133
+ implicit = ResolvedSection.new(
134
+ section: Section.new(key: UNGROUPED_KEY, fields: [].freeze, options: {}.freeze),
135
+ fields: leftovers
136
+ )
137
+ resolved.push(implicit)
138
+ end
139
+
140
+ resolved
141
+ end
142
+ end
143
+ end
144
+ end
@@ -26,6 +26,7 @@ module Plutonium
26
26
  include Plutonium::Definition::ConfigAttr
27
27
  include Plutonium::Definition::Presentable
28
28
  include Plutonium::Definition::StructuredInputs
29
+ include Plutonium::Definition::FormLayout
29
30
 
30
31
  # On interactions, declaring a structured input also declares the backing
31
32
  # ActiveModel attribute so the value survives `attributes=` and appears in
@@ -4,14 +4,24 @@ module Plutonium
4
4
  extend ActiveSupport::Concern
5
5
 
6
6
  included do
7
- # prevent this package from being added to the view lookup
8
- # since we need finer control over how views are resolved.
9
- # view lookup configuration is handled at the controller level
10
- config.before_configuration do
11
- # this touches the internals of rails, but I could not find a good way of doing this
12
- # we get the initializer instance and set the block property to a noop
7
+ # Prevent this package's app/views from being appended to the global
8
+ # ActionController/ActionMailer view lookup Plutonium resolves package
9
+ # views at the controller level (see Plutonium::Core::Controllers::Bootable,
10
+ # which reads current_engine.paths["app/views"]). We neutralize the
11
+ # engine's built-in `add_view_paths` initializer rather than clearing
12
+ # config.paths["app/views"], which that controller-level resolver needs.
13
+ #
14
+ # This MUST run as a real initializer (before :add_view_paths), NOT in
15
+ # before_configuration: that hook can fire before sibling package engines
16
+ # are loaded (it does in development, where :before_configuration has
17
+ # already run by the time config/packages.rb loads). Touching
18
+ # Rails.application.initializers there forces Rails.application.railties
19
+ # to memoize early — with only the packages loaded so far — permanently
20
+ # dropping the rest from the autoload paths (e.g. `uninitialized constant
21
+ # Blogging::Post`). By initializer-run time, railties is fully populated.
22
+ initializer :plutonium_neutralize_add_view_paths, before: :add_view_paths do
13
23
  add_view_paths_initializer = Rails.application.initializers.find do |a|
14
- a.context_class == self && a.name.to_s == "add_view_paths"
24
+ a.context_class == self.class && a.name.to_s == "add_view_paths"
15
25
  end
16
26
  add_view_paths_initializer&.instance_variable_set(:@block, ->(app) {})
17
27
  end
@@ -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