plutonium 0.58.1 → 0.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-auth/SKILL.md +7 -1
- data/.claude/skills/plutonium-behavior/SKILL.md +4 -0
- data/.claude/skills/plutonium-resource/SKILL.md +49 -0
- data/CHANGELOG.md +16 -0
- data/app/assets/plutonium.css +1 -1
- data/docs/.vitepress/config.ts +1 -0
- data/docs/reference/auth/accounts.md +7 -0
- data/docs/reference/resource/actions.md +3 -0
- data/docs/reference/resource/definition.md +129 -0
- data/docs/reference/resource/export.md +94 -0
- data/docs/reference/ui/forms.md +51 -21
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +917 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md.tasks.json +40 -0
- data/docs/superpowers/specs/2026-06-12-export-csv-default-action-design.md +306 -0
- data/docs/superpowers/specs/2026-06-14-form-sectioning-design.md +237 -0
- data/gemfiles/rails_7.gemfile.lock +3 -1
- data/gemfiles/rails_8.0.gemfile.lock +3 -1
- data/gemfiles/rails_8.1.gemfile.lock +3 -1
- data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +3 -3
- data/lib/generators/pu/rodauth/admin_generator.rb +5 -2
- data/lib/generators/pu/rodauth/migration_generator.rb +1 -1
- data/lib/generators/pu/rodauth/templates/app/interactions/resend_admin_interaction.rb.tt +18 -0
- data/lib/generators/pu/rodauth/views_generator.rb +1 -1
- data/lib/plutonium/definition/base.rb +4 -0
- data/lib/plutonium/definition/form_layout.rb +144 -0
- data/lib/plutonium/interaction/base.rb +1 -0
- data/lib/plutonium/package/engine.rb +17 -7
- data/lib/plutonium/query/filter.rb +4 -1
- data/lib/plutonium/query/filters/association.rb +1 -2
- data/lib/plutonium/resource/controller.rb +1 -0
- data/lib/plutonium/resource/controllers/export_csv.rb +162 -0
- data/lib/plutonium/resource/controllers/queryable.rb +1 -0
- data/lib/plutonium/resource/policy.rb +21 -0
- data/lib/plutonium/routing/mapper_extensions.rb +13 -0
- data/lib/plutonium/ui/export_button.rb +86 -0
- data/lib/plutonium/ui/form/components/section.rb +58 -0
- data/lib/plutonium/ui/form/resource.rb +85 -7
- data/lib/plutonium/ui/table/components/toolbar.rb +9 -2
- data/lib/plutonium/ui/table/resource.rb +18 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +1 -0
- data/src/css/slim_select.css +11 -2
- metadata +26 -2
|
@@ -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.
|
|
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.
|
|
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
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
@@ -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
|