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
@@ -47,12 +47,12 @@ module Plutonium
47
47
  def render_header
48
48
  div(class: "key-value-store-header") do
49
49
  if attributes[:label]
50
- h3(class: "text-lg font-semibold text-gray-900 dark:text-white") do
50
+ h3(class: "text-lg font-semibold text-[var(--pu-text)]") do
51
51
  plain attributes[:label]
52
52
  end
53
53
  end
54
54
  if attributes[:description]
55
- p(class: "text-sm text-gray-500 dark:text-gray-400") do
55
+ p(class: "text-sm text-[var(--pu-text-muted)]") do
56
56
  plain attributes[:description]
57
57
  end
58
58
  end
@@ -69,7 +69,7 @@ module Plutonium
69
69
 
70
70
  def render_key_value_pair(key, value, index)
71
71
  div(
72
- class: "key-value-pair flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded",
72
+ class: "key-value-pair flex items-center gap-2 p-2 border border-[var(--pu-border)] rounded-[var(--pu-radius-sm)]",
73
73
  data_key_value_store_target: "pair"
74
74
  ) do
75
75
  # Key input
@@ -79,7 +79,7 @@ module Plutonium
79
79
  value: key,
80
80
  name: "#{field_name}[#{index}][key]",
81
81
  id: "#{field.dom.id}_#{index}_key",
82
- class: "flex-1 px-3 py-1 text-sm border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white",
82
+ class: "flex-1 px-3 py-1 text-sm border border-[var(--pu-border)] rounded-[var(--pu-radius-sm)] focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-[var(--pu-surface)] text-[var(--pu-text)]",
83
83
  data_key_value_store_target: "keyInput"
84
84
  )
85
85
 
@@ -90,14 +90,14 @@ module Plutonium
90
90
  value: value,
91
91
  name: "#{field_name}[#{index}][value]",
92
92
  id: "#{field.dom.id}_#{index}_value",
93
- class: "flex-1 px-3 py-1 text-sm border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white",
93
+ class: "flex-1 px-3 py-1 text-sm border border-[var(--pu-border)] rounded-[var(--pu-radius-sm)] focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-[var(--pu-surface)] text-[var(--pu-text)]",
94
94
  data_key_value_store_target: "valueInput"
95
95
  )
96
96
 
97
97
  # Remove button
98
98
  button(
99
99
  type: :button,
100
- class: "px-2 py-1 text-red-600 hover:text-red-800 focus:outline-none",
100
+ class: "px-2 py-1 text-danger-600 hover:text-danger-800 focus:outline-none transition-colors",
101
101
  data_action: "key-value-store#removePair"
102
102
  ) do
103
103
  plain "×"
