plutonium 0.34.1 → 0.35.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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/skill.md +53 -0
  3. data/.claude/skills/{assets → plutonium-assets}/SKILL.md +13 -8
  4. data/.claude/skills/{connect-resource → plutonium-connect-resource}/SKILL.md +1 -1
  5. data/.claude/skills/{controller → plutonium-controller}/SKILL.md +27 -13
  6. data/.claude/skills/{create-resource → plutonium-create-resource}/SKILL.md +1 -1
  7. data/.claude/skills/{definition → plutonium-definition}/SKILL.md +10 -10
  8. data/.claude/skills/{definition-actions → plutonium-definition-actions}/SKILL.md +34 -9
  9. data/.claude/skills/{definition-fields → plutonium-definition-fields}/SKILL.md +38 -10
  10. data/.claude/skills/plutonium-definition-query/SKILL.md +356 -0
  11. data/.claude/skills/{forms → plutonium-forms}/SKILL.md +6 -6
  12. data/.claude/skills/{installation → plutonium-installation}/SKILL.md +9 -9
  13. data/.claude/skills/{interaction → plutonium-interaction}/SKILL.md +20 -19
  14. data/.claude/skills/{model → plutonium-model}/SKILL.md +3 -3
  15. data/.claude/skills/{model-features → plutonium-model-features}/SKILL.md +3 -3
  16. data/.claude/skills/{nested-resources → plutonium-nested-resources}/SKILL.md +5 -5
  17. data/.claude/skills/{package → plutonium-package}/SKILL.md +7 -8
  18. data/.claude/skills/{policy → plutonium-policy}/SKILL.md +26 -4
  19. data/.claude/skills/{portal → plutonium-portal}/SKILL.md +33 -31
  20. data/.claude/skills/{resource → plutonium-resource}/SKILL.md +27 -27
  21. data/.claude/skills/{rodauth → plutonium-rodauth}/SKILL.md +5 -5
  22. data/.claude/skills/plutonium-theming/SKILL.md +424 -0
  23. data/.claude/skills/{views → plutonium-views}/SKILL.md +7 -7
  24. data/CHANGELOG.md +52 -0
  25. data/CLAUDE.md +215 -0
  26. data/CONTRIBUTING.md +72 -18
  27. data/README.md +100 -19
  28. data/app/assets/plutonium.css +1 -11
  29. data/app/assets/plutonium.js +1685 -1146
  30. data/app/assets/plutonium.js.map +4 -4
  31. data/app/assets/plutonium.min.js +70 -70
  32. data/app/assets/plutonium.min.js.map +4 -4
  33. data/app/views/resource/interactive_bulk_action.html.erb +1 -5
  34. data/app/views/rodauth/_email_auth_request_form.html.erb +1 -1
  35. data/app/views/rodauth/_login_form.html.erb +15 -55
  36. data/app/views/rodauth/_login_form_footer.html.erb +2 -2
  37. data/app/views/rodauth/_password_visibility.html.erb +2 -8
  38. data/app/views/rodauth/add_recovery_codes.html.erb +2 -2
  39. data/app/views/rodauth/change_login.html.erb +36 -19
  40. data/app/views/rodauth/change_password.html.erb +34 -10
  41. data/app/views/rodauth/close_account.html.erb +12 -4
  42. data/app/views/rodauth/confirm_password.html.erb +19 -17
  43. data/app/views/rodauth/create_account.html.erb +30 -109
  44. data/app/views/rodauth/email_auth.html.erb +1 -1
  45. data/app/views/rodauth/logout.html.erb +4 -4
  46. data/app/views/rodauth/otp_auth.html.erb +13 -4
  47. data/app/views/rodauth/otp_disable.html.erb +12 -4
  48. data/app/views/rodauth/otp_setup.html.erb +29 -12
  49. data/app/views/rodauth/otp_unlock.html.erb +19 -10
  50. data/app/views/rodauth/otp_unlock_not_available.html.erb +7 -7
  51. data/app/views/rodauth/recovery_auth.html.erb +12 -4
  52. data/app/views/rodauth/recovery_codes.html.erb +12 -4
  53. data/app/views/rodauth/remember.html.erb +7 -7
  54. data/app/views/rodauth/reset_password.html.erb +23 -7
  55. data/app/views/rodauth/reset_password_request.html.erb +14 -10
  56. data/app/views/rodauth/sms_auth.html.erb +13 -4
  57. data/app/views/rodauth/sms_confirm.html.erb +13 -4
  58. data/app/views/rodauth/sms_disable.html.erb +12 -4
  59. data/app/views/rodauth/sms_request.html.erb +1 -1
  60. data/app/views/rodauth/sms_setup.html.erb +23 -7
  61. data/app/views/rodauth/two_factor_auth.html.erb +2 -2
  62. data/app/views/rodauth/two_factor_disable.html.erb +12 -4
  63. data/app/views/rodauth/two_factor_manage.html.erb +7 -7
  64. data/app/views/rodauth/unlock_account.html.erb +13 -5
  65. data/app/views/rodauth/unlock_account_request.html.erb +2 -2
  66. data/app/views/rodauth/verify_account.html.erb +25 -7
  67. data/app/views/rodauth/verify_account_resend.html.erb +14 -10
  68. data/app/views/rodauth/verify_login_change.html.erb +1 -1
  69. data/app/views/rodauth/webauthn_auth.html.erb +1 -1
  70. data/app/views/rodauth/webauthn_remove.html.erb +18 -8
  71. data/app/views/rodauth/webauthn_setup.html.erb +12 -4
  72. data/docs/.vitepress/config.ts +15 -26
  73. data/docs/.vitepress/theme/custom.css +388 -29
  74. data/docs/getting-started/index.md +1 -1
  75. data/docs/getting-started/tutorial/02-first-resource.md +9 -0
  76. data/docs/getting-started/tutorial/06-nested-resources.md +2 -2
  77. data/docs/getting-started/tutorial/07-author-portal.md +191 -0
  78. data/docs/getting-started/tutorial/{07-customizing-ui.md → 08-customizing-ui.md} +7 -7
  79. data/docs/getting-started/tutorial/index.md +5 -2
  80. data/docs/guides/authorization.md +33 -0
  81. data/docs/guides/creating-packages.md +12 -16
  82. data/docs/guides/custom-actions.md +36 -0
  83. data/docs/guides/search-filtering.md +121 -42
  84. data/docs/guides/theming.md +232 -36
  85. data/docs/index.md +203 -57
  86. data/docs/public/og-image.png +0 -0
  87. data/docs/reference/controller/index.md +14 -16
  88. data/docs/reference/definition/actions.md +38 -3
  89. data/docs/reference/definition/fields.md +3 -3
  90. data/docs/reference/definition/index.md +2 -2
  91. data/docs/reference/generators/index.md +0 -1
  92. data/docs/reference/interaction/index.md +14 -10
  93. data/docs/reference/model/index.md +0 -1
  94. data/docs/reference/portal/index.md +13 -27
  95. data/gemfiles/rails_7.gemfile.lock +1 -1
  96. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  97. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  98. data/lib/generators/pu/pkg/portal/portal_generator.rb +0 -2
  99. data/lib/generators/pu/pkg/portal/templates/app/views/package/dashboard/index.html.erb +28 -72
  100. data/lib/plutonium/action/interactive.rb +2 -2
  101. data/lib/plutonium/core/controller.rb +2 -1
  102. data/lib/plutonium/definition/actions.rb +2 -2
  103. data/lib/plutonium/lib/deep_freezer.rb +3 -7
  104. data/lib/plutonium/query/filter.rb +14 -0
  105. data/lib/plutonium/query/filters/association.rb +49 -0
  106. data/lib/plutonium/query/filters/boolean.rb +35 -0
  107. data/lib/plutonium/query/filters/date.rb +97 -0
  108. data/lib/plutonium/query/filters/date_range.rb +58 -0
  109. data/lib/plutonium/query/filters/select.rb +55 -0
  110. data/lib/plutonium/resource/controllers/crud_actions.rb +24 -6
  111. data/lib/plutonium/resource/controllers/interactive_actions.rb +76 -58
  112. data/lib/plutonium/resource/controllers/queryable.rb +4 -2
  113. data/lib/plutonium/resource/query_object.rb +1 -1
  114. data/lib/plutonium/ui/action_button.rb +23 -65
  115. data/lib/plutonium/ui/actions_dropdown.rb +103 -0
  116. data/lib/plutonium/ui/block.rb +1 -1
  117. data/lib/plutonium/ui/breadcrumbs.rb +12 -19
  118. data/lib/plutonium/ui/color_mode_selector.rb +1 -1
  119. data/lib/plutonium/ui/component/kit.rb +6 -0
  120. data/lib/plutonium/ui/component_classes.rb +102 -0
  121. data/lib/plutonium/ui/display/base.rb +15 -0
  122. data/lib/plutonium/ui/display/components/attachment.rb +6 -5
  123. data/lib/plutonium/ui/display/components/boolean.rb +23 -0
  124. data/lib/plutonium/ui/display/components/color.rb +23 -0
  125. data/lib/plutonium/ui/display/resource.rb +1 -1
  126. data/lib/plutonium/ui/display/theme.rb +29 -15
  127. data/lib/plutonium/ui/empty_card.rb +3 -3
  128. data/lib/plutonium/ui/form/base.rb +20 -0
  129. data/lib/plutonium/ui/form/components/key_value_store.rb +11 -11
  130. data/lib/plutonium/ui/form/components/resource_select.rb +31 -0
  131. data/lib/plutonium/ui/form/components/secure_association.rb +1 -2
  132. data/lib/plutonium/ui/form/components/uppy.rb +5 -4
  133. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +4 -4
  134. data/lib/plutonium/ui/form/interaction.rb +17 -1
  135. data/lib/plutonium/ui/form/query.rb +133 -80
  136. data/lib/plutonium/ui/form/theme.rb +50 -35
  137. data/lib/plutonium/ui/frame_navigator_panel.rb +2 -2
  138. data/lib/plutonium/ui/layout/base.rb +1 -1
  139. data/lib/plutonium/ui/layout/header.rb +4 -7
  140. data/lib/plutonium/ui/layout/rodauth_layout.rb +7 -7
  141. data/lib/plutonium/ui/layout/sidebar.rb +1 -1
  142. data/lib/plutonium/ui/nav_grid_menu.rb +7 -6
  143. data/lib/plutonium/ui/nav_user.rb +9 -8
  144. data/lib/plutonium/ui/page/interactive_action.rb +5 -5
  145. data/lib/plutonium/ui/page_header.rb +29 -10
  146. data/lib/plutonium/ui/panel.rb +4 -4
  147. data/lib/plutonium/ui/sidebar_menu.rb +8 -8
  148. data/lib/plutonium/ui/skeleton_table.rb +7 -8
  149. data/lib/plutonium/ui/tab_list.rb +5 -5
  150. data/lib/plutonium/ui/table/base.rb +3 -0
  151. data/lib/plutonium/ui/table/components/attachment.rb +4 -3
  152. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +82 -0
  153. data/lib/plutonium/ui/table/components/pagy_info.rb +2 -2
  154. data/lib/plutonium/ui/table/components/pagy_pagination.rb +13 -8
  155. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +101 -0
  156. data/lib/plutonium/ui/table/components/scopes_bar.rb +2 -2
  157. data/lib/plutonium/ui/table/components/selection_column.rb +100 -0
  158. data/lib/plutonium/ui/table/display_theme.rb +6 -6
  159. data/lib/plutonium/ui/table/resource.rb +93 -52
  160. data/lib/plutonium/ui/table/theme.rb +28 -15
  161. data/lib/plutonium/version.rb +1 -1
  162. data/package.json +2 -2
  163. data/plutonium.gemspec +5 -4
  164. data/src/css/components.css +471 -0
  165. data/src/css/intl_tel_input.css +2 -2
  166. data/src/css/plutonium.css +2 -0
  167. data/src/css/tokens.css +149 -0
  168. data/src/js/controllers/bulk_actions_controller.js +109 -0
  169. data/src/js/controllers/filter_panel_controller.js +35 -0
  170. data/src/js/controllers/register_controllers.js +5 -1
  171. data/src/js/controllers/resource_drop_down_controller.js +25 -1
  172. data/src/js/controllers/slim_select_controller.js +6 -2
  173. data/src/js/turbo/turbo_actions.js +1 -1
  174. metadata +52 -39
  175. data/.claude/skills/definition-query/SKILL.md +0 -334
  176. data/docs/concepts/architecture.md +0 -226
  177. data/docs/concepts/auto-detection.md +0 -254
  178. data/docs/concepts/index.md +0 -61
  179. data/docs/concepts/packages-portals.md +0 -304
  180. data/docs/concepts/resources.md +0 -224
  181. data/docs/cookbook/blog.md +0 -411
  182. data/docs/cookbook/index.md +0 -289
  183. data/docs/cookbook/saas.md +0 -481
  184. data/docs/public/CLAUDE.md +0 -578
  185. data/lib/generators/pu/pkg/portal/templates/app/controllers/resource_controller.rb.tt +0 -5
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Table
6
+ module Components
7
+ class BulkActionsToolbar < Plutonium::UI::Component::Base
8
+ include Phlex::Rails::Helpers::LinkTo
9
+
10
+ # Color to CSS class mapping for soft button variants
11
+ COLOR_CLASSES = {
12
+ primary: "pu-btn-soft-primary",
13
+ success: "pu-btn-soft-success",
14
+ warning: "pu-btn-soft-warning",
15
+ danger: "pu-btn-soft-danger",
16
+ info: "pu-btn-soft-info",
17
+ accent: "pu-btn-soft-accent",
18
+ secondary: "pu-btn-soft-secondary"
19
+ }.freeze
20
+
21
+ def initialize(bulk_actions:)
22
+ @bulk_actions = bulk_actions
23
+ end
24
+
25
+ def view_template
26
+ # Always render toolbar - hidden by default, Stimulus shows it when items are selected
27
+ div(
28
+ class: "hidden flex pu-toolbar",
29
+ data: {bulk_actions_target: "toolbar"}
30
+ ) do
31
+ render_selected_count
32
+ render_action_buttons
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def render_selected_count
39
+ span(class: "pu-toolbar-text") do
40
+ span(data: {bulk_actions_target: "selectedCount"}) { "0" }
41
+ plain " selected"
42
+ end
43
+ end
44
+
45
+ def render_action_buttons
46
+ div(class: "pu-toolbar-actions") do
47
+ @bulk_actions.each do |action|
48
+ render_action_button(action)
49
+ end
50
+ end
51
+ end
52
+
53
+ def render_action_button(action)
54
+ url = route_options_to_url(action.route_options, resource_class)
55
+
56
+ link_to(
57
+ url,
58
+ class: button_classes(action),
59
+ data: {
60
+ bulk_actions_target: "actionButton",
61
+ bulk_action_name: action.name,
62
+ bulk_action_url: url,
63
+ turbo_frame: action.turbo_frame
64
+ }
65
+ ) do
66
+ if action.icon
67
+ render action.icon.new(class: "h-4 w-4")
68
+ end
69
+ span { action.label }
70
+ end
71
+ end
72
+
73
+ def button_classes(action)
74
+ color_key = (action.color || action.category)&.to_sym || :primary
75
+ color_class = COLOR_CLASSES[color_key] || COLOR_CLASSES[:primary]
76
+ "pu-btn pu-btn-sm #{color_class}"
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -13,7 +13,7 @@ module Plutonium
13
13
  end
