plutonium 0.49.0 → 0.50.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-definition/SKILL.md +87 -2
- data/.claude/skills/plutonium-installation/SKILL.md +6 -0
- data/.claude/skills/plutonium-invites/SKILL.md +41 -0
- data/.claude/skills/plutonium-views/SKILL.md +59 -0
- data/CHANGELOG.md +27 -0
- data/app/assets/plutonium.css +2 -2
- data/app/assets/plutonium.js +404 -25
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +45 -45
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/plutonium/_flash.html.erb +1 -1
- data/app/views/plutonium/_resource_header.html.erb +4 -4
- data/app/views/plutonium/_resource_sidebar.html.erb +9 -9
- data/app/views/resource/_resource_grid.html.erb +1 -0
- data/config/brakeman.ignore +25 -2
- data/docs/guides/user-invites.md +64 -0
- data/docs/reference/definition/actions.md +14 -1
- data/docs/reference/definition/index.md +58 -0
- data/docs/reference/views/index.md +43 -0
- data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md +1487 -0
- data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md.tasks.json +15 -0
- data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md +841 -0
- data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json +103 -0
- data/docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md +270 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
- data/lib/generators/pu/core/update/update_generator.rb +20 -0
- data/lib/generators/pu/invites/install_generator.rb +136 -35
- data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +8 -2
- data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +7 -1
- data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +4 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +9 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +2 -2
- data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +1 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +8 -8
- data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +13 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +3 -3
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +1 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +1 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +2 -2
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +4 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +4 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +5 -1
- data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +54 -5
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +5 -1
- data/lib/plutonium/action/base.rb +44 -1
- data/lib/plutonium/action/interactive.rb +1 -1
- data/lib/plutonium/configuration.rb +4 -0
- data/lib/plutonium/definition/actions.rb +3 -0
- data/lib/plutonium/definition/base.rb +8 -0
- data/lib/plutonium/definition/metadata.rb +40 -0
- data/lib/plutonium/definition/views.rb +94 -0
- data/lib/plutonium/helpers/turbo_helper.rb +1 -1
- data/lib/plutonium/interaction/response/redirect.rb +1 -1
- data/lib/plutonium/invites/concerns/invite_token.rb +11 -3
- data/lib/plutonium/invites/concerns/invite_user.rb +13 -4
- data/lib/plutonium/invites/controller.rb +14 -1
- data/lib/plutonium/invites/pending_invite_check.rb +37 -28
- data/lib/plutonium/query/base.rb +8 -0
- data/lib/plutonium/query/filters/association.rb +30 -8
- data/lib/plutonium/query/filters/boolean.rb +5 -0
- data/lib/plutonium/resource/controllers/interactive_actions.rb +13 -9
- data/lib/plutonium/resource/controllers/presentable.rb +11 -2
- data/lib/plutonium/resource/definition.rb +42 -0
- data/lib/plutonium/resource/policy.rb +23 -8
- data/lib/plutonium/resource/query_object.rb +64 -6
- data/lib/plutonium/testing/resource_definition.rb +2 -2
- data/lib/plutonium/ui/action_button.rb +4 -2
- data/lib/plutonium/ui/component/kit.rb +12 -0
- data/lib/plutonium/ui/display/base.rb +3 -1
- data/lib/plutonium/ui/display/resource.rb +109 -25
- data/lib/plutonium/ui/display/theme.rb +2 -1
- data/lib/plutonium/ui/dyna_frame/content.rb +8 -14
- data/lib/plutonium/ui/empty_card.rb +1 -1
- data/lib/plutonium/ui/form/base.rb +29 -1
- data/lib/plutonium/ui/form/components/hidden_wrapper.rb +25 -0
- data/lib/plutonium/ui/form/components/resource_select.rb +79 -1
- data/lib/plutonium/ui/form/components/secure_association.rb +7 -2
- data/lib/plutonium/ui/form/components/sticky_footer.rb +17 -0
- data/lib/plutonium/ui/form/resource.rb +48 -9
- data/lib/plutonium/ui/form/theme.rb +1 -1
- data/lib/plutonium/ui/frame_navigator_panel.rb +7 -4
- data/lib/plutonium/ui/grid/card.rb +235 -0
- data/lib/plutonium/ui/grid/resource.rb +149 -0
- data/lib/plutonium/ui/layout/base.rb +37 -1
- data/lib/plutonium/ui/layout/header.rb +1 -2
- data/lib/plutonium/ui/layout/icon_rail.rb +212 -0
- data/lib/plutonium/ui/layout/resource_layout.rb +10 -3
- data/lib/plutonium/ui/layout/sidebar.rb +12 -24
- data/lib/plutonium/ui/layout/topbar.rb +100 -0
- data/lib/plutonium/ui/modal/base.rb +109 -0
- data/lib/plutonium/ui/modal/centered.rb +21 -0
- data/lib/plutonium/ui/modal/slideover.rb +26 -0
- data/lib/plutonium/ui/page/base.rb +25 -6
- data/lib/plutonium/ui/page/edit.rb +13 -1
- data/lib/plutonium/ui/page/index.rb +40 -1
- data/lib/plutonium/ui/page/interactive_action.rb +8 -39
- data/lib/plutonium/ui/page/new.rb +13 -1
- data/lib/plutonium/ui/page/show.rb +8 -1
- data/lib/plutonium/ui/page_header.rb +8 -13
- data/lib/plutonium/ui/panel.rb +10 -19
- data/lib/plutonium/ui/sidebar_menu.rb +2 -25
- data/lib/plutonium/ui/tab_list.rb +29 -7
- data/lib/plutonium/ui/table/base.rb +106 -0
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +12 -4
- data/lib/plutonium/ui/table/components/filter_form.rb +171 -0
- data/lib/plutonium/ui/table/components/filter_pills.rb +89 -0
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +13 -12
- data/lib/plutonium/ui/table/components/scopes_pills.rb +67 -0
- data/lib/plutonium/ui/table/components/selection_column.rb +2 -11
- data/lib/plutonium/ui/table/components/toolbar.rb +104 -0
- data/lib/plutonium/ui/table/components/view_switcher.rb +81 -0
- data/lib/plutonium/ui/table/resource.rb +158 -89
- data/lib/plutonium/ui/table/theme.rb +14 -5
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium.rb +6 -0
- data/package.json +1 -1
- data/src/css/components.css +304 -131
- data/src/css/tokens.css +101 -85
- data/src/js/controllers/autosubmit_controller.js +24 -0
- data/src/js/controllers/bulk_actions_controller.js +15 -16
- data/src/js/controllers/capture_url_controller.js +14 -0
- data/src/js/controllers/filter_panel_controller.js +77 -19
- data/src/js/controllers/flatpickr_controller.js +23 -0
- data/src/js/controllers/frame_navigator_controller.js +34 -6
- data/src/js/controllers/icon_rail_controller.js +22 -0
- data/src/js/controllers/icon_rail_flyout_controller.js +128 -0
- data/src/js/controllers/register_controllers.js +16 -0
- data/src/js/controllers/resource_tab_list_controller.js +56 -3
- data/src/js/controllers/row_click_controller.js +21 -0
- data/src/js/controllers/sidebar_controller.js +28 -1
- data/src/js/controllers/table_column_menu_controller.js +43 -0
- data/src/js/controllers/table_header_controller.js +16 -0
- data/src/js/controllers/view_switcher_controller.js +29 -0
- metadata +33 -3
|
@@ -29,7 +29,7 @@ module Plutonium
|
|
|
29
29
|
# GET /resources/1/record_actions/:interactive_action
|
|
30
30
|
def interactive_record_action
|
|
31
31
|
build_interactive_record_action_interaction
|
|
32
|
-
render :interactive_record_action,
|
|
32
|
+
render :interactive_record_action, formats: [:html], **modal_render_options
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
# POST /resources/1/record_actions/:interactive_action
|
|
@@ -62,7 +62,7 @@ module Plutonium
|
|
|
62
62
|
end
|
|
63
63
|
else
|
|
64
64
|
format.any(:html, :turbo_stream) do
|
|
65
|
-
render :interactive_record_action,
|
|
65
|
+
render :interactive_record_action, formats: [:html], content_type: "text/html", **modal_render_options, status: :unprocessable_content
|
|
66
66
|
end
|
|
67
67
|
format.any do
|
|
68
68
|
@errors = @interaction.errors
|
|
@@ -77,7 +77,7 @@ module Plutonium
|
|
|
77
77
|
def interactive_resource_action
|
|
78
78
|
skip_verify_current_authorized_scope!
|
|
79
79
|
build_interactive_resource_action_interaction
|
|
80
|
-
render :interactive_resource_action,
|
|
80
|
+
render :interactive_resource_action, formats: [:html], **modal_render_options
|
|
81
81
|
end
|
|
82
82
|
|
|
83
83
|
# POST /resources/resource_actions/:interactive_action
|
|
@@ -111,7 +111,7 @@ module Plutonium
|
|
|
111
111
|
end
|
|
112
112
|
else
|
|
113
113
|
format.any(:html, :turbo_stream) do
|
|
114
|
-
render :interactive_resource_action,
|
|
114
|
+
render :interactive_resource_action, formats: [:html], content_type: "text/html", **modal_render_options, status: :unprocessable_content
|
|
115
115
|
end
|
|
116
116
|
format.any do
|
|
117
117
|
@errors = @interaction.errors
|
|
@@ -125,7 +125,7 @@ module Plutonium
|
|
|
125
125
|
# GET /resources/bulk_actions/:interactive_action?ids[]=1&ids[]=2
|
|
126
126
|
def interactive_bulk_action
|
|
127
127
|
build_interactive_bulk_action_interaction
|
|
128
|
-
render :interactive_bulk_action,
|
|
128
|
+
render :interactive_bulk_action, formats: [:html], **modal_render_options
|
|
129
129
|
end
|
|
130
130
|
|
|
131
131
|
# POST /resources/bulk_actions/:interactive_action?ids[]=1&ids[]=2
|
|
@@ -158,7 +158,7 @@ module Plutonium
|
|
|
158
158
|
end
|
|
159
159
|
else
|
|
160
160
|
format.any(:html, :turbo_stream) do
|
|
161
|
-
render :interactive_bulk_action,
|
|
161
|
+
render :interactive_bulk_action, formats: [:html], content_type: "text/html", **modal_render_options, status: :unprocessable_content
|
|
162
162
|
end
|
|
163
163
|
format.any do
|
|
164
164
|
@errors = @interaction.errors
|
|
@@ -171,9 +171,13 @@ module Plutonium
|
|
|
171
171
|
|
|
172
172
|
private
|
|
173
173
|
|
|
174
|
-
#
|
|
175
|
-
|
|
176
|
-
|
|
174
|
+
# Render options for modal-aware actions. Returns `{ layout: false }` for
|
|
175
|
+
# turbo-frame requests so the bare frame is rendered, and an empty hash
|
|
176
|
+
# for top-level requests so the controller's default layout proc applies.
|
|
177
|
+
# (Passing `layout: nil` explicitly is treated as "no layout" by Rails,
|
|
178
|
+
# which is why we omit the key entirely on the default path.)
|
|
179
|
+
def modal_render_options
|
|
180
|
+
helpers.current_turbo_frame.present? ? {layout: false} : {}
|
|
177
181
|
end
|
|
178
182
|
|
|
179
183
|
def current_interactive_action
|
|
@@ -5,7 +5,7 @@ module Plutonium
|
|
|
5
5
|
extend ActiveSupport::Concern
|
|
6
6
|
|
|
7
7
|
included do
|
|
8
|
-
helper_method :build_form, :build_detail, :build_collection
|
|
8
|
+
helper_method :build_form, :build_detail, :build_collection, :build_grid_collection
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
private
|
|
@@ -82,8 +82,17 @@ module Plutonium
|
|
|
82
82
|
current_definition.collection_class.new(@resource_records, resource_fields: presentable_attributes, resource_definition: current_definition)
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
def build_grid_collection
|
|
86
|
+
current_definition.grid_class.new(@resource_records, resource_fields: presentable_attributes, resource_definition: current_definition)
|
|
87
|
+
end
|
|
88
|
+
|
|
85
89
|
def build_detail
|
|
86
|
-
current_definition.detail_class.new(
|
|
90
|
+
current_definition.detail_class.new(
|
|
91
|
+
resource_record!,
|
|
92
|
+
resource_fields: presentable_attributes,
|
|
93
|
+
resource_associations: permitted_associations,
|
|
94
|
+
resource_definition: current_definition
|
|
95
|
+
)
|
|
87
96
|
end
|
|
88
97
|
|
|
89
98
|
def build_form(record = resource_record!, action: action_name, form_action: nil, **)
|
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
module Plutonium
|
|
2
2
|
module Resource
|
|
3
3
|
class Definition < Plutonium::Definition::Base
|
|
4
|
+
class_attribute :modal_mode, default: :slideover, instance_accessor: false
|
|
5
|
+
|
|
6
|
+
VALID_MODAL_MODES = [:centered, :slideover, false].freeze
|
|
7
|
+
|
|
8
|
+
# Sets how :new / :edit actions render.
|
|
9
|
+
# - :slideover (default) — slide-in panel from the right
|
|
10
|
+
# - :centered — centered dialog
|
|
11
|
+
# - false — no modal; new/edit are full standalone pages
|
|
12
|
+
def self.modal(mode)
|
|
13
|
+
unless VALID_MODAL_MODES.include?(mode)
|
|
14
|
+
raise ArgumentError, "modal must be one of #{VALID_MODAL_MODES.inspect}, got #{mode.inspect}"
|
|
15
|
+
end
|
|
16
|
+
self.modal_mode = mode
|
|
17
|
+
configure_crud_modal_targets!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Re-derives the default :new / :edit actions so their turbo_frame
|
|
21
|
+
# matches the current `modal_mode`. Called when `.modal` is set
|
|
22
|
+
# and once at Resource::Definition load (so the default
|
|
23
|
+
# :slideover state propagates to the action records). Subclasses
|
|
24
|
+
# inherit those records via DefineableProps#inherited (deep_dup);
|
|
25
|
+
# calling `.modal` on a subclass re-runs this method locally.
|
|
26
|
+
def self.configure_crud_modal_targets!
|
|
27
|
+
target = (modal_mode == false) ? nil : Plutonium::REMOTE_MODAL_FRAME
|
|
28
|
+
[:new, :edit].each do |name|
|
|
29
|
+
action = defined_actions[name]
|
|
30
|
+
next unless action
|
|
31
|
+
next if action.turbo_frame == target
|
|
32
|
+
defined_actions[name] = action.with(turbo_frame: target)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def modal
|
|
37
|
+
self.class.modal_mode
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Apply the default modal target ("remote_modal") to :new / :edit
|
|
41
|
+
# so resources that never call `.modal` still get the slideover
|
|
42
|
+
# behavior. Subclasses inherit the configured actions via
|
|
43
|
+
# DefineableProps' deep_dup; calling `.modal` on a subclass
|
|
44
|
+
# re-runs the configuration locally.
|
|
45
|
+
configure_crud_modal_targets!
|
|
4
46
|
end
|
|
5
47
|
end
|
|
6
48
|
end
|
|
@@ -67,16 +67,31 @@ module Plutonium
|
|
|
67
67
|
raise ArgumentError, "parent and parent_association must both be provided together"
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
# Parent association scoping (nested routes)
|
|
71
|
-
#
|
|
72
|
-
#
|
|
70
|
+
# Parent association scoping (nested routes).
|
|
71
|
+
#
|
|
72
|
+
# The parent context is set on the policy for the whole request, so it
|
|
73
|
+
# leaks into sibling lookups too — e.g. a SecureAssociation field on
|
|
74
|
+
# the child's form authorizes an unrelated resource scope while
|
|
75
|
+
# parent/parent_association are still set. Only apply parent scoping
|
|
76
|
+
# when the relation actually corresponds to the parent's named
|
|
77
|
+
# association; otherwise fall through to entity scoping so we don't
|
|
78
|
+
# produce an incoherent (and silently empty) result.
|
|
73
79
|
assoc_reflection = parent.class.reflect_on_association(parent_association)
|
|
74
|
-
if assoc_reflection.
|
|
75
|
-
#
|
|
76
|
-
parent
|
|
80
|
+
if assoc_reflection && relation.klass <= assoc_reflection.klass
|
|
81
|
+
# The parent was already entity-scoped during authorization, so
|
|
82
|
+
# children accessed through the parent don't need additional
|
|
83
|
+
# entity scoping.
|
|
84
|
+
if assoc_reflection.collection?
|
|
85
|
+
# has_many: merge with the association's scope
|
|
86
|
+
parent.public_send(parent_association).merge(relation)
|
|
87
|
+
else
|
|
88
|
+
# has_one: scope by foreign key
|
|
89
|
+
relation.where(assoc_reflection.foreign_key => parent.id)
|
|
90
|
+
end
|
|
91
|
+
elsif entity_scope
|
|
92
|
+
relation.associated_with(entity_scope)
|
|
77
93
|
else
|
|
78
|
-
|
|
79
|
-
relation.where(assoc_reflection.foreign_key => parent.id)
|
|
94
|
+
relation
|
|
80
95
|
end
|
|
81
96
|
elsif entity_scope
|
|
82
97
|
# Entity scoping (multi-tenancy)
|
|
@@ -63,6 +63,7 @@ module Plutonium
|
|
|
63
63
|
# Builds a URL with the given options for search and sorting.
|
|
64
64
|
#
|
|
65
65
|
# @param options [Hash] The options for building the URL.
|
|
66
|
+
# @option options [Boolean] :replace When true, clears all existing sorts before applying the new one
|
|
66
67
|
# @return [String] The constructed URL with query parameters.
|
|
67
68
|
def build_url(**options)
|
|
68
69
|
q = {}
|
|
@@ -74,11 +75,19 @@ module Plutonium
|
|
|
74
75
|
selected_scope_filter
|
|
75
76
|
end
|
|
76
77
|
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
if options.delete(:replace)
|
|
79
|
+
q[:sort_directions] = {}
|
|
80
|
+
q[:sort_fields] = []
|
|
81
|
+
else
|
|
82
|
+
q[:sort_directions] = selected_sort_directions.dup
|
|
83
|
+
q[:sort_fields] = selected_sort_fields.dup
|
|
84
|
+
end
|
|
79
85
|
handle_sort_options!(q, options)
|
|
80
86
|
|
|
81
|
-
|
|
87
|
+
filter_keys = filter_definitions.keys.map(&:to_sym)
|
|
88
|
+
filter_overrides = options.slice(*filter_keys).stringify_keys
|
|
89
|
+
q.merge! params.with_indifferent_access.slice(*filter_definitions.keys)
|
|
90
|
+
q.merge!(filter_overrides)
|
|
82
91
|
compacted = deep_compact({q: q})
|
|
83
92
|
|
|
84
93
|
# Preserve explicit "All" selection (scope: nil in options means show all)
|
|
@@ -119,18 +128,67 @@ module Plutonium
|
|
|
119
128
|
|
|
120
129
|
def sort_definitions = @sort_definitions ||= {}.with_indifferent_access
|
|
121
130
|
|
|
131
|
+
# Returns an array of hashes describing each currently active filter.
|
|
132
|
+
# Each hash has: name, label, value_label, clear_url
|
|
133
|
+
def active_filter_descriptions
|
|
134
|
+
filter_definitions.filter_map do |name, filter|
|
|
135
|
+
name = name.to_sym
|
|
136
|
+
filter_params = params[name]
|
|
137
|
+
next unless filter_params.present?
|
|
138
|
+
|
|
139
|
+
value_label = case filter_params
|
|
140
|
+
when Hash, ActionController::Parameters
|
|
141
|
+
entries = filter_params.to_h.reject { |_, v| v.blank? }
|
|
142
|
+
next if entries.empty?
|
|
143
|
+
# Single-input filters defer to the filter's `humanize_value`
|
|
144
|
+
# (e.g. Association resolves ids to labels, Boolean translates
|
|
145
|
+
# "true" -> "Yes"). Multi-input filters keep input-name
|
|
146
|
+
# qualifiers (e.g. "from 2024, to 2025").
|
|
147
|
+
if entries.size == 1
|
|
148
|
+
humanized = filter.humanize_value(entries.values.first)
|
|
149
|
+
next if humanized.blank?
|
|
150
|
+
humanized
|
|
151
|
+
else
|
|
152
|
+
entries.map { |k, v| "#{k.to_s.humanize.downcase} #{v}" }.join(", ")
|
|
153
|
+
end
|
|
154
|
+
when Array
|
|
155
|
+
entries = filter_params.reject(&:blank?)
|
|
156
|
+
next if entries.empty?
|
|
157
|
+
humanized = filter.humanize_value(entries)
|
|
158
|
+
next if humanized.blank?
|
|
159
|
+
humanized
|
|
160
|
+
else
|
|
161
|
+
next if filter_params.to_s.blank?
|
|
162
|
+
humanized = filter.humanize_value(filter_params)
|
|
163
|
+
next if humanized.blank?
|
|
164
|
+
humanized
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
{
|
|
168
|
+
name: name,
|
|
169
|
+
label: name.to_s.humanize,
|
|
170
|
+
value_label: value_label,
|
|
171
|
+
clear_url: build_url(name => nil)
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
122
176
|
# Provides sorting parameters for the given field name.
|
|
123
177
|
#
|
|
124
178
|
# @param name [Symbol, String] The name of the field to sort.
|
|
125
|
-
# @return [Hash, nil] The sorting parameters including URL and
|
|
179
|
+
# @return [Hash, nil] The sorting parameters including URL, multi_url, direction, position and multi flag.
|
|
126
180
|
def sort_params_for(name)
|
|
127
181
|
return unless sort_definitions[name]
|
|
128
182
|
|
|
183
|
+
multi = selected_sort_fields.size > 1 && selected_sort_fields.include?(name.to_s)
|
|
184
|
+
|
|
129
185
|
{
|
|
130
|
-
url: build_url(sort: name),
|
|
186
|
+
url: build_url(sort: name, replace: true),
|
|
187
|
+
multi_url: build_url(sort: name),
|
|
131
188
|
reset_url: build_url(sort: name, reset: true),
|
|
132
189
|
position: selected_sort_fields.index(name.to_s),
|
|
133
|
-
direction: selected_sort_directions[name]
|
|
190
|
+
direction: selected_sort_directions[name],
|
|
191
|
+
multi: multi
|
|
134
192
|
}
|
|
135
193
|
end
|
|
136
194
|
|
|
@@ -33,8 +33,8 @@ module Plutonium
|
|
|
33
33
|
klass.defined_fields.each_key do |field_name|
|
|
34
34
|
next if field_name == :id
|
|
35
35
|
assert resource_class.column_names.include?(field_name.to_s) ||
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
resource_class.method_defined?(field_name) ||
|
|
37
|
+
resource_class.reflect_on_association(field_name),
|
|
38
38
|
"Field :#{field_name} declared in #{klass} but not defined on #{resource_class}"
|
|
39
39
|
end
|
|
40
40
|
end
|
|
@@ -24,10 +24,11 @@ module Plutonium
|
|
|
24
24
|
|
|
25
25
|
DROPDOWN_DEFAULT_COLOR = "text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)]"
|
|
26
26
|
|
|
27
|
-
def initialize(action, url:, variant: :default)
|
|
27
|
+
def initialize(action, url:, variant: :default, data: {})
|
|
28
28
|
@action = action
|
|
29
29
|
@url = url
|
|
30
30
|
@variant = variant
|
|
31
|
+
@extra_data = data
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
def view_template
|
|
@@ -49,7 +50,7 @@ module Plutonium
|
|
|
49
50
|
link_to(
|
|
50
51
|
url_with_return_to,
|
|
51
52
|
class: button_classes,
|
|
52
|
-
data: {turbo_frame: @action.turbo_frame}
|
|
53
|
+
data: {turbo_frame: @action.turbo_frame}.merge(@extra_data)
|
|
53
54
|
) do
|
|
54
55
|
render_button_content
|
|
55
56
|
end
|
|
@@ -61,6 +62,7 @@ module Plutonium
|
|
|
61
62
|
method: @action.route_options.method,
|
|
62
63
|
name: :return_to, value: return_to_url,
|
|
63
64
|
class: "inline-block",
|
|
65
|
+
data: @extra_data,
|
|
64
66
|
form: {
|
|
65
67
|
data: {
|
|
66
68
|
turbo: @action.turbo,
|
|
@@ -78,6 +78,8 @@ module Plutonium
|
|
|
78
78
|
|
|
79
79
|
def BuildTableScopesBar(...) = Plutonium::UI::Table::Components::ScopesBar.new(...)
|
|
80
80
|
|
|
81
|
+
def BuildTableScopesPills(...) = Plutonium::UI::Table::Components::ScopesPills.new(...)
|
|
82
|
+
|
|
81
83
|
def BuildTableInfo(...) = Plutonium::UI::Table::Components::PagyInfo.new(...)
|
|
82
84
|
|
|
83
85
|
def BuildTablePagination(...) = Plutonium::UI::Table::Components::PagyPagination.new(...)
|
|
@@ -86,7 +88,17 @@ module Plutonium
|
|
|
86
88
|
|
|
87
89
|
def BuildBulkActionsToolbar(...) = Plutonium::UI::Table::Components::BulkActionsToolbar.new(...)
|
|
88
90
|
|
|
91
|
+
def BuildTableToolbar(...) = Plutonium::UI::Table::Components::Toolbar.new(...)
|
|
92
|
+
|
|
93
|
+
def BuildTableFilterPills(...) = Plutonium::UI::Table::Components::FilterPills.new(...)
|
|
94
|
+
|
|
95
|
+
def BuildTableViewSwitcher(...) = Plutonium::UI::Table::Components::ViewSwitcher.new(...)
|
|
96
|
+
|
|
89
97
|
def BuildColorModeSelector(...) = Plutonium::UI::ColorModeSelector.new(...)
|
|
98
|
+
|
|
99
|
+
def BuildModalCentered(...) = Plutonium::UI::Modal::Centered.new(...)
|
|
100
|
+
|
|
101
|
+
def BuildModalSlideover(...) = Plutonium::UI::Modal::Slideover.new(...)
|
|
90
102
|
end
|
|
91
103
|
end
|
|
92
104
|
end
|
|
@@ -13,56 +13,93 @@ module Plutonium
|
|
|
13
13
|
@resource_definition = resource_definition
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Metadata fields the user is permitted to see — intersection of
|
|
17
|
+
# the definition's declared metadata with the policy-filtered
|
|
18
|
+
# `resource_fields`. Computed lazily so we don't run the
|
|
19
|
+
# intersection when the resource doesn't declare any metadata.
|
|
20
|
+
def metadata_fields
|
|
21
|
+
@metadata_fields ||= resource_definition.defined_metadata_fields & resource_fields
|
|
22
|
+
end
|
|
23
|
+
|
|
16
24
|
def display_template
|
|
17
|
-
|
|
18
|
-
|
|
25
|
+
if associations_present?
|
|
26
|
+
render_tablist_with_details
|
|
27
|
+
else
|
|
28
|
+
render_fields
|
|
29
|
+
end
|
|
19
30
|
end
|
|
20
31
|
|
|
21
32
|
private
|
|
22
33
|
|
|
34
|
+
def associations_present?
|
|
35
|
+
present_associations? && resource_associations.present?
|
|
36
|
+
end
|
|
37
|
+
|
|
23
38
|
def render_fields
|
|
39
|
+
if metadata_fields.any?
|
|
40
|
+
div(class: "grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_320px] gap-6 items-start") do
|
|
41
|
+
div { render_main_field_card }
|
|
42
|
+
aside { render_metadata_panel }
|
|
43
|
+
end
|
|
44
|
+
else
|
|
45
|
+
render_main_field_card
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def render_main_field_card
|
|
24
50
|
Block do
|
|
25
51
|
fields_wrapper do
|
|
26
|
-
|
|
52
|
+
# Skip fields claimed by the metadata panel — rendering
|
|
53
|
+
# them in both places duplicates information.
|
|
54
|
+
(resource_fields - metadata_fields).each do |name|
|
|
27
55
|
render_resource_field name
|
|
28
56
|
end
|
|
29
57
|
end
|
|
30
58
|
end
|
|
31
59
|
end
|
|
32
60
|
|
|
33
|
-
|
|
34
|
-
|
|
61
|
+
# Renders the declared metadata fields as a vertical stack beside
|
|
62
|
+
# the main field card. Reuses render_resource_field (same path
|
|
63
|
+
# the main details use) so labels/values match in style; the
|
|
64
|
+
# only difference from the main card is the wrapper — a single
|
|
65
|
+
# column of fields instead of the form's multi-column grid.
|
|
66
|
+
def render_metadata_panel
|
|
67
|
+
Block do
|
|
68
|
+
div(class: "pu-card-body flex flex-col gap-6") do
|
|
69
|
+
metadata_fields.each { |name| render_resource_field(name) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
35
73
|
|
|
74
|
+
def render_tablist_with_details
|
|
36
75
|
tablist = BuildTabList()
|
|
37
76
|
|
|
77
|
+
# Build an inner display component for the Details tab.
|
|
78
|
+
# It must be a standalone Phlex component so that TabList can call
|
|
79
|
+
# `render(details_display)` from within its own context. Phlex propagates
|
|
80
|
+
# @_state through render calls, so the inner component writes to the same
|
|
81
|
+
# buffer as the outer Resource display even though self changes.
|
|
82
|
+
details_display = build_details_display
|
|
83
|
+
|
|
84
|
+
tablist.with_tab(
|
|
85
|
+
identifier: "details",
|
|
86
|
+
title: -> { plain "Details" }
|
|
87
|
+
) do
|
|
88
|
+
render details_display
|
|
89
|
+
end
|
|
90
|
+
|
|
38
91
|
resource_associations.each do |name|
|
|
39
92
|
reflection = object.class.reflect_on_association name
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
raise ArgumentError,
|
|
43
|
-
"unknown association #{object.class}##{name} defined in #permitted_associations"
|
|
44
|
-
elsif !registered_resources.include?(reflection.klass)
|
|
45
|
-
raise ArgumentError,
|
|
46
|
-
"#{object.class}##{name} defined in #permitted_associations, but #{reflection.klass} is not a registered resource"
|
|
47
|
-
end
|
|
93
|
+
raise_unknown_association(name) unless reflection
|
|
94
|
+
raise_unregistered_association(name, reflection) unless registered_resources.include?(reflection.klass)
|
|
48
95
|
|
|
49
96
|
title = object.class.human_attribute_name(name)
|
|
50
|
-
src =
|
|
51
|
-
when :belongs_to
|
|
52
|
-
associated = object.public_send name
|
|
53
|
-
resource_url_for(associated, parent: nil) if associated
|
|
54
|
-
when :has_one
|
|
55
|
-
associated = object.public_send name
|
|
56
|
-
resource_url_for(associated, parent: object, association: name)
|
|
57
|
-
when :has_many
|
|
58
|
-
resource_url_for(reflection.klass, parent: object, association: name)
|
|
59
|
-
end
|
|
60
|
-
|
|
97
|
+
src = association_src(name, reflection)
|
|
61
98
|
next unless src
|
|
62
99
|
|
|
63
100
|
tablist.with_tab(
|
|
64
101
|
identifier: title.parameterize,
|
|
65
|
-
title: -> {
|
|
102
|
+
title: -> { plain title }
|
|
66
103
|
) do
|
|
67
104
|
FrameNavigatorPanel(title: "", src:, panel_id: "association-panel-#{title.parameterize}")
|
|
68
105
|
end
|
|
@@ -71,6 +108,53 @@ module Plutonium
|
|
|
71
108
|
render tablist
|
|
72
109
|
end
|
|
73
110
|
|
|
111
|
+
# Builds a standalone Phlex component whose sole job is to render the
|
|
112
|
+
# resource fields. Having a distinct component lets TabList call
|
|
113
|
+
# `render(details_display)` so that Phlex propagates its @_state correctly,
|
|
114
|
+
# while avoiding the `instance_exec` context-switch problem that would
|
|
115
|
+
# occur if we put `render_fields` directly inside the `with_tab` block.
|
|
116
|
+
#
|
|
117
|
+
# The anonymous subclass overrides `view_template` to skip the outer
|
|
118
|
+
# `display_wrapper` div (which would duplicate the dom id already emitted
|
|
119
|
+
# by the parent Resource display) and renders just the fields content.
|
|
120
|
+
def build_details_display
|
|
121
|
+
resource = self
|
|
122
|
+
|
|
123
|
+
klass = Class.new(self.class) do
|
|
124
|
+
define_method(:view_template) do
|
|
125
|
+
resource.send(:render_fields)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
klass.new(
|
|
130
|
+
object,
|
|
131
|
+
resource_fields: resource_fields,
|
|
132
|
+
resource_associations: [],
|
|
133
|
+
resource_definition: resource_definition
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def association_src(name, reflection)
|
|
138
|
+
case reflection.macro
|
|
139
|
+
when :belongs_to
|
|
140
|
+
associated = object.public_send name
|
|
141
|
+
resource_url_for(associated, parent: nil) if associated
|
|
142
|
+
when :has_one
|
|
143
|
+
associated = object.public_send name
|
|
144
|
+
resource_url_for(associated, parent: object, association: name)
|
|
145
|
+
when :has_many
|
|
146
|
+
resource_url_for(reflection.klass, parent: object, association: name)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def raise_unknown_association(name)
|
|
151
|
+
raise ArgumentError, "unknown association #{object.class}##{name} defined in #permitted_associations"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def raise_unregistered_association(name, reflection)
|
|
155
|
+
raise ArgumentError, "#{object.class}##{name} defined in #permitted_associations, but #{reflection.klass} is not a registered resource"
|
|
156
|
+
end
|
|
157
|
+
|
|
74
158
|
def render_resource_field(name)
|
|
75
159
|
when_permitted(name) do
|
|
76
160
|
# field :name, as: :string
|
|
@@ -8,7 +8,8 @@ module Plutonium
|
|
|
8
8
|
super.merge({
|
|
9
9
|
base: "",
|
|
10
10
|
value_wrapper: "max-h-[300px] overflow-y-auto",
|
|
11
|
-
fields_wrapper: "
|
|
11
|
+
fields_wrapper: "pu-card",
|
|
12
|
+
fields_inner: "pu-card-body grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-x-8 gap-y-6 grid-flow-row-dense",
|
|
12
13
|
|
|
13
14
|
# Labels and descriptions
|
|
14
15
|
label: "text-sm font-semibold uppercase tracking-wide text-[var(--pu-text-muted)] mb-2",
|
|
@@ -1,29 +1,23 @@
|
|
|
1
1
|
module Plutonium
|
|
2
2
|
module UI
|
|
3
3
|
module DynaFrame
|
|
4
|
+
# Conditionally wraps its content in a turbo-frame matching the inbound
|
|
5
|
+
# request's `Turbo-Frame` header. In frame mode adds the flash partial
|
|
6
|
+
# so toast/alert messages still surface inside frames; in non-frame
|
|
7
|
+
# mode renders the block as-is.
|
|
4
8
|
class Content < Plutonium::UI::Component::Base
|
|
5
9
|
include Phlex::Rails::Helpers::TurboFrameTag
|
|
6
10
|
|
|
7
|
-
def
|
|
8
|
-
@content = content
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
def view_template
|
|
11
|
+
def view_template(&block)
|
|
12
12
|
if current_turbo_frame.present?
|
|
13
|
-
# Frame request: render only the turbo-frame with content
|
|
14
13
|
turbo_frame_tag(current_turbo_frame) do
|
|
15
14
|
render partial("flash")
|
|
16
|
-
|
|
15
|
+
yield if block_given?
|
|
17
16
|
end
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
yield(self)
|
|
17
|
+
elsif block_given?
|
|
18
|
+
yield
|
|
21
19
|
end
|
|
22
20
|
end
|
|
23
|
-
|
|
24
|
-
def render_content
|
|
25
|
-
@content&.call
|
|
26
|
-
end
|
|
27
21
|
end
|
|
28
22
|
end
|
|
29
23
|
end
|
|
@@ -10,6 +10,28 @@ module Plutonium
|
|
|
10
10
|
include Phlexi::Field::Common::Tokens
|
|
11
11
|
include Plutonium::UI::Form::Options::InferredTypes
|
|
12
12
|
|
|
13
|
+
# Consume `:as` here so it doesn't land in Phlexi's `@options` —
|
|
14
|
+
# `:as` is a Plutonium-internal concept (it picks the tag method),
|
|
15
|
+
# not a Phlexi field option.
|
|
16
|
+
def initialize(*args, as: nil, **kwargs, &block)
|
|
17
|
+
@as = as
|
|
18
|
+
super(*args, **kwargs, &block)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
attr_reader :as
|
|
22
|
+
|
|
23
|
+
def hidden?
|
|
24
|
+
as.to_s == "hidden"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Hidden fields (`form.field(name, as: :hidden)`) skip the label /
|
|
28
|
+
# hint / error chrome and render inside a `<div hidden>` so they're
|
|
29
|
+
# also excluded from CSS Grid / Flex layout.
|
|
30
|
+
def wrapped(**, &)
|
|
31
|
+
return Plutonium::UI::Form::Components::HiddenWrapper.new(self, &) if hidden?
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
|
|
13
35
|
def textarea_tag(**attributes, &)
|
|
14
36
|
attributes[:data_controller] = tokens(attributes[:data_controller], "textarea-autogrow")
|
|
15
37
|
super
|
|
@@ -45,7 +67,13 @@ module Plutonium
|
|
|
45
67
|
end
|
|
46
68
|
|
|
47
69
|
def resource_select_tag(**attributes, &)
|
|
48
|
-
|
|
70
|
+
attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select")
|
|
71
|
+
# class!: "" clears the underlying <select>'s themed classes
|
|
72
|
+
# (pu-input etc.) — the visible element is slim-select's
|
|
73
|
+
# generated .ss-main, so leaving Tailwind input chrome on the
|
|
74
|
+
# native select can leak into chip layout (e.g. forcing
|
|
75
|
+
# flex-direction: column or w-full on multi-mode chips).
|
|
76
|
+
create_component(Components::ResourceSelect, :select, class!: "", **attributes, &)
|
|
49
77
|
end
|
|
50
78
|
|
|
51
79
|
def secure_association_tag(**attributes, &)
|