@@ -110,7 +110,7 @@ module Plutonium
110
110
  button(
111
111
  type: :button,
112
112
  id: "#{field.dom.id}_add_button",
113
- class: "inline-flex items-center px-3 py-1 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-blue-900 dark:text-blue-300 dark:border-blue-700 dark:hover:bg-blue-800",
113
+ class: "pu-btn pu-btn-sm pu-btn-soft-primary",
114
114
  data: {
115
115
  action: "key-value-store#addPair",
116
116
  key_value_store_target: "addButton"
@@ -124,7 +124,7 @@ module Plutonium
124
124
  def render_template
125
125
  template(data_key_value_store_target: "template") do
126
126
  div(
127
- class: "key-value-pair flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded",
127
+ class: "key-value-pair flex items-center gap-2 p-2 border border-[var(--pu-border)] rounded-[var(--pu-radius-sm)]",
128
128
  data_key_value_store_target: "pair"
129
129
  ) do
130
130
  input(
@@ -132,7 +132,7 @@ module Plutonium
132
132
  placeholder: "Key",
133
133
  name: "#{field_name}[__INDEX__][key]",
134
134
  id: "#{field.dom.id}___INDEX___key",
135
- class: "flex-1 px-3 py-1 text-sm border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white",
135
+ class: "flex-1 px-3 py-1 text-sm border border-[var(--pu-border)] rounded-[var(--pu-radius-sm)] focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-[var(--pu-surface)] text-[var(--pu-text)]",
136
136
  data_key_value_store_target: "keyInput"
137
137
  )
138
138
 
@@ -141,13 +141,13 @@ module Plutonium
141
141
  placeholder: "Value",
142
142
  name: "#{field_name}[__INDEX__][value]",
143
143
  id: "#{field.dom.id}___INDEX___value",
144
- class: "flex-1 px-3 py-1 text-sm border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white",
144
+ class: "flex-1 px-3 py-1 text-sm border border-[var(--pu-border)] rounded-[var(--pu-radius-sm)] focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-[var(--pu-surface)] text-[var(--pu-text)]",
145
145
  data_key_value_store_target: "valueInput"
146
146
  )
147
147
 
148
148
  button(
149
149
  type: :button,
150
- class: "px-2 py-1 text-red-600 hover:text-red-800 focus:outline-none",
150
+ class: "px-2 py-1 text-danger-600 hover:text-danger-800 focus:outline-none transition-colors",
151
151
  data_action: "key-value-store#removePair"
152
152
  ) do
153
153
  plain "×"
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # Select for choosing a resource record
8
+ class ResourceSelect < Phlexi::Form::Components::Select
9
+ protected
10
+
11
+ def choices
12
+ @choices ||= begin
13
+ collection = attributes.delete(:choices) || @association_class&.all || []
14
+ build_choice_mapper(collection)
15
+ end
16
+ end
17
+
18
+ def build_attributes
19
+ super
20
+ @association_class = attributes.delete(:association_class)
21
+ end
22
+
23
+ # Use include_blank string as blank option text (Phlexi default uses placeholder)
24
+ def blank_option_text
25
+ @include_blank.is_a?(String) ? @include_blank : super
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -23,8 +23,7 @@ module Plutonium
23
23
 
24
24
  a(
25
25
  href: add_url,
26
- class:
27
- "bg-gray-100 dark:bg-gray-600 dark:hover:bg-gray-700 dark:border-gray-500 hover:bg-gray-200 border border-gray-300 rounded-lg p-3 focus:ring-gray-100 dark:focus:ring-gray-700 focus:ring-2 focus:outline-none dark:text-white"
26
+ class: "bg-[var(--pu-surface-alt)] hover:bg-[var(--pu-border)] border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] p-3 focus:ring-2 focus:ring-[var(--pu-border)] focus:outline-none text-[var(--pu-text)] transition-colors"
28
27
  ) do
29
28
  render Phlex::TablerIcons::Plus.new(class: "w-3 h-3")
30
29
  end
@@ -67,7 +67,8 @@ module Plutonium
67
67
  end
68
68
 
69
69
  div(
70
- class: "attachment-preview group relative bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:shadow-md transition-all duration-300",
70
+ class: "attachment-preview group relative bg-[var(--pu-surface)] border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] hover:shadow-md transition-all duration-300",
71
+ style: "box-shadow: var(--pu-shadow-sm)",
71
72
  data: {
72
73
  controller: "attachment-preview",
73
74
  attachment_preview_mime_type_value: attachment.content_type,
@@ -99,7 +100,7 @@ module Plutonium
99
100
  )
100
101
  else
101
102
  div(
102
- class: "w-full h-full flex items-center justify-center bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400 font-mono"
103
+ class: "w-full h-full flex items-center justify-center bg-[var(--pu-surface-alt)] text-[var(--pu-text-muted)] font-mono"
103
104
  ) do
104
105
  ".#{attachment_extension(attachment)}"
105
106
  end
@@ -109,7 +110,7 @@ module Plutonium
109
110
 
110
111
  def render_filename(attachment)
111
112
  div(
112
- class: "px-2 py-1.5 text-sm text-gray-700 dark:text-gray-300 border-t border-gray-200 dark:border-gray-700 truncate text-center bg-white dark:bg-gray-800",
113
+ class: "px-2 py-1.5 text-sm text-[var(--pu-text-muted)] border-t border-[var(--pu-border)] truncate text-center bg-[var(--pu-surface)]",
113
114
  title: attachment.filename
114
115
  ) do
115
116
  plain attachment.filename.to_s
@@ -119,7 +120,7 @@ module Plutonium
119
120
  def render_delete_button
120
121
  button(
121
122
  type: "button",
122
- class: "w-full py-2 px-4 text-sm text-red-600 dark:text-red-400 bg-white dark:bg-gray-800 hover:bg-red-50 dark:hover:bg-red-900/50 rounded-b-lg transition-colors duration-200 flex items-center justify-center gap-2 border-t border-gray-200 dark:border-gray-700",
123
+ class: "w-full py-2 px-4 text-sm text-danger-600 dark:text-danger-400 bg-[var(--pu-surface)] hover:bg-danger-50 dark:hover:bg-danger-900/50 rounded-b-[var(--pu-radius-md)] transition-colors duration-200 flex items-center justify-center gap-2 border-t border-[var(--pu-border)]",
123
124
  data: {action: "click->attachment-preview#remove"}
124
125
  ) do
125
126
  span(class: "bi bi-trash")
@@ -143,13 +143,13 @@ module Plutonium
143
143
 
144
144
  def render_nested_field_header(context)
145
145
  div do
146
- h2(class: "text-lg font-semibold text-gray-900 dark:text-white") { context.name.to_s.humanize }
146
+ h2(class: "text-lg font-semibold text-[var(--pu-text)]") { context.name.to_s.humanize }
147
147
  render_nested_fields_header_description(context.options[:description]) if context.options[:description]
148
148
  end
149
149
  end
150
150
 
151
151
  def render_nested_fields_header_description(description)
152
- p(class: "text-md font-normal text-gray-500 dark:text-gray-400") { description }
152
+ p(class: "text-md font-normal text-[var(--pu-text-muted)]") { description }
153
153
  end
154
154
 
155
155
  def render_nested_field_content(context)
@@ -193,7 +193,7 @@ module Plutonium
193
193
  def render_nested_fields_fieldset(nested, context)
194
194
  fieldset(
195
195
  data_new_record: !nested.object&.persisted?,
196
- class: "nested-resource-form-fields border border-gray-200 dark:border-gray-700 rounded-lg p-4 space-y-4 relative"
196
+ class: "nested-resource-form-fields border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] p-4 space-y-4 relative"
197
197
  ) do
198
198
  render_nested_fields_fieldset_content(nested, context)
199
199
  render_nested_fields_delete_button(nested, context.options)
@@ -238,7 +238,7 @@ module Plutonium
238
238
  def render_nested_fields_delete_checkbox
239
239
  input(
240
240
  type: :checkbox,
241
- class: "w-4 h-4 ms-2 text-red-600 bg-red-100 border-red-300 rounded focus:ring-red-500 dark:focus:ring-red-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600 cursor-pointer",
241
+ class: "w-4 h-4 ms-2 text-danger-600 bg-danger-100 border-danger-300 rounded focus:ring-danger-500 dark:focus:ring-danger-600 focus:ring-2 dark:bg-[var(--pu-surface-alt)] dark:border-[var(--pu-border)] cursor-pointer",
242
242
  data_action: "nested-resource-form-fields#remove"
243
243
  )
244
244
  end
@@ -38,7 +38,7 @@ module Plutonium
38
38
  :commit_interactive_record_action
39
39
  when :interactive_resource_action
40
40
  :commit_interactive_resource_action
41
- when :interactive_collection_action
41
+ when :interactive_bulk_action
42
42
  :commit_interactive_bulk_action
43
43
  else
44
44
  action_name
@@ -47,9 +47,25 @@ module Plutonium
47
47
 
48
48
  def initialize_attributes
49
49
  super
50
+ attributes[:id] = :interaction_form
50
51
  attributes.fetch(:data_turbo) { attributes[:data_turbo] = object.turbo.to_s }
51
52
  end
52
53
 
54
+ def render_bulk_action_ids
55
+ action = helpers.current_interactive_action
56
+ return unless action&.bulk_action?
57
+
58
+ ids = Array(helpers.params[:ids])
59
+ ids.each do |id|
60
+ input(type: :hidden, name: "ids[]", value: id)
61
+ end
62
+ end
63
+
64
+ def form_template
65
+ render_bulk_action_ids
66
+ super
67
+ end
68
+
53
69
  def submit_button(*, **)
54
70
  super do
55
71
  object.label
@@ -11,7 +11,6 @@ module Plutonium
11
11
  options[:method] = :get
12
12
  attributes = mix(attributes.deep_merge(
13
13
  id: :search_form,
14
- class!: "space-y-2 mb-4",
15
14
  controller: "form",
16
15
  data: {controller: "form", turbo_frame: nil}
17
16
  ))
@@ -21,6 +20,10 @@ module Plutonium
21
20
  @page_size = page_size
22
21
  end
23
22
 
23
+ def form_class
24
+ "mb-4"
25
+ end
26
+
24
27
  def form_template
25
28
  render_fields
26
29
  end
@@ -28,9 +31,23 @@ module Plutonium
28
31
  private
29
32
 
30
33
  def render_fields
31
- render_search_fields
32
- render_filter_fields
33
- div hidden: true do # workaround the fact that input array does not accept other attributes for now
34
+ has_search = query_object.search_filter.present?
35
+ has_filters = query_object.filter_definitions.present?
36
+
37
+ if has_search || has_filters
38
+ div(class: "flex items-center gap-3") do
39
+ # Search takes remaining space
40
+ if has_search
41
+ render_search_field
42
+ else
43
+ div(class: "flex-1") # Spacer when no search
44
+ end
45
+ render_filter_button if has_filters
46
+ end
47
+ end
48
+
49
+ # Hidden fields for sorting, scope, etc.
50
+ div(hidden: true) do
34
51
  input(name: "limit", value: @page_size, type: :hidden, hidden: true) if @page_size
35
52
  render_sort_fields
36
53
  render_scope_fields
@@ -38,13 +55,11 @@ module Plutonium
38
55
  end
39
56
 
40
57
  def render_sort_fields
41
- # q[sort_fields][]=name&q[sort_fields][]=created_at
42
58
  field :sort_fields do |name|
43
59
  render name.input_array_tag do |array|
44
60
  render array.input_tag(type: :hidden, hidden: true)
45
61
  end
46
62
  end
47
- # q[sort_directions][created_at]=ASC&q[sort_directions][name]=ASC&
48
63
  nest_one :sort_directions do |nested|
49
64
  query_object.sort_definitions.each do |filter_name, definition|
50
65
  nested.field(filter_name) do |f|
@@ -55,118 +70,156 @@ module Plutonium
55
70
  end
56
71
 
57
72
  def render_scope_fields
58
- # q[scope]=&
59
73
  return if query_object.scope_definitions.blank?
60
74
 
61
75
  render field(:scope).input_tag(type: :hidden, hidden: true)
62
76
  end
63
77
 
64
- def render_search_fields
65
- # q[search]=&
66
- return unless query_object.search_filter
67
-
78
+ def render_search_field
68
79
  search_query = query_object.search_query
69
- div(class: "relative") do
80
+ div(class: "relative flex-1 min-w-0") do
70
81
  div(class: "absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none") do
71
- svg(
72
- class: "w-5 h-5 text-gray-500 dark:text-gray-400",
73
- aria_hidden: "true",
74
- fill: "currentColor",
75
- viewbox: "0 0 20 20",
76
- xmlns: "http://www.w3.org/2000/svg"
77
- ) do |s|
78
- s.path(
79
- fill_rule: "evenodd",
80
- d:
81
- "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z",
82
- clip_rule: "evenodd"
83
- )
84
- end
82
+ render Phlex::TablerIcons::Search.new(class: "w-5 h-5 text-[var(--pu-text-muted)]")
85
83
  end
86
84
  render field(:search, value: search_query)
87
85
  .placeholder("Search...")
88
86
  .input_tag(
89
87
  value: search_query,
90
- class: "block w-full p-2 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500",
91
- data: {
92
- action: "form#submit",
93
- turbo_permanent: true
94
- }
88
+ class: "pu-input pu-input-icon-left w-full",
89
+ data: {action: "form#submit", turbo_permanent: true}
95
90
  )
96
91
  end
97
92
  end
98
93
 
99
- def render_filter_fields
100
- return if query_object.filter_definitions.blank?
101
-
102
- div(class: "flex flex-wrap items-center gap-4") do
103
- span(class: "text-sm font-medium text-gray-900 dark:text-white") { "Filters:" }
104
- div(class: "flex flex-wrap items-center gap-4 mr-auto") do
105
- div class: "flex flex-wrap items-center gap-4" do
106
- query_object.filter_definitions.each do |filter_name, definition|
107
- nest_one filter_name do |nested|
108
- definition.defined_inputs.each do |input_name, _|
109
- render_defined_field nested, definition, input_name
110
- end
111
- end
94
+ def render_filter_button
95
+ active_count = count_active_filters
96
+
97
+ div(
98
+ class: "relative",
99
+ data: {controller: "resource-drop-down", resource_drop_down_placement_value: "left-start"}
100
+ ) do
101
+ # Filter button (trigger)
102
+ button(
103
+ type: "button",
104
+ class: "pu-btn pu-btn-secondary px-4 py-3 text-base",
105
+ data: {resource_drop_down_target: "trigger"}
106
+ ) do
107
+ render Phlex::TablerIcons::Filter.new(class: "w-4 h-4 inline-block align-text-bottom")
108
+ plain " Filters"
109
+ if active_count > 0
110
+ plain " "
111
+ span(class: "inline-flex items-center justify-center w-5 h-5 text-xs font-semibold rounded-full text-gray-800 bg-white") do
112
+ plain active_count.to_s
112
113
  end
113
114
  end
114
115
  end
115
- div(class: "flex flex-wrap items-center gap-2") do
116
- actions_wrapper do
117
- render field(:submit).submit_button_tag(
118
- name: nil,
119
- class!: "inline-flex items-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
120
- ) do
121
- render Phlex::TablerIcons::Filter.new(class: "w-4 h-4 mr-2")
122
- plain "Apply Filters"
123
- end
124
116
 
125
- render field(:reset).submit_button_tag(
126
- name: nil,
127
- type: :reset,
128
- class!: "inline-flex items-center text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg text-sm px-4 py-2 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700"
129
- ) do
130
- render Phlex::TablerIcons::X.new(class: "w-4 h-4 mr-2")
131
- plain "Clear Filters"
132
- end
133
- end
117
+ # Filter panel (dropdown menu)
118
+ # Mobile: fullscreen (override Popper with !important)
119
+ # Desktop: Popper positions the dropdown
120
+ div(
121
+ class: "hidden z-[100] bg-[var(--pu-surface)] shadow-lg flex flex-col " \
122
+ "max-md:!fixed max-md:!inset-0 max-md:!transform-none " \
123
+ "md:w-80 md:max-h-[70vh] md:border md:border-[var(--pu-border)] md:rounded-[var(--pu-radius-lg)]",
124
+ data: {resource_drop_down_target: "menu", controller: "filter-panel"},
125
+ aria_hidden: "true"
126
+ ) do
127
+ render_filter_panel
134
128
  end
135
129
  end
136
130
  end
137
131
 
138
- def form_action
139
- # query forms post to the same page
140
- nil
141
- end
132
+ def render_filter_panel
133
+ # Sticky header
134
+ div(class: "sticky top-0 z-10 flex items-center justify-between p-4 bg-[var(--pu-surface)] border-b border-[var(--pu-border)]") do
135
+ div(class: "flex items-center gap-3") do
136
+ # Close button (mobile only)
137
+ button(
138
+ type: "button",
139
+ class: "md:hidden p-1 text-[var(--pu-text-muted)] hover:text-[var(--pu-text)]",
140
+ data: {action: "resource-drop-down#hide"}
141
+ ) do
142
+ render Phlex::TablerIcons::X.new(class: "w-5 h-5")
143
+ end
144
+ span(class: "text-sm font-semibold text-[var(--pu-text)]") { "Filters" }
145
+ end
146
+ button(
147
+ type: "button",
148
+ class: "text-sm text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] transition-colors",
149
+ data: {action: "filter-panel#clear"}
150
+ ) { "Clear all" }
151
+ end
142
152
 
143
- def render_defined_field(nested, resource_definition, name)
144
- # field :name, as: :string
145
- # input :name, as: :string
146
- # input :description, wrapper: {class: "col-span-full"}
147
- # input :age, class: "max-h-fit"
148
- # input :dob do |f|
149
- # f.date_tag
150
- # end
153
+ # Scrollable filter fields
154
+ div(class: "p-4 overflow-y-auto flex-1 space-y-4") do
155
+ query_object.filter_definitions.each do |filter_name, definition|
156
+ nest_one filter_name do |nested|
157
+ inputs = definition.defined_inputs
158
+ has_multiple_inputs = inputs.size > 1
159
+ inputs.each do |input_name, _|
160
+ # For multi-input filters (like date range), include the input name in the label
161
+ label = if has_multiple_inputs
162
+ "#{filter_name.to_s.humanize} (#{input_name.to_s.humanize.downcase})"
163
+ else
164
+ filter_name.to_s.humanize
165
+ end
166
+ render_filter_field nested, definition, input_name, filter_label: label
167
+ end
168
+ end
169
+ end
170
+ end
151
171
 
152
- field_options = resource_definition.defined_fields[name] ? resource_definition.defined_fields[name][:options] : {}
172
+ # Sticky footer
173
+ div(class: "sticky bottom-0 z-10 p-4 bg-[var(--pu-surface)] border-t border-[var(--pu-border)]") do
174
+ render field(:submit).submit_button_tag(
175
+ name: nil,
176
+ class!: "pu-btn pu-btn-md pu-btn-primary w-full"
177
+ ) { "Apply Filters" }
178
+ end
179
+ end
153
180
 
154
- input_definition = definition.defined_inputs[name] || {}
181
+ def render_filter_field(nested, resource_definition, name, filter_label: nil)
182
+ input_definition = resource_definition.defined_inputs[name] || {}
155
183
  input_options = input_definition[:options] || {}
184
+ field_options = resource_definition.defined_fields[name] ? resource_definition.defined_fields[name][:options].dup : {}
156
185
 
157
186
  tag = input_options[:as] || field_options[:as]
158
187
  tag_attributes = input_options.except(:wrapper, :as)
188
+
159
189
  tag_block = input_definition[:block] || ->(f) {
160
190
  tag ||= f.inferred_field_component
161
- f.send(:"#{tag}_tag", **tag_attributes, class: tokens(tag_attributes[:class], "flex-1"))
191
+ f.send(:"#{tag}_tag", **tag_attributes, class: "w-full")
162
192
  }
163
193
 
164
194
  field_options = field_options.except(:as)
165
- nested.field(name, **field_options) do |f|
166
- f.placeholder(f.label) unless f.placeholder
167
- render instance_exec(f, &tag_block)
195
+
196
+ # Render with label
197
+ div(class: "space-y-1.5") do
198
+ label(class: "text-sm font-medium text-[var(--pu-text)]") { filter_label }
199
+ nested.field(name, **field_options) do |f|
200
+ # Set placeholder for blank option text in selects
201
+ f.placeholder(input_options[:include_blank] || "All") if input_options[:include_blank]
202
+ render instance_exec(f, &tag_block)
203
+ end
168
204
  end
169
205
  end
206
+
207
+ def count_active_filters
208
+ count = 0
209
+ query_object.filter_definitions.each do |filter_name, _|
210
+ filter_params = helpers.params.dig(:q, filter_name)
211
+ next unless filter_params.is_a?(Hash) || filter_params.is_a?(ActionController::Parameters)
212
+
213
+ filter_params.each_value do |v|
214
+ count += 1 if v.present?
215
+ end
216
+ end
217
+ count
218
+ end
219
+
220
+ def form_action
221
+ nil
222
+ end
170
223
  end
171
224
  end
172
225
  end