14
14
 
15
15
  def view_template
16
- div(class: "flex flex-col md:flex-row justify-between items-center text-sm text-gray-500 dark:text-gray-400") do
16
+ div(class: "flex flex-col md:flex-row justify-between items-center text-sm text-[var(--pu-text-muted)]") do
17
17
  results_info
18
18
  per_page_selector
19
19
  end
@@ -53,7 +53,7 @@ module Plutonium
53
53
  end
54
54
 
55
55
  def select_classes
56
- "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 min-w-[5em]"
56
+ "bg-[var(--pu-surface)] border border-[var(--pu-border)] text-[var(--pu-text)] text-sm rounded-[var(--pu-radius-md)] focus:ring-2 focus:ring-primary-500 focus:border-primary-500 block p-2.5 min-w-[5em]"
57
57
  end
58
58
 
59
59
  def page_url(limit)
@@ -12,8 +12,8 @@ module Plutonium
12
12
  end
13
13
 
14
14
  def view_template
15
- nav(aria_label: "Page navigation", class: "flex justify-center mt-4") do
16
- ul(class: "inline-flex -space-x-px text-sm") do
15
+ nav(aria_label: "Page navigation", class: "flex justify-center mt-6") do
16
+ ul(class: "inline-flex items-center gap-1 text-sm") do
17
17
  prev_link
