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
@@ -6,6 +6,15 @@ module Plutonium
6
6
  class Base < Phlexi::Table::Base
7
7
  include Plutonium::UI::Component::Behaviour
8
8
 
9
+ # Make every body row a row-click candidate. The controller
10
+ # delegates to whatever element inside the row is tagged
11
+ # `data-row-click-target="show"` (typically the show action
12
+ # button). Rows without such a target become a no-op — no
13
+ # special-casing needed in this layer.
14
+ def table_body_row_attributes(wrapped_object)
15
+ super.merge(data: {controller: "row-click", action: "click->row-click#click"})
16
+ end
17
+
9
18
  # Use custom SelectionColumn with Stimulus data attributes
10
19
  class SelectionColumn < Plutonium::UI::Table::Components::SelectionColumn; end
11
20
 
@@ -16,6 +25,103 @@ module Plutonium
16
25
  end
17
26
  end
18
27
  end
28
+
29
+ # Override DataColumn to use our enhanced SortableHeaderCell
30
+ class DataColumn < Phlexi::Table::Components::DataColumn
31
+ def header_cell
32
+ SortableHeaderCell.new(label, sort_params:)
33
+ end
34
+ end
35
+
36
+ # Enhanced sortable header cell with:
37
+ # - plain click → replace sort (single column); shift-click → multi-sort
38
+ # - direction indicator (↑/↓) with priority badge when multi-sort active
39
+ # - ⋯ column menu with Clear sort + disabled placeholders
40
+ class SortableHeaderCell < Phlexi::Table::Components::SortableHeaderCell
41
+ def view_template
42
+ if !sort_params
43
+ div(class: themed(:header_cell_content_wrapper)) { plain label_text }
44
+ return
45
+ end
46
+
47
+ div(class: themed(:header_cell_sort_wrapper), data: {controller: "table-header"}) do
48
+ render_sort_link
49
+ render_column_menu
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ attr_reader :sort_params
56
+
57
+ def label_text
58
+ @value.to_s
59
+ end
60
+
61
+ def render_sort_link
62
+ a(
63
+ href: sort_params[:url],
64
+ class: themed(:header_cell_link),
65
+ data: {
66
+ action: "click->table-header#headerClick",
67
+ table_header_multi_href: sort_params[:multi_url]
68
+ }
69
+ ) do
70
+ span { plain label_text }
71
+ render_sort_indicator
72
+ end
73
+ end
74
+
75
+ def render_sort_indicator
76
+ direction = sort_params[:direction]
77
+
78
+ if direction.present?
79
+ span(class: themed(:sort_icon_active)) do
80
+ if direction == "ASC"
81
+ render Phlex::TablerIcons::ArrowUp.new(class: "w-3 h-3")
82
+ else
83
+ render Phlex::TablerIcons::ArrowDown.new(class: "w-3 h-3")
84
+ end
85
+ end
86
+ if sort_params[:multi]
87
+ span(class: themed(:sort_priority_badge)) do
88
+ plain((sort_params[:position] + 1).to_s)
89
+ end
90
+ end
91
+ else
92
+ span(class: themed(:sort_icon_inactive)) do
93
+ render Phlex::TablerIcons::Selector.new(class: "w-3 h-3")
94
+ end
95
+ end
96
+ end
97
+
98
+ def render_column_menu
99
+ div(class: "relative group/col-menu", data: {controller: "table-column-menu"}) do
100
+ button(
101
+ type: "button",
102
+ class: themed(:column_menu_trigger),
103
+ data: {action: "click->table-column-menu#toggle"},
104
+ aria: {label: "Column options"}
105
+ ) do
106
+ render Phlex::TablerIcons::Dots.new(class: "w-3 h-3")
107
+ end
108
+
109
+ div(
110
+ class: themed(:column_menu_panel),
111
+ data: {"table-column-menu-target": "panel"}
112
+ ) do
113
+ render_menu_item("Clear sort", sort_params[:reset_url], icon: Phlex::TablerIcons::X)
114
+ end
115
+ end
116
+ end
117
+
118
+ def render_menu_item(label, href, icon: nil)
119
+ a(href: href, class: themed(:column_menu_item)) do
120
+ render icon.new(class: "w-4 h-4 shrink-0") if icon
121
+ span { plain label }
122
+ end
123
+ end
124
+ end
19
125
  end
