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
|
@@ -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-
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
49
|
+
def render_group(actions)
|
|
50
|
+
return if actions.empty?
|
|
48
51
|
div(class: "py-1") do
|
|
49
|
-
|
|
52
|
+
actions.each { |action| render_action_item(action) }
|
|
50
53
|
end
|
|
51
54
|
end
|
|
52
55
|
|
|
53
|
-
def
|
|
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
|