18
18
  page_links
19
19
  next_link
@@ -79,19 +79,24 @@ module Plutonium
79
79
  end
80
80
 
81
81
  def link_classes(first = false, last = false)
82
- classes = ["flex", "items-center", "justify-center", "px-3", "h-8", "leading-tight", "text-gray-500", "bg-white", "border", "border-gray-300", "hover:bg-gray-100", "hover:text-gray-700", "dark:bg-gray-800", "dark:border-gray-700", "dark:text-gray-400", "dark:hover:bg-gray-700", "dark:hover:text-white"]
83
- classes << "rounded-s-lg" if first
84
- classes << "rounded-e-lg" if last
82
+ base = "flex items-center justify-center w-9 h-9 text-[var(--pu-text-muted)] bg-[var(--pu-surface)] border border-[var(--pu-border)] hover:bg-[var(--pu-surface-alt)] hover:text-[var(--pu-text)] transition-colors"
83
+ classes = [base]
84
+ classes << "rounded-l-lg" if first
85
+ classes << "rounded-r-lg" if last
86
+ classes << "rounded-lg" if !first && !last
85
87
  classes.join(" ")
86
88
  end
87
89
 
88
90
  def current_link_classes
89
- "flex items-center justify-center px-3 h-8 text-blue-600 border border-gray-300 bg-blue-50 hover:bg-blue-100 hover:text-blue-700 dark:border-gray-700 dark:bg-gray-700 dark:text-white cursor-not-allowed"
91
+ "flex items-center justify-center w-9 h-9 text-white bg-primary-600 border border-primary-600 rounded-lg font-medium cursor-default"
90
92
  end