20
126
  end
21
127
  end
@@ -23,33 +23,41 @@ module Plutonium
23
23
  end
24
24
 
25
25
  def view_template
26
- # Always render toolbar - hidden by default, Stimulus shows it when items are selected
27
26
  div(
28
- class: "hidden flex pu-toolbar",
27
+ class: "hidden flex items-center gap-3 px-4 py-2 border-b border-[var(--pu-border)] bg-primary-50 dark:bg-primary-950/30",
29
28
  data: {bulk_actions_target: "toolbar"}
30
29
  ) do
31
30
  render_selected_count
32
31
  render_action_buttons
32
+ render_clear_selection
33
33
  end
34
34
  end
35
35
 
36
36
  private
37
37
 
38
38
  def render_selected_count
39
- span(class: "pu-toolbar-text") do
39
+ div(class: "text-sm font-medium text-primary-700 dark:text-primary-300") do
40
40
  span(data: {bulk_actions_target: "selectedCount"}) { "0" }
41
41
  plain " selected"
42
42
  end
43
43
  end
44
44
 
45
45
  def render_action_buttons
46
- div(class: "pu-toolbar-actions") do
46
+ div(class: "flex items-center gap-1.5") do
47
47
  @bulk_actions.each do |action|
48
48
  render_action_button(action)
49
49
  end
50
50
  end
51
51
  end
52
52
 
53
+ def render_clear_selection
54
+ button(
55
+ type: "button",
56
+ data: {action: "click->bulk-actions#clearSelection"},
57
+ class: "ml-auto text-xs text-primary-700 dark:text-primary-300 hover:underline"
58
+ ) { "Clear selection" }
59
+ end
60
+
53
61
  def render_action_button(action)
54
62
  url = route_options_to_url(action.route_options, resource_class)
