plutonium 0.49.1 → 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-views/SKILL.md +59 -0
- data/CHANGELOG.md +12 -0
- data/app/assets/plutonium.css +2 -2
- data/app/assets/plutonium.js +369 -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/_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/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-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_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/lite/rails_pulse/rails_pulse_generator.rb +54 -5
- 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/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/presentable.rb +11 -2
- data/lib/plutonium/resource/definition.rb +42 -0
- 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/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/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 +31 -3
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Definition
|
|
5
|
+
# Adds the `metadata` DSL — a list of field names rendered in the
|
|
6
|
+
# show page's right-side panel as label/value rows. Opt-in: when no
|
|
7
|
+
# `metadata` call is made, the show page stays full-width with no
|
|
8
|
+
# aside.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# class PostDefinition < Plutonium::Definition::Base
|
|
12
|
+
# metadata :created_at, :updated_at, :author, :state
|
|
13
|
+
# end
|
|
14
|
+
module Metadata
|
|
15
|
+
extend ActiveSupport::Concern
|
|
16
|
+
|
|
17
|
+
included do
|
|
18
|
+
class_attribute :defined_metadata_fields, default: [], instance_accessor: false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class_methods do
|
|
22
|
+
# Declares the fields rendered in the show page metadata panel.
|
|
23
|
+
# Each name is looked up in `defined_fields` for display config
|
|
24
|
+
# (label/format), so a field can have custom formatting in the
|
|
25
|
+
# main show body and the panel without redeclaring.
|
|
26
|
+
#
|
|
27
|
+
# @param names [Array<Symbol>]
|
|
28
|
+
def metadata(*names)
|
|
29
|
+
self.defined_metadata_fields = names.flatten.map(&:to_sym)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# class_attribute is declared with instance_accessor: false; expose
|
|
34
|
+
# an instance reader that delegates so callers with a definition
|
|
35
|
+
# instance (e.g. `current_definition`) can ask without poking the
|
|
36
|
+
# class directly. Mirrors Definition::Views.
|
|
37
|
+
def defined_metadata_fields = self.class.defined_metadata_fields
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Definition
|
|
5
|
+
# DSL for declaring which index views a resource supports and how
|
|
6
|
+
# they're configured.
|
|
7
|
+
#
|
|
8
|
+
# @example Enable both views, default to Grid
|
|
9
|
+
# class UserDefinition < Plutonium::Resource::Definition
|
|
10
|
+
# views :table, :grid
|
|
11
|
+
# default_view :grid
|
|
12
|
+
#
|
|
13
|
+
# grid_fields(
|
|
14
|
+
# image: :avatar,
|
|
15
|
+
# header: :name,
|
|
16
|
+
# subheader: :email,
|
|
17
|
+
# meta: [:role, :status]
|
|
18
|
+
# )
|
|
19
|
+
# end
|
|
20
|
+
module Views
|
|
21
|
+
extend ActiveSupport::Concern
|
|
22
|
+
|
|
23
|
+
KNOWN_VIEWS = %i[table grid].freeze
|
|
24
|
+
GRID_SLOTS = %i[image header subheader body meta footer].freeze
|
|
25
|
+
GRID_LAYOUTS = %i[compact media].freeze
|
|
26
|
+
|
|
27
|
+
included do
|
|
28
|
+
class_attribute :defined_views, default: [:table], instance_accessor: false
|
|
29
|
+
class_attribute :defined_default_view, default: nil, instance_accessor: false
|
|
30
|
+
class_attribute :defined_grid_fields, default: {}, instance_accessor: false
|
|
31
|
+
class_attribute :defined_grid_layout, default: :compact, instance_accessor: false
|
|
32
|
+
class_attribute :defined_grid_columns, default: nil, instance_accessor: false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class_methods do
|
|
36
|
+
# Declares the index views this resource supports.
|
|
37
|
+
# @param list [Array<Symbol>] one or more of {KNOWN_VIEWS}
|
|
38
|
+
def views(*list)
|
|
39
|
+
list = list.flatten.map(&:to_sym)
|
|
40
|
+
invalid = list - KNOWN_VIEWS
|
|
41
|
+
raise ArgumentError, "Unknown views: #{invalid.inspect}. Valid: #{KNOWN_VIEWS}" if invalid.any?
|
|
42
|
+
self.defined_views = list.empty? ? [:table] : list
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Declares the default index view. Must be one of {.views}.
|
|
46
|
+
# Falls back to the first declared view if unset.
|
|
47
|
+
def default_view(name = nil)
|
|
48
|
+
if name.nil?
|
|
49
|
+
defined_default_view || defined_views.first
|
|
50
|
+
else
|
|
51
|
+
name = name.to_sym
|
|
52
|
+
unless defined_views.include?(name)
|
|
53
|
+
raise ArgumentError, "default_view #{name.inspect} not in views #{defined_views.inspect}"
|
|
54
|
+
end
|
|
55
|
+
self.defined_default_view = name
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Maps grid slots to fields. Each slot is optional. Implicitly
|
|
60
|
+
# adds `:grid` to {.views} so a resource can opt into the Grid
|
|
61
|
+
# view simply by declaring its slots.
|
|
62
|
+
# @param slots [Hash{Symbol => Symbol, Array<Symbol>}]
|
|
63
|
+
def grid_fields(**slots)
|
|
64
|
+
invalid = slots.keys - GRID_SLOTS
|
|
65
|
+
raise ArgumentError, "Unknown grid slots: #{invalid.inspect}. Valid: #{GRID_SLOTS}" if invalid.any?
|
|
66
|
+
self.defined_grid_fields = slots
|
|
67
|
+
self.defined_views = defined_views + [:grid] unless defined_views.include?(:grid)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Layout shape for grid cards. :compact (default) places the image
|
|
71
|
+
# left of the content; :media stacks the image full-width on top.
|
|
72
|
+
def grid_layout(value)
|
|
73
|
+
value = value.to_sym
|
|
74
|
+
unless GRID_LAYOUTS.include?(value)
|
|
75
|
+
raise ArgumentError, "grid_layout must be one of #{GRID_LAYOUTS}, got #{value.inspect}"
|
|
76
|
+
end
|
|
77
|
+
self.defined_grid_layout = value
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Override responsive column count. Default is 1 / 2 / 3 / 4 at
|
|
81
|
+
# sm / md / lg / xl.
|
|
82
|
+
def grid_columns(value)
|
|
83
|
+
self.defined_grid_columns = Integer(value)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def defined_views = self.class.defined_views
|
|
88
|
+
def default_view = self.class.default_view
|
|
89
|
+
def defined_grid_fields = self.class.defined_grid_fields
|
|
90
|
+
def defined_grid_layout = self.class.defined_grid_layout
|
|
91
|
+
def defined_grid_columns = self.class.defined_grid_columns
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/plutonium/query/base.rb
CHANGED
|
@@ -24,6 +24,14 @@ module Plutonium
|
|
|
24
24
|
def apply(scope, **params)
|
|
25
25
|
raise NotImplementedError, "#{self.class}#apply(scope, **params)"
|
|
26
26
|
end
|
|
27
|
+
|
|
28
|
+
# Human-readable rendering of a single filter value for the active
|
|
29
|
+
# filter pill row. Defaults to `value.to_s`. Subclasses
|
|
30
|
+
# (Filters::Association, Filters::Boolean) override to translate
|
|
31
|
+
# raw param values (SGIDs, "true"/"false") into recognisable text.
|
|
32
|
+
def humanize_value(value)
|
|
33
|
+
value.to_s
|
|
34
|
+
end
|
|
27
35
|
end
|
|
28
36
|
end
|
|
29
37
|
end
|
|
@@ -16,7 +16,7 @@ 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:
|
|
19
|
+
def initialize(class_name: nil, resource_class: nil, scope: nil, multiple: true, **)
|
|
20
20
|
super(**)
|
|
21
21
|
@class_name = class_name
|
|
22
22
|
@resource_class = resource_class
|
|
@@ -24,15 +24,21 @@ module Plutonium
|
|
|
24
24
|
@multiple = multiple
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
def humanize_value(value)
|
|
28
|
+
return "" if value.blank?
|
|
29
|
+
ids = decode_ids(value)
|
|
30
|
+
return "" if ids.empty?
|
|
31
|
+
records = association_class.where(id: ids)
|
|
32
|
+
records.map { |r| r.respond_to?(:to_label) ? r.to_label : r.to_s }.join(", ")
|
|
33
|
+
rescue
|
|
34
|
+
Array(value).reject(&:blank?).join(", ")
|
|
35
|
+
end
|
|
36
|
+
|
|
27
37
|
def apply(scope, value:)
|
|
28
38
|
return scope if value.blank?
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
scope.where(foreign_key => value.reject(&:blank?))
|
|
33
|
-
else
|
|
34
|
-
scope.where(foreign_key => value)
|
|
35
|
-
end
|
|
39
|
+
ids = decode_ids(value)
|
|
40
|
+
return scope if ids.empty?
|
|
41
|
+
scope.where("#{key}_id": ids)
|
|
36
42
|
end
|
|
37
43
|
|
|
38
44
|
def customize_inputs
|
|
@@ -45,6 +51,22 @@ module Plutonium
|
|
|
45
51
|
|
|
46
52
|
private
|
|
47
53
|
|
|
54
|
+
# Accepts either an SGID (the new default sent by ResourceSelect)
|
|
55
|
+
# or a raw id (legacy URLs). Returns the underlying record ids.
|
|
56
|
+
def decode_ids(value)
|
|
57
|
+
Array(value).reject(&:blank?).filter_map { |v| decode_id(v) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def decode_id(value)
|
|
61
|
+
gid = SignedGlobalID.parse(value)
|
|
62
|
+
return gid.model_id if gid
|
|
63
|
+
value
|
|
64
|
+
rescue
|
|
65
|
+
value
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
48
70
|
def association_class
|
|
49
71
|
@association_class ||= resolve_class_name || detect_class_from_reflection || infer_class_from_key
|
|
50
72
|
end
|
|
@@ -16,6 +16,11 @@ module Plutonium
|
|
|
16
16
|
@false_label = false_label
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
def humanize_value(value)
|
|
20
|
+
return "" if value.blank?
|
|
21
|
+
ActiveModel::Type::Boolean.new.cast(value) ? @true_label : @false_label
|
|
22
|
+
end
|
|
23
|
+
|
|
19
24
|
def apply(scope, value:)
|
|
20
25
|
return scope if value.blank?
|
|
21
26
|
|
|
@@ -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
|
|
@@ -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
|