91
93
 
92
94
  def disabled_link_classes(first = false, last = false)
93
- classes = link_classes(first, last).split
94
- classes << "opacity-50" << "cursor-not-allowed"
95
+ base = "flex items-center justify-center w-9 h-9 text-[var(--pu-text-subtle)] bg-[var(--pu-surface-alt)] border border-[var(--pu-border)] opacity-50 cursor-not-allowed"
96
+ classes = [base]
97
+ classes << "rounded-l-lg" if first
98
+ classes << "rounded-r-lg" if last
99
+ classes << "rounded-lg" if !first && !last
95
100
  classes.join(" ")
96
101
  end
97
102
 
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Table
6
+ module Components
7
+ # Dropdown menu for secondary and danger row actions in tables
8
+ # Shows a compact icon trigger with grouped actions
9
+ class RowActionsDropdown < Plutonium::UI::Component::Base
10
+ def initialize(actions:, record:)
11
+ @actions = actions
12
+ @record = record
13
+ end
14
+
15
+ def view_template
16
+ div(class: "relative", data: {controller: "resource-drop-down"}) do
17
+ render_trigger_button
18
+ render_dropdown_menu
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def render_trigger_button
25
+ button(
26
+ type: "button",
27
+ class: "p-1.5 rounded-[var(--pu-radius-md)] border border-[var(--pu-border)] bg-[var(--pu-surface)] text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] hover:border-[var(--pu-border-strong)] transition-colors",
28
+ aria: {expanded: "false", haspopup: "true", label: "More actions"},
29
+ data: {resource_drop_down_target: "trigger"}
30
+ ) do
31
+ render Phlex::TablerIcons::DotsVertical.new(class: "w-4 h-4")
32
+ end
33
+ end
34
+
35
+ def render_dropdown_menu
36
+ div(
37
+ class: "hidden absolute right-0 z-50 mt-1 w-40 origin-top-right bg-[var(--pu-surface)] border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] overflow-hidden",
38
+ style: "box-shadow: var(--pu-shadow-lg)",
39
+ data: {resource_drop_down_target: "menu"}
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?
44
+ end
45
+ end
46
+
47
+ def render_secondary_actions
48
+ div(class: "py-1") do
49
+ secondary_actions.each { |action| render_action_item(action) }
50
+ end
51
+ end
52
+
53
+ def render_danger_divider
54
+ div(class: "border-t border-[var(--pu-border-muted)]")
55
+ end
56
+
57
+ def render_danger_actions
58
+ div(class: "py-1") do
59
+ danger_actions.each { |action| render_action_item(action, danger: true) }
60
+ end
61
+ end
62
+
63
+ def render_action_item(action, danger: false)
64
+ url = route_options_to_url(action.route_options, @record)
65
+
66
+ link_attrs = {
67
+ href: url,
68
+ class: tokens(
69
+ "flex items-center gap-2 px-3 py-1.5 text-sm transition-colors",
70
+ danger ? "text-danger-600 dark:text-danger-400 hover:bg-danger-50 dark:hover:bg-danger-900/30" : "text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)]"
71
+ )
72
+ }
73
+
74
+ # Add turbo frame if specified
75
+ link_attrs[:data] = {turbo_frame: action.turbo_frame} if action.turbo_frame
76
+
77
+ # Add confirmation if specified
78
+ if action.confirmation
79
+ link_attrs[:data] ||= {}
80
+ link_attrs[:data][:turbo_method] = action.route_options.method if action.route_options.method
81
+ link_attrs[:data][:turbo_confirm] = action.confirmation
82
+ end
83
+
84
+ a(**link_attrs) do
85
+ render action.icon.new(class: "w-4 h-4") if action.icon
86
+ span { action.label }
87
+ end
88
+ end
89
+
90
+ def secondary_actions
91
+ @secondary_actions ||= @actions.select { |a| a.category.secondary? }.sort_by(&:position)
92
+ end
93
+
94
+ def danger_actions
95
+ @danger_actions ||= @actions.select { |a| a.category.danger? }.sort_by(&:position)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -124,11 +124,11 @@ module Plutonium
124
124
  end
125
125
 
126
126
  def active_scope_class
127
- "px-4 py-2 text-sm font-medium text-white bg-primary-700 border border-primary-700 rounded-lg hover:bg-primary-800 focus:z-10 focus:ring-2 focus:ring-primary-700 focus:text-white dark:bg-primary-600 dark:border-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
127
+ "pu-btn pu-btn-sm pu-btn-primary"
128
128
  end
129
129
 
130
130
  def inactive_scope_class
131
- "px-4 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 hover:text-gray-700 focus:z-10 focus:ring-2 focus:ring-gray-300 focus:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-gray-700 dark:focus:text-white"
131
+ "pu-btn pu-btn-sm pu-btn-ghost"
132
132
  end
133
133
 
134
134
  def render?
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Table
6
+ module Components
7
+ # Custom selection column with Stimulus data attributes for bulk actions
8
+ class SelectionColumn < Phlexi::Table::Components::SelectionColumn
9
+ def header_cell
10
+ SelectionHeaderCell.new
11
+ end
12
+
13
+ def data_cell(wrapped_object)
14
+ allowed_actions = compute_allowed_actions(wrapped_object.unwrapped)
15
+ SelectionDataCell.new(wrapped_object.field(key).dom.value, allowed_actions)
16
+ end
17
+
18
+ # Add hidden class and Stimulus target to header cell
19
+ def header_cell_attributes
20
+ {
21
+ class: "hidden w-12",
22
+ data: {bulk_actions_target: "selectionCell"}
23
+ }
24
+ end
25
+
26
+ # Add hidden class and Stimulus target to data cell
27
+ def data_cell_attributes(wrapped_object)
28
+ {
29
+ scope: :row,
30
+ class: "hidden",
31
+ data: {bulk_actions_target: "selectionCell"}
32
+ }
33
+ end
34
+
35
+ private
36
+
37
+ def bulk_actions
38
+ options[:bulk_actions] || []
39
+ end
40
+
41
+ def policy_resolver
42
+ options[:policy_resolver]
43
+ end
44
+
45
+ def compute_allowed_actions(record)
46
+ return bulk_action_names unless policy_resolver
47
+
48
+ policy = policy_resolver.call(record)
49
+ bulk_actions.select { |action|
50
+ policy.allowed_to?(:"#{action.name}?")
51
+ }.map { |a| a.name.to_s }
52
+ end
53
+ end
54
+
55
+ # Header cell checkbox with "select all" functionality
56
+ class SelectionHeaderCell < Phlexi::Table::HTML
57
+ def view_template
58
+ input(
59
+ type: :checkbox,
60
+ class: themed(:selection_checkbox),
61
+ data: {
62
+ bulk_actions_target: "checkboxAll",
63
+ action: "bulk-actions#toggleAll"
64
+ }
65
+ )
66
+ end
67
+ end
68
+
69
+ # Data cell checkbox for individual row selection
70
+ class SelectionDataCell < Phlexi::Table::HTML
71
+ def initialize(value, allowed_actions)
72
+ @value = value
73
+ @allowed_actions = allowed_actions
74
+ end
75
+
76
+ def view_template
77
+ if @allowed_actions.empty?
78
+ # Show X when no actions available for this record
79
+ span(
80
+ class: "inline-flex items-center justify-center size-4 text-[var(--pu-text-subtle)]",
81
+ title: "No bulk actions available"
82
+ ) { "✕" }
83
+ else
84
+ input(
85
+ type: :checkbox,
86
+ value: @value,
87
+ class: themed(:selection_checkbox),
88
+ data: {
89
+ bulk_actions_target: "checkbox",
90
+ action: "bulk-actions#toggle",
91
+ allowed_actions: @allowed_actions.join(",")
92
+ }
93
+ )
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -7,13 +7,13 @@ module Plutonium
7
7
  def self.theme
8
8
  super.merge({
9
9
  value_wrapper: "max-h-[150px] overflow-y-auto",
10
- prefixed_icon: "w-4 h-4 mr-1",
11
- link: "text-primary-600 dark:text-primary-500",
10
+ prefixed_icon: "w-4 h-4 mr-1 text-[var(--pu-text-muted)]",
11
+ link: "text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300 transition-colors",
12
12
  color: "flex items-center",
13
- color_indicator: "w-10 h-10 rounded-full mr-2",
14
- email: "flex items-center text-primary-600 dark:text-primary-500 whitespace-nowrap",
15
- phone: "flex items-center text-primary-600 dark:text-primary-500 whitespace-nowrap",
16
- json: " whitespace-pre font-mono shadow-inner p-4",
13
+ color_indicator: "w-8 h-8 rounded-md mr-2 shadow-sm border border-[var(--pu-border)]",
14
+ email: "flex items-center gap-1 text-primary-600 dark:text-primary-400 hover:text-primary-500 whitespace-nowrap transition-colors",
15
+ phone: "flex items-center gap-1 text-primary-600 dark:text-primary-400 hover:text-primary-500 whitespace-nowrap transition-colors",
16
+ json: "whitespace-pre font-mono text-xs bg-[var(--pu-surface-alt)] border border-[var(--pu-border-muted)] rounded-[var(--pu-radius-sm)] p-2 overflow-x-auto",
17
17
  attachment_value_wrapper: "flex flex-wrap gap-1"
18
18
  })
19
19
  end
@@ -32,7 +32,7 @@ module Plutonium
32
32
  end
33
33
 
34
34
  def render_empty_card
35
- EmptyCard("No #{resource_name_plural(resource_class)} match your query") {
35
+ EmptyCard("No #{resource_name_plural(resource_class).downcase} available") {
36
36
  action = resource_definition.defined_actions[:new]
37
37
  if action&.permitted_by?(current_policy)
38
38
  url = route_options_to_url(action.route_options, resource_class)
@@ -42,70 +42,111 @@ module Plutonium
42
42
  end
43
43
 
44
44
  def render_table
45
- render Plutonium::UI::Table::Base.new(collection) do |table|
46
- @resource_fields.each do |name|
47
- # field :name, as: :string
48
- # column :description, class: "text-red-700"
49
- # column :age, align: :end
50
- # column :dob do |proxy|
51
- # proxy.field(:dob).date_tag
52
- # end
53
-
54
- field_options = resource_definition.defined_fields[name] ? resource_definition.defined_fields[name][:options].dup : {}
55
-
56
- display_definition = resource_definition.defined_displays[name] || {}
57
- display_options = display_definition[:options] || {}
58
-
59
- column_definition = resource_definition.defined_columns[name] || {}
60
- column_options = column_definition[:options] || {}
61
-
62
- # Check for conditional rendering
63
- condition = column_options[:condition]
64
- conditionally_hidden = condition && !instance_exec(&condition)
65
- next if conditionally_hidden
66
-
67
- tag = column_options[:as] || display_definition[:as] || field_options[:as]
68
-
69
- # Extract field-level options from display_options and column_options
70
- # These are Phlexi field options that should NOT be passed to the tag builder
71
- field_level_keys = [:label, :description, :placeholder]
72
- display_tag_attributes = display_options.except(:wrapper, :as, :condition, *field_level_keys)
73
- column_tag_attributes = column_options.except(:wrapper, :as, :align, :condition, *field_level_keys)
74
- tag_attributes = display_tag_attributes.merge(column_tag_attributes)
75
- tag_block = column_definition[:block] || ->(wrapped_object, key) {
76
- f = wrapped_object.field(key)
77
- tag ||= f.inferred_field_component
78
- f.send(:"#{tag}_tag", **tag_attributes)
79
- }
80
-
81
- # For table columns, only extract column-level options (label and align)
82
- # Field-level options like description and placeholder don't make sense in table cells
83
- field_options = field_options.except(:condition).merge(**column_options.slice(:align, :label))
84
- table.column name,
85
- **field_options,
86
- sort_params: current_query_object.sort_params_for(name),
87
- &tag_block
88
- end
45
+ # Wrap table in Stimulus controller for bulk actions
46
+ div(data: bulk_actions_controller_data) do
47
+ # Bulk actions toolbar (hidden by default, shown when items selected)
48
+ BulkActionsToolbar(bulk_actions:) if bulk_actions.any?
49
+
50
+ render Plutonium::UI::Table::Base.new(collection) do |table|
51
+ # Selection column for bulk actions (hidden by default, Stimulus shows it)
52
+ # Pass bulk actions and policy resolver for per-record authorization
53
+ table.selection_column :id,
54
+ bulk_actions:,
55
+ policy_resolver: ->(record) { policy_for(record:) }
56
+
57
+ @resource_fields.each do |name|
58
+ # field :name, as: :string
59
+ # column :description, class: "text-red-700"
60
+ # column :age, align: :end
61
+ # column :description do |record|
62
+ # record.description&.truncate(50)
63
+ # end
64
+
65
+ field_options = resource_definition.defined_fields[name] ? resource_definition.defined_fields[name][:options].dup : {}
66
+
67
+ display_definition = resource_definition.defined_displays[name] || {}
68
+ display_options = display_definition[:options] || {}
69
+
70
+ column_definition = resource_definition.defined_columns[name] || {}
71
+ column_options = column_definition[:options] || {}
72
+
73
+ # Check for conditional rendering
74
+ condition = column_options[:condition]
75
+ conditionally_hidden = condition && !instance_exec(&condition)
76
+ next if conditionally_hidden
77
+
78
+ tag = column_options[:as] || display_definition[:as] || field_options[:as]
79
+
80
+ # Extract field-level options from display_options and column_options
81
+ # These are Phlexi field options that should NOT be passed to the tag builder
82
+ field_level_keys = [:label, :description, :placeholder]
83
+ display_tag_attributes = display_options.except(:wrapper, :as, :condition, *field_level_keys)
84
+ column_tag_attributes = column_options.except(:wrapper, :as, :align, :condition, *field_level_keys)
85
+ tag_attributes = display_tag_attributes.merge(column_tag_attributes)
86
+ tag_block = if column_definition[:block]
87
+ # User-provided blocks receive the raw record for convenience
88
+ user_block = column_definition[:block]
89
+ ->(wrapped_object, _key) { user_block.call(wrapped_object.unwrapped) }
90
+ else
91
+ ->(wrapped_object, key) {
92
+ f = wrapped_object.field(key)
93
+ tag ||= f.inferred_field_component
94
+ f.send(:"#{tag}_tag", **tag_attributes)
95
+ }
96
+ end
97
+
98
+ # For table columns, only extract column-level options (label and align)
99
+ # Field-level options like description and placeholder don't make sense in table cells
100
+ field_options = field_options.except(:condition).merge(**column_options.slice(:align, :label))
101
+ table.column name,
102
+ **field_options,
103
+ sort_params: current_query_object.sort_params_for(name),
104
+ &tag_block
105
+ end
89
106
 
90
- table.actions do |wrapped_object|
91
- record = wrapped_object.unwrapped
92
- policy = policy_for(record:)
107
+ table.actions do |wrapped_object|
108
+ record = wrapped_object.unwrapped
109
+ policy = policy_for(record:)
93
110
 
94
- div(class: "flex space-x-2") do
95
- resource_definition.defined_actions
111
+ actions = resource_definition.defined_actions
96
112
  .select { |k, a| a.collection_record_action? && policy.allowed_to?(:"#{k}?") }
97
113
  .values
98
- .each do |action|
114
+
115
+ primary_actions = actions.select { |a| a.category.primary? }.sort_by(&:position)
116
+ dropdown_actions = actions.reject { |a| a.category.primary? }.sort_by(&:position)
117
+
118
+ div(class: "flex items-center gap-1") do
119
+ # Primary actions as buttons
120
+ primary_actions.each do |action|
99
121
  url = route_options_to_url(action.route_options, record)
100
122
  ActionButton(action, url:, variant: :table)
101
123
  end
124
+
125
+ # Secondary/danger actions in dropdown
126
+ if dropdown_actions.any?
127
+ RowActionsDropdown(actions: dropdown_actions, record:)
128
+ end
129
+ end
102
130
  end
103
131
  end
104
132
  end
105
133
  end
106
134
 
135
+ def bulk_actions
136
+ @bulk_actions ||= resource_definition.defined_actions
137
+ .select { |k, a| a.bulk_action? }
138
+ .values
139
+ end
140
+
141
+ def bulk_actions_controller_data
142
+ {
143
+ controller: "bulk-actions",
144
+ bulk_actions_has_actions_value: bulk_actions.any?
145
+ }
146
+ end
147
+
107
148
  def render_footer
108
- div(class: "lg:sticky lg:dyna:static bottom-[-2px] mt-1 p-4 pb-6 w-full z-30 bg-gray-50 dark:bg-gray-900") {
149
+ div(class: "lg:sticky lg:dyna:static bottom-[-2px] mt-1 p-4 pb-6 w-full z-30 bg-[var(--pu-body)]") {
109
150
  TableInfo(pagy_instance)
110
151
  TablePagination(pagy_instance)
111
152
  }
@@ -6,28 +6,41 @@ module Plutonium
6
6
  class Theme < Phlexi::Table::Theme
7
7
  def self.theme
8
8
  super.merge({
9
- selection_checkbox: "w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600",
10
- name_column: "font-medium text-gray-900 whitespace-nowrap dark:text-white",
9
+ # Selection
10
+ selection_checkbox: "pu-checkbox",
11
+ selection_header_cell: "pu-selection-cell",
12
+ selection_body_cell: "pu-selection-cell py-4",
13
+
14
+ # Column formatting
15
+ name_column: "font-medium text-[var(--pu-text)] whitespace-nowrap",
11
16
  align_start: "text-start",
12
17
  align_end: "text-end",
13
- wrapper: "relative overflow-x-auto shadow-md sm:rounded-lg",
14
- base: "w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400",
15
- caption: "p-5 text-lg font-semibold text-left rtl:text-right text-gray-900 bg-white dark:text-white dark:bg-gray-800",
16
- description: "mt-1 text-sm font-normal text-gray-500 dark:text-gray-400",
17
- header: "text-xs text-gray-700 uppercase bg-gray-200 dark:bg-gray-700 dark:text-gray-400",
18
- header_grouping_cell: "px-6 py-3 text-center text-sm border-b border-t border-r last:border-r-0 dark:border-gray-800",
19
- header_cell: "px-6 py-3",
18
+
19
+ # Table structure
20
+ wrapper: "pu-table-wrapper overflow-x-auto",
21
+ base: "pu-table rtl:text-right",
22
+ caption: "pu-panel-header text-lg font-semibold text-left rtl:text-right",
23
+ description: "mt-1 text-sm font-normal text-[var(--pu-text-muted)]",
24
+
25
+ # Header
26
+ header: "pu-table-header",
27
+ header_grouping_cell: "pu-table-header-cell text-center text-sm border-b border-t border-r last:border-r-0 border-[var(--pu-table-border)]",
28
+ header_cell: "pu-table-header-cell",
20
29
  header_cell_content_wrapper: "inline-flex items-center",
21
30
  header_cell_sort_wrapper: "flex items-center",
22
31
  header_cell_sort_indicator: "ml-1.5",
23
- body_row: "bg-white border-b last:border-none dark:bg-gray-800 dark:border-gray-700",
24
- body_cell: "px-6 py-4 whitespace-pre max-w-[450px] overflow-hidden text-ellipsis transition-all duration-300 ease-in-out",
32
+
33
+ # Body
34
+ body_row: "pu-table-body-row",
35
+ body_cell: "pu-table-body-cell whitespace-pre max-w-[450px] overflow-hidden text-ellipsis",
36
+
37
+ # Sorting
25
38
  sort_icon: "w-3 h-3",
26
- sort_icon_active: "text-primary-600",
27
- sort_icon_inactive: "text-gray-600 dark:text-gray-500",
39
+ sort_icon_active: "text-primary-600 dark:text-primary-400",
40
+ sort_icon_inactive: "text-[var(--pu-text-subtle)]",
28
41
  sort_index_clear_link: "ml-2",
29
- sort_index_clear_link_text: "text-xs font-bold text-gray-600 dark:text-gray-500",
30
- sort_index_clear_link_icon: "ml-1 text-red-600"
42
+ sort_index_clear_link_text: "text-xs font-bold text-[var(--pu-text-subtle)]",
43
+ sort_index_clear_link_icon: "ml-1 text-danger-600 dark:text-danger-400"
31
44
  })
32
45
  end
33
46
  end
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.34.1"
2
+ VERSION = "0.35.0"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.34.1",
4
- "description": "Core assets for the Plutonium gem",
3
+ "version": "0.35.0",
4
+ "description": "Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
7
7
  "files": [