55
63
 
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Table
6
+ module Components
7
+ # Phlexi form rendering the filter slideover body. Submits via GET
8
+ # with `q[<filter>][<input>]=value` so the existing query object
9
+ # parses values exactly as the legacy filter dropdown did. Hidden
10
+ # fields preserve sort, scope and search state across applies.
11
+ class FilterForm < Plutonium::UI::Form::Base
12
+ attr_reader :query_object, :search_value, :search_param
13
+
14
+ def initialize(*, query_object:, search_url:, search_param: :q, search_value: nil, attributes: {}, **opts, &)
15
+ opts[:as] = :q
16
+ opts[:method] = :get
17
+ attributes = attributes.deep_merge(
18
+ id: "filter-form",
19
+ data: {turbo_frame: nil}
20
+ )
21
+ super(*, attributes:, **opts, &)
22
+ @query_object = query_object
23
+ @search_url = search_url
24
+ @search_param = search_param
25
+ @search_value = search_value
26
+ end
27
+
28
+ def form_class
29
+ "flex-1 flex flex-col min-h-0"
30
+ end
31
+
32
+ def form_template
33
+ render_header
34
+ render_fields_region
35
+ render_footer
36
+ render_hidden_state
37
+ end
38
+
39
+ private
40
+
41
+ def render_header
42
+ div(class: "shrink-0 flex items-center justify-between gap-4 px-6 pt-5 pb-4 " \
43
+ "border-b border-[var(--pu-border)]") do
44
+ h2(class: "text-lg font-semibold text-[var(--pu-text)]") { "Filters" }
45
+ div(class: "flex items-center gap-1") do
46
+ button(
47
+ type: "button",
48
+ class: "text-sm text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] px-2 py-1 rounded transition-colors",
49
+ data: {action: "filter-panel#clear"}
50
+ ) { "Clear" }
51
+ button(
52
+ type: "button",
53
+ class: "p-1.5 -m-1.5 text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] rounded-md transition-colors",
54
+ data: {action: "filter-panel#close"},
55
+ "aria-label": "Close filters"
56
+ ) do
57
+ render Phlex::TablerIcons::X.new(class: "w-5 h-5")
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ def render_fields_region
64
+ div(class: "flex-1 min-h-0 overflow-y-auto px-6 py-5 space-y-4") do
65
+ query_object.filter_definitions.each do |filter_name, definition|
66
+ nest_one filter_name do |nested|
67
+ inputs = definition.defined_inputs
68
+ has_multiple_inputs = inputs.size > 1
69
+ inputs.each do |input_name, _|
70
+ label = if has_multiple_inputs
71
+ "#{filter_name.to_s.humanize} (#{input_name.to_s.humanize.downcase})"
72
+ else
73
+ filter_name.to_s.humanize
74
+ end
75
+ render_filter_field(nested, definition, input_name, filter_label: label)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ def render_footer
83
+ div(class: "shrink-0 px-6 py-3 border-t border-[var(--pu-border)] " \
84
+ "flex items-center justify-end gap-2") do
85
+ render field(:submit).submit_button_tag(
86
+ name: nil,
87
+ class!: "pu-btn pu-btn-md pu-btn-primary"
88
+ ) { "Apply" }
89
+ end
90
+ end
91
+
92
+ # Hidden inputs preserve query state (sort, scope, search) so
93
+ # applying a filter doesn't reset them.
94
+ def render_hidden_state
95
+ div(hidden: true) do
96
+ if search_value.present?
97
+ input(name: "#{search_param}[search]", value: search_value, type: :hidden, hidden: true)
98
+ end
99
+ # Preserve the current view selection across filter applies
100
+ # and clears so the user stays where they were.
101
+ if helpers.params[:view].present?
102
+ input(name: "view", value: helpers.params[:view], type: :hidden, hidden: true)
103
+ end
104
+ render_sort_fields
105
+ render_scope_fields
106
+ end
107
+ end
108
+
109
+ def render_sort_fields
110
+ field :sort_fields do |name|
111
+ render name.input_array_tag do |array|
112
+ render array.input_tag(type: :hidden, hidden: true)
113
+ end
114
+ end
115
+ nest_one :sort_directions do |nested|
116
+ query_object.sort_definitions.each do |filter_name, _|
117
+ nested.field(filter_name) do |f|
118
+ render f.input_tag(type: :hidden, hidden: true)
119
+ end
120
+ end
121
+ end
122
+ end
123
+
124
+ def render_scope_fields
125
+ return if query_object.scope_definitions.blank?
126
+ render field(:scope).input_tag(type: :hidden, hidden: true)
127
+ end
128
+
129
+ def render_filter_field(nested, resource_definition, name, filter_label: nil)
130
+ input_definition = resource_definition.defined_inputs[name] || {}
131
+ input_options = input_definition[:options] || {}
132
+ field_options = resource_definition.defined_fields[name] ? resource_definition.defined_fields[name][:options].dup : {}
133
+
134
+ tag = input_options[:as] || field_options[:as]
135
+ tag_attributes = input_options.except(:wrapper, :as)
136
+
137
+ tag_block = input_definition[:block] || ->(f) {
138
+ tag ||= f.inferred_field_component
139
+ f.send(:"#{tag}_tag", **tag_attributes, class: "w-full")
140
+ }
141
+
142
+ field_options = field_options.except(:as)
143
+
144
+ # Explicitly thread the current param value as the field's
145
+ # initial value. Phlexi *can* read it off the form's hash
146
+ # record via `object[key]`, but going through `value:` is
147
+ # unambiguous and avoids subtle differences between the
148
+ # form's hash navigation and direct param reads.
149
+ current_value = current_param_value(nested.key, name)
150
+
151
+ div(class: "space-y-1.5") do
152
+ label(class: "text-sm font-medium text-[var(--pu-text)]") { filter_label }
153
+ nested.field(name, value: current_value, **field_options) do |f|
154
+ f.placeholder(input_options[:include_blank] || "All") if input_options[:include_blank]
155
+ render instance_exec(f, &tag_block)
156
+ end
157
+ end
158
+ end
159
+
160
+ def current_param_value(filter_name, input_name)
161
+ helpers.params.dig(search_param, filter_name, input_name)
162
+ end
163
+
164
+ def form_action
165
+ @search_url
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Table
6
+ module Components
7
+ class FilterPills < Plutonium::UI::Component::Base
8
+ def initialize(query:, total_count: nil)
9
+ @query = query
10
+ @total_count = total_count
11
+ end
12
+
13
+ def view_template
14
+ return if @query.active_filter_descriptions.empty? && @total_count.to_i.zero?
15
+
16
+ div(
17
+ class: "flex items-center gap-1.5 px-4 py-2 border-b border-[var(--pu-border)] flex-wrap",
18
+ data: {bulk_actions_target: "filterPills"}
19
+ ) do
20
+ @query.active_filter_descriptions.each { |f| render_pill(f) }
21
+ if @query.active_filter_descriptions.any?
22
+ render_add_filter_pill
23
+ render_clear_all_pill
24
+ end
25
+ render_result_count if @total_count
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def render_pill(filter)
32
+ span(class: "inline-flex items-center gap-1.5 h-6 px-2 rounded-full bg-primary-50 border border-primary-200 text-xs text-primary-700 dark:bg-primary-950/40 dark:border-primary-900/60 dark:text-primary-300") do
33
+ span { plain "#{filter[:label]}: #{filter[:value_label]}" }
34
+ a(href: filter[:clear_url],
35
+ aria: {label: "Remove #{filter[:label]} filter"},
36
+ class: "ml-0.5 inline-flex items-center justify-center w-4 h-4 rounded hover:bg-primary-200 dark:hover:bg-primary-900/60") do
37
+ render Phlex::TablerIcons::X.new(class: "w-3 h-3")
38
+ end
39
+ end
40
+ end
41
+
42
+ def render_add_filter_pill
43
+ button(type: "button",
44
+ data: {action: "click->filter-panel#toggle"},
45
+ class: "inline-flex items-center gap-1 h-6 px-2 rounded-full border border-dashed border-[var(--pu-border)] text-xs text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:border-[var(--pu-border-strong)]") do
46
+ render Phlex::TablerIcons::Plus.new(class: "w-3 h-3")
47
+ plain "Filter"
48
+ end
49
+ end
50
+
51
+ def render_clear_all_pill
52
+ a(href: clear_all_url,
53
+ class: "inline-flex items-center gap-1 h-6 px-2 rounded-full text-xs text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] underline-offset-2 hover:underline") do
54
+ plain "Clear all"
55
+ end
56
+ end
57
+
58
+ # Strip every active filter param while preserving search, scope,
59
+ # sort, view, and pagination state.
60
+ def clear_all_url
61
+ keep = request.query_parameters.dup
62
+ q = keep[:q] || keep["q"]
63
+ if q.is_a?(Hash) || q.is_a?(ActionController::Parameters)
64
+ filter_keys = @query.filter_definitions.keys.map(&:to_s)
65
+ # to_unsafe_h on Parameters; to_h on Hash both yield a
66
+ # plain Hash. Using to_h on Parameters would raise
67
+ # UnfilteredParameters in newer Rails versions.
68
+ raw = q.respond_to?(:to_unsafe_h) ? q.to_unsafe_h : q.to_h
69
+ cleaned = raw.reject { |k, _| filter_keys.include?(k.to_s) }
70
+ if cleaned.empty?
71
+ keep.delete(:q)
72
+ keep.delete("q")
73
+ else
74
+ keep["q"] = cleaned
75
+ end
76
+ end
77
+ "#{request.path}?#{keep.to_query}"
78
+ end
79
+
80
+ def render_result_count
81
+ div(class: "ml-auto text-xs text-[var(--pu-text-muted)]") do
82
+ plain "#{@total_count} #{(@total_count == 1) ? "result" : "results"}"
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -38,33 +38,34 @@ module Plutonium
38
38
  style: "box-shadow: var(--pu-shadow-lg)",
