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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-definition/SKILL.md +87 -2
  3. data/.claude/skills/plutonium-installation/SKILL.md +6 -0
  4. data/.claude/skills/plutonium-views/SKILL.md +59 -0
  5. data/CHANGELOG.md +12 -0
  6. data/app/assets/plutonium.css +2 -2
  7. data/app/assets/plutonium.js +369 -25
  8. data/app/assets/plutonium.js.map +4 -4
  9. data/app/assets/plutonium.min.js +45 -45
  10. data/app/assets/plutonium.min.js.map +4 -4
  11. data/app/views/plutonium/_resource_header.html.erb +4 -4
  12. data/app/views/plutonium/_resource_sidebar.html.erb +9 -9
  13. data/app/views/resource/_resource_grid.html.erb +1 -0
  14. data/config/brakeman.ignore +25 -2
  15. data/docs/reference/definition/actions.md +14 -1
  16. data/docs/reference/definition/index.md +58 -0
  17. data/docs/reference/views/index.md +43 -0
  18. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md +841 -0
  19. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json +103 -0
  20. data/docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md +270 -0
  21. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  22. data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
  23. data/lib/generators/pu/core/update/update_generator.rb +20 -0
  24. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +54 -5
  25. data/lib/plutonium/action/base.rb +44 -1
  26. data/lib/plutonium/action/interactive.rb +1 -1
  27. data/lib/plutonium/configuration.rb +4 -0
  28. data/lib/plutonium/definition/actions.rb +3 -0
  29. data/lib/plutonium/definition/base.rb +8 -0
  30. data/lib/plutonium/definition/metadata.rb +40 -0
  31. data/lib/plutonium/definition/views.rb +94 -0
  32. data/lib/plutonium/helpers/turbo_helper.rb +1 -1
  33. data/lib/plutonium/interaction/response/redirect.rb +1 -1
  34. data/lib/plutonium/query/base.rb +8 -0
  35. data/lib/plutonium/query/filters/association.rb +30 -8
  36. data/lib/plutonium/query/filters/boolean.rb +5 -0
  37. data/lib/plutonium/resource/controllers/presentable.rb +11 -2
  38. data/lib/plutonium/resource/definition.rb +42 -0
  39. data/lib/plutonium/resource/query_object.rb +64 -6
  40. data/lib/plutonium/testing/resource_definition.rb +2 -2
  41. data/lib/plutonium/ui/action_button.rb +4 -2
  42. data/lib/plutonium/ui/component/kit.rb +12 -0
  43. data/lib/plutonium/ui/display/base.rb +3 -1
  44. data/lib/plutonium/ui/display/resource.rb +109 -25
  45. data/lib/plutonium/ui/display/theme.rb +2 -1
  46. data/lib/plutonium/ui/dyna_frame/content.rb +8 -14
  47. data/lib/plutonium/ui/empty_card.rb +1 -1
  48. data/lib/plutonium/ui/form/base.rb +29 -1
  49. data/lib/plutonium/ui/form/components/hidden_wrapper.rb +25 -0
  50. data/lib/plutonium/ui/form/components/resource_select.rb +79 -1
  51. data/lib/plutonium/ui/form/components/secure_association.rb +7 -2
  52. data/lib/plutonium/ui/form/components/sticky_footer.rb +17 -0
  53. data/lib/plutonium/ui/form/resource.rb +48 -9
  54. data/lib/plutonium/ui/form/theme.rb +1 -1
  55. data/lib/plutonium/ui/frame_navigator_panel.rb +7 -4
  56. data/lib/plutonium/ui/grid/card.rb +235 -0
  57. data/lib/plutonium/ui/grid/resource.rb +149 -0
  58. data/lib/plutonium/ui/layout/base.rb +37 -1
  59. data/lib/plutonium/ui/layout/header.rb +1 -2
  60. data/lib/plutonium/ui/layout/icon_rail.rb +212 -0
  61. data/lib/plutonium/ui/layout/resource_layout.rb +10 -3
  62. data/lib/plutonium/ui/layout/sidebar.rb +12 -24
  63. data/lib/plutonium/ui/layout/topbar.rb +100 -0
  64. data/lib/plutonium/ui/modal/base.rb +109 -0
  65. data/lib/plutonium/ui/modal/centered.rb +21 -0
  66. data/lib/plutonium/ui/modal/slideover.rb +26 -0
  67. data/lib/plutonium/ui/page/base.rb +25 -6
  68. data/lib/plutonium/ui/page/edit.rb +13 -1
  69. data/lib/plutonium/ui/page/index.rb +40 -1
  70. data/lib/plutonium/ui/page/interactive_action.rb +8 -39
  71. data/lib/plutonium/ui/page/new.rb +13 -1
  72. data/lib/plutonium/ui/page/show.rb +8 -1
  73. data/lib/plutonium/ui/page_header.rb +8 -13
  74. data/lib/plutonium/ui/panel.rb +10 -19
  75. data/lib/plutonium/ui/sidebar_menu.rb +2 -25
  76. data/lib/plutonium/ui/tab_list.rb +29 -7
  77. data/lib/plutonium/ui/table/base.rb +106 -0
  78. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +12 -4
  79. data/lib/plutonium/ui/table/components/filter_form.rb +171 -0
  80. data/lib/plutonium/ui/table/components/filter_pills.rb +89 -0
  81. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +13 -12
  82. data/lib/plutonium/ui/table/components/scopes_pills.rb +67 -0
  83. data/lib/plutonium/ui/table/components/selection_column.rb +2 -11
  84. data/lib/plutonium/ui/table/components/toolbar.rb +104 -0
  85. data/lib/plutonium/ui/table/components/view_switcher.rb +81 -0
  86. data/lib/plutonium/ui/table/resource.rb +158 -89
  87. data/lib/plutonium/ui/table/theme.rb +14 -5
  88. data/lib/plutonium/version.rb +1 -1
  89. data/lib/plutonium.rb +6 -0
  90. data/package.json +1 -1
  91. data/src/css/components.css +304 -131
  92. data/src/css/tokens.css +101 -85
  93. data/src/js/controllers/autosubmit_controller.js +24 -0
  94. data/src/js/controllers/bulk_actions_controller.js +15 -16
  95. data/src/js/controllers/capture_url_controller.js +14 -0
  96. data/src/js/controllers/filter_panel_controller.js +77 -19
  97. data/src/js/controllers/frame_navigator_controller.js +34 -6
  98. data/src/js/controllers/icon_rail_controller.js +22 -0
  99. data/src/js/controllers/icon_rail_flyout_controller.js +128 -0
  100. data/src/js/controllers/register_controllers.js +16 -0
  101. data/src/js/controllers/resource_tab_list_controller.js +56 -3
  102. data/src/js/controllers/row_click_controller.js +21 -0
  103. data/src/js/controllers/table_column_menu_controller.js +43 -0
  104. data/src/js/controllers/table_header_controller.js +16 -0
  105. data/src/js/controllers/view_switcher_controller.js +29 -0
  106. 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
@@ -6,7 +6,7 @@ module Plutonium
6
6
  end
7
7
 
8
8
  def remote_modal_frame_tag(&)
9
- turbo_frame_tag("remote_modal", &)
9
+ turbo_frame_tag(Plutonium::REMOTE_MODAL_FRAME, &)
10
10
  end
11
11
  end
12
12
  end
@@ -29,7 +29,7 @@ module Plutonium
29
29
 
30
30
  respond_to do |format|
31
31
  format.turbo_stream do
32
- if helpers.current_turbo_frame == "remote_modal"
32
+ if helpers.current_turbo_frame == Plutonium::REMOTE_MODAL_FRAME
33
33
  render turbo_stream: [
34
34
  helpers.turbo_stream_redirect(url)
35
35
  ]
@@ -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: false, **)
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
- foreign_key = :"#{key}_id"
31
- if @multiple && value.is_a?(Array)
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(resource_record!, resource_fields: presentable_attributes, resource_associations: permitted_associations, resource_definition: current_definition)
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
- q[:sort_directions] = selected_sort_directions.dup
78
- q[:sort_fields] = selected_sort_fields.dup
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
- q.merge! params.slice(*filter_definitions.keys)
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 direction.
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
- resource_class.method_defined?(field_name) ||
37
- resource_class.reflect_on_association(field_name),
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
@@ -46,7 +46,9 @@ module Plutonium
46
46
 
47
47
  def fields_wrapper(&)
48
48
  div(class: themed(:fields_wrapper)) {
49
- yield
49
+ div(class: themed(:fields_inner)) {
50
+ yield
51
+ }
50
52
  }
51
53
  end
52
54
  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
- render_fields
18
- render_associations if present_associations?
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
- resource_fields.each do |name|
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
- def render_associations
34
- return unless resource_associations.present?
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
- if !reflection
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 = case reflection.macro
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: -> { h5(class: "text-2xl font-bold tracking-tight text-[var(--pu-text)]") { 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