39
39
  data: {resource_drop_down_target: "menu"}
40
40
  ) do
41
- render_secondary_actions if secondary_actions.any?
42
- render_danger_divider if secondary_actions.any? && danger_actions.any?
43
- render_danger_actions if danger_actions.any?
41
+ render_group(primary_actions)
42
+ render_divider if primary_actions.any? && (secondary_actions.any? || danger_actions.any?)
43
+ render_group(secondary_actions)
44
+ render_divider if secondary_actions.any? && danger_actions.any?
45
+ render_group(danger_actions)
44
46
  end
45
47
  end
46
48
 
47
- def render_secondary_actions
49
+ def render_group(actions)
50
+ return if actions.empty?
48
51
  div(class: "py-1") do
49
- secondary_actions.each { |action| render_action_item(action) }
52
+ actions.each { |action| render_action_item(action) }
50
53
  end
51
54
  end
52
55
 
53
- def render_danger_divider
56
+ def render_divider
54
57
  div(class: "border-t border-[var(--pu-border-muted)]")
55
58
  end
56
59
 
57
- def render_danger_actions
58
- div(class: "py-1") do
59
- danger_actions.each { |action| render_action_item(action) }
60
- end
61
- end
62
-
63
60
  def render_action_item(action)
64
61
  url = route_options_to_url(action.route_options, @record)
65
62
  render Plutonium::UI::ActionButton.new(action, url: url, variant: :row_dropdown)
66
63
  end
67
64
 
65
+ def primary_actions
66
+ @primary_actions ||= @actions.select { |a| a.category.primary? }.sort_by(&:position)
67
+ end
68
+
68
69
  def secondary_actions
69
70
  @secondary_actions ||= @actions.select { |a| a.category.secondary? }.sort_by(&:position)
70
71
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Table
6
+ module Components
7
+ class ScopesPills < Plutonium::UI::Component::Base
8
+ def view_template
9
+ return if scopes.empty?
10
+
11
+ nav(role: "tablist",
12
+ aria: {label: "Scope"},
13
+ class: "flex items-center gap-1 px-4 py-2 border-b border-[var(--pu-border)]") do
14
+ render_all_pill
15
+ scopes.each_key { |key| render_pill(key) }
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def render_all_pill
22
+ active = all_scope_active?
23
+ a(
24
+ id: "all-scope",
25
+ href: current_query_object.build_url(scope: nil),
26
+ role: "tab",
27
+ aria: {selected: active},
28
+ class: pill_classes(active)
29
+ ) { "All" }
30
+ end
31
+
32
+ def render_pill(key)
33
+ active = current_query_object.selected_scope.to_s == key.to_s
34
+ label = key.to_s.humanize
35
+
36
+ a(
37
+ id: "#{key}-scope",
38
+ href: current_query_object.build_url(scope: key),
39
+ role: "tab",
40
+ aria: {selected: active},
41
+ class: pill_classes(active)
42
+ ) { label }
43
+ end
44
+
45
+ def pill_classes(active)
46
+ base = "px-3 py-1 rounded-md text-sm transition-colors"
47
+ state = if active
48
+ "bg-primary-100 text-primary-700 dark:bg-primary-950/40 dark:text-primary-300"
49
+ else
50
+ "text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)]"
51
+ end
52
+ "#{base} #{state}"
53
+ end
54
+
55
+ def all_scope_active?
56
+ current_query_object.all_scope_selected? ||
57
+ (!raw_resource_query_params.key?(:scope) && current_query_object.default_scope_name.blank?)
58
+ end
59
+
60
+ def scopes
61
+ @scopes ||= current_query_object.scope_definitions
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -15,21 +15,12 @@ module Plutonium
15
15
  SelectionDataCell.new(wrapped_object.field(value_key).dom.value, allowed_actions)
16
16
  end
17
17
 
18
- # Add hidden class and Stimulus target to header cell
19
18
  def header_cell_attributes
20
- {
21
- class: "hidden w-12",
22
- data: {bulk_actions_target: "selectionCell"}
23
- }
19
+ {class: "w-12"}
24
20
  end
25
21
 
26
- # Add hidden class and Stimulus target to data cell
27
22
  def data_cell_attributes(wrapped_object)
28
- {
29
- scope: :row,
30
- class: "hidden",
31
- data: {bulk_actions_target: "selectionCell"}
32
- }
23
+ {scope: :row}
33
24
  end
34
25
 
35
26
  private
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Table
6
+ module Components
7
+ # Modern index toolbar combining view switcher, filter/group controls,
8
+ # inline search, and column config / overflow icon buttons into a single
9
+ # tight strip rendered above the table when shell == :modern.
10
+ class Toolbar < Plutonium::UI::Component::Base
11
+ def initialize(query:, search_url:, search_param: :q, search_value: nil, views: [:table], current_view: :table, view_cookie_name: nil, view_cookie_path: "/")
12
+ @query = query
13
+ @search_url = search_url
14
+ @search_param = search_param
15
+ @search_value = search_value
16
+ @views = views
17
+ @current_view = current_view
18
+ @view_cookie_name = view_cookie_name
19
+ @view_cookie_path = view_cookie_path
20
+ end
21
+
22
+ def render?
23
+ @views.size > 1 || has_filters? || has_search?
24
+ end
25
+
26
+ def view_template
27
+ div(class: "flex items-center gap-2 px-4 py-2 border-b border-[var(--pu-border)] bg-[var(--pu-surface-alt)]") do
28
+ switcher = ViewSwitcher.new(views: @views, current: @current_view, cookie_name: @view_cookie_name, cookie_path: @view_cookie_path)
29
+ render switcher
30
+ render_divider if switcher.render?
31
+ render_filter_button
32
+ div(class: "flex-1")
33
+ render_search if has_search?
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def has_filters?
40
+ @query && @query.filter_definitions.present?
41
+ end
42
+
43
+ def has_search?
44
+ @query && @query.search_filter.present?
45
+ end
46
+
47
+ def active_filter_count
48
+ @query ? @query.active_filter_descriptions.size : 0
49
+ end
50
+
51
+ def render_divider
52
+ div(class: "w-px h-5 bg-[var(--pu-border)]")
53
+ end
54
+
55
+ def render_filter_button
56
+ return unless has_filters?
57
+
58
+ count = active_filter_count
59
+ button(
60
+ type: "button",
61
+ class: "pu-btn pu-btn-outline pu-btn-sm",
62
+ data: {action: "click->filter-panel#toggle"}
63
+ ) do
64
+ render Phlex::TablerIcons::AdjustmentsHorizontal.new(class: "w-4 h-4 shrink-0")
65
+ span { "Filter" }
66
+ if count > 0
67
+ span(class: "ml-1 inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 " \
68
+ "rounded-full bg-primary-600 text-white text-[10px] font-semibold leading-none") do
69
+ plain count.to_s
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ def render_search
76
+ form(method: :get, action: @search_url) do
77
+ div(class: "relative") do
78
+ div(class: "absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none") do
79
+ render Phlex::TablerIcons::Search.new(class: "w-4 h-4 text-[var(--pu-text-muted)]")
80
+ end
81
+ input(
82
+ id: "pu-toolbar-search",
83
+ type: "search",
84
+ name: "#{@search_param}[search]",
85
+ value: @search_value,
86
+ placeholder: "Search...",
87
+ class: "pu-input pu-input-toolbar pu-input-icon-left w-[220px]",
88
+ # turbo-permanent + a stable id keep the DOM node
89
+ # across Turbo morphs so focus / caret / IME state
90
+ # survive the search-as-you-type submit cycle.
91
+ data: {
92
+ controller: "autosubmit",
93
+ action: "input->autosubmit#submit search->autosubmit#submit",
94
+ turbo_permanent: true
95
+ }
96
+ )
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end