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,97 @@
1
+ module Plutonium
2
+ module Query
3
+ module Filters
4
+ # Date filter for date/datetime columns
5
+ #
6
+ # @example Filter by exact date
7
+ # filter :created_at, with: :date, predicate: :eq
8
+ #
9
+ # @example Filter by date before
10
+ # filter :due_date, with: :date, predicate: :lt
11
+ #
12
+ # @example Filter by date on or after
13
+ # filter :start_date, with: :date, predicate: :gteq
14
+ #
15
+ class Date < Filter
16
+ VALID_PREDICATES = [
17
+ :eq, # Equal (on this date)
18
+ :not_eq, # Not equal
19
+ :lt, # Less than (before)
20
+ :lteq, # Less than or equal (on or before)
21
+ :gt, # Greater than (after)
22
+ :gteq # Greater than or equal (on or after)
23
+ ].freeze
24
+
25
+ def initialize(predicate: :eq, **)
26
+ super(**)
27
+ unless VALID_PREDICATES.include?(predicate)
28
+ raise ArgumentError, "unsupported predicate #{predicate}. Valid predicates are: #{VALID_PREDICATES.join(", ")}"
29
+ end
30
+ @predicate = predicate
31
+ end
32
+
33
+ def apply(scope, value:)
34
+ return scope if value.blank?
35
+
36
+ date_value = parse_date(value)
37
+ return scope unless date_value
38
+
39
+ case @predicate
40
+ when :eq
41
+ scope.where(key => date_value.all_day)
42
+ when :not_eq
43
+ scope.where.not(key => date_value.all_day)
44
+ when :lt
45
+ scope.where(key => ...date_value.beginning_of_day)
46
+ when :lteq
47
+ scope.where(key => ..date_value.end_of_day)
48
+ when :gt
49
+ scope.where(key => (date_value.end_of_day + 1.second)..)
50
+ when :gteq
51
+ scope.where(key => date_value.beginning_of_day..)
52
+ else
53
+ raise NotImplementedError, "date filter predicate #{@predicate}"
54
+ end
55
+ end
56
+
57
+ def customize_inputs
58
+ input :value, as: :date
59
+ field :value, placeholder: generate_placeholder
60
+ end
61
+
62
+ private
63
+
64
+ def parse_date(value)
65
+ case value
66
+ when ::Date, ::DateTime, ::Time, ActiveSupport::TimeWithZone
67
+ value.to_date
68
+ when String
69
+ ::Date.parse(value)
70
+ end
71
+ rescue ArgumentError
72
+ nil
73
+ end
74
+
75
+ def generate_placeholder
76
+ base = key.to_s.humanize
77
+ case @predicate
78
+ when :eq
79
+ base
80
+ when :not_eq
81
+ "#{base} not on..."
82
+ when :lt
83
+ "#{base} before..."
84
+ when :lteq
85
+ "#{base} on or before..."
86
+ when :gt
87
+ "#{base} after..."
88
+ when :gteq
89
+ "#{base} on or after..."
90
+ else
91
+ base
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,58 @@
1
+ module Plutonium
2
+ module Query
3
+ module Filters
4
+ # DateRange filter for filtering between two dates
5
+ #
6
+ # @example Basic usage
7
+ # filter :created_at, with: :date_range
8
+ #
9
+ # @example With custom labels
10
+ # filter :published_at, with: :date_range, from_label: "Published from", to_label: "Published to"
11
+ #
12
+ class DateRange < Filter
13
+ def initialize(from_label: nil, to_label: nil, **)
14
+ super(**)
15
+ @from_label = from_label
16
+ @to_label = to_label
17
+ end
18
+
19
+ def apply(scope, from: nil, to: nil)
20
+ from_date = parse_date(from)
21
+ to_date = parse_date(to)
22
+
23
+ if from_date && to_date
24
+ scope.where(key => from_date.beginning_of_day..to_date.end_of_day)
25
+ elsif from_date
26
+ scope.where(key => from_date.beginning_of_day..)
27
+ elsif to_date
28
+ scope.where(key => ..to_date.end_of_day)
29
+ else
30
+ scope
31
+ end
32
+ end
33
+
34
+ def customize_inputs
35
+ input :from, as: :date
36
+ input :to, as: :date
37
+ field :from, placeholder: @from_label || "#{key.to_s.humanize} from..."
38
+ field :to, placeholder: @to_label || "#{key.to_s.humanize} to..."
39
+ end
40
+
41
+ private
42
+
43
+ def parse_date(value)
44
+ return nil if value.blank?
45
+
46
+ case value
47
+ when ::Date, ::DateTime, ::Time, ActiveSupport::TimeWithZone
48
+ value.to_date
49
+ when String
50
+ ::Date.parse(value)
51
+ end
52
+ rescue ArgumentError
53
+ nil
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,55 @@
1
+ module Plutonium
2
+ module Query
3
+ module Filters
4
+ # Select filter for choosing from a predefined collection of options
5
+ #
6
+ # @example Basic usage with array
7
+ # filter :status, with: :select, choices: %w[draft published archived]
8
+ #
9
+ # @example With proc for dynamic choices
10
+ # filter :category, with: :select, choices: -> { Category.pluck(:name, :id) }
11
+ #
12
+ # @example With multiple selection
13
+ # filter :tags, with: :select, choices: %w[ruby rails js], multiple: true
14
+ #
15
+ class Select < Filter
16
+ def initialize(choices: nil, multiple: false, **)
17
+ super(**)
18
+ @choices = choices
19
+ @multiple = multiple
20
+ end
21
+
22
+ def apply(scope, value:)
23
+ return scope if value.blank?
24
+
25
+ if @multiple && value.is_a?(Array)
26
+ scope.where(key => value.reject(&:blank?))
27
+ else
28
+ scope.where(key => value)
29
+ end
30
+ end
31
+
32
+ def customize_inputs
33
+ input :value,
34
+ as: :select,
35
+ choices: resolved_choices,
36
+ multiple: @multiple,
37
+ include_blank: @multiple ? false : "All"
38
+ end
39
+
40
+ private
41
+
42
+ def resolved_choices
43
+ case @choices
44
+ when Proc
45
+ @choices
46
+ when nil
47
+ []
48
+ else
49
+ @choices
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -53,9 +53,14 @@ module Plutonium
53
53
 
54
54
  respond_to do |format|
55
55
  if params[:pre_submit]
56
- format.any(:html, :turbo_stream) { render :new, formats: [:html], status: :unprocessable_content }
56
+ format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form)) }
57
+ format.html { render :new, status: :unprocessable_content }
57
58
  elsif resource_record!.save
58
- format.any(:html, :turbo_stream) do
59
+ format.turbo_stream do
60
+ flash.notice = "#{resource_class.model_name.human} was successfully created."
61
+ render turbo_stream: helpers.turbo_stream_redirect(redirect_url_after_submit)
62
+ end
63
+ format.html do
59
64
  redirect_to redirect_url_after_submit,
60
65
  notice: "#{resource_class.model_name.human} was successfully created."
61
66
  end
@@ -94,9 +99,14 @@ module Plutonium
94
99
 
95
100
  respond_to do |format|
96
101
  if params[:pre_submit]
97
- format.any(:html, :turbo_stream) { render :edit, formats: [:html], status: :unprocessable_content }
102
+ format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form)) }
103
+ format.html { render :edit, status: :unprocessable_content }
98
104
  elsif resource_record!.save
99
- format.any(:html, :turbo_stream) do
105
+ format.turbo_stream do
106
+ flash.notice = "#{resource_class.model_name.human} was successfully updated."
107
+ render turbo_stream: helpers.turbo_stream_redirect(redirect_url_after_submit)
108
+ end
109
+ format.html do
100
110
  redirect_to redirect_url_after_submit,
101
111
  notice: "#{resource_class.model_name.human} was successfully updated.",
102
112
  status: :see_other
@@ -121,13 +131,21 @@ module Plutonium
121
131
  respond_to do |format|
122
132
  resource_record!.destroy
123
133
 
124
- format.any(:html, :turbo_stream) do
134
+ format.turbo_stream do
135
+ flash.notice = "#{resource_class.model_name.human} was successfully deleted."
136
+ render turbo_stream: helpers.turbo_stream_redirect(redirect_url_after_destroy)
137
+ end
138
+ format.html do
125
139
  redirect_to redirect_url_after_destroy,
126
140
  notice: "#{resource_class.model_name.human} was successfully deleted."
127
141
  end
128
142
  format.json { head :no_content }
129
143
  rescue ActiveRecord::InvalidForeignKey
130
- format.any(:html, :turbo_stream) do
144
+ format.turbo_stream do
145
+ flash.alert = "#{resource_class.model_name.human} is referenced by other records."
146
+ render turbo_stream: helpers.turbo_stream_redirect(resource_url_for(resource_record!))
147
+ end
148
+ format.html do
131
149
  redirect_to resource_url_for(resource_record!),
132
150
  alert: "#{resource_class.model_name.human} is referenced by other records."
133
151
  end
@@ -18,9 +18,12 @@ module Plutonium
18
18
  ]
19
19
 
20
20
  before_action :authorize_interactive_resource_action!, only: %i[
21
- interactive_bulk_action commit_interactive_bulk_action
22
21
  interactive_resource_action commit_interactive_resource_action
23
22
  ]
23
+
24
+ before_action :authorize_interactive_bulk_action!, only: %i[
25
+ interactive_bulk_action commit_interactive_bulk_action
26
+ ]
24
27
  end
25
28
 
26
29
  # GET /resources/1/record_actions/:interactive_action
@@ -39,7 +42,10 @@ module Plutonium
39
42
  build_interactive_record_action_interaction
40
43
 
41
44
  if params[:pre_submit]
42
- render :interactive_record_action, formats: [:html], status: :unprocessable_content
45
+ respond_to do |format|
46
+ format.turbo_stream { render turbo_stream: turbo_stream.replace("interaction-form", view_context.render(@interaction.build_form)) }
47
+ format.html { render :interactive_record_action, formats: [:html], status: :unprocessable_content }
48
+ end
43
49
  return
44
50
  end
45
51
 
@@ -50,15 +56,12 @@ module Plutonium
50
56
  if outcome.success?
51
57
  return_url = redirect_url_after_action_on(resource_record!)
52
58
 
53
- if helpers.current_turbo_frame == "remote_modal"
54
- format.turbo_stream do
55
- render turbo_stream: [
56
- helpers.turbo_stream_redirect(return_url)
57
- ]
58
- end
59
+ format.turbo_stream do
60
+ render turbo_stream: helpers.turbo_stream_redirect(return_url)
61
+ end
62
+ format.html do
63
+ redirect_to return_url, status: :see_other
59
64
  end
60
-
61
- format.any { redirect_to return_url, status: :see_other }
62
65
  else
63
66
  format.any(:html, :turbo_stream) do
64
67
  render :interactive_record_action, formats: [:html], status: :unprocessable_content
@@ -95,9 +98,8 @@ module Plutonium
95
98
 
96
99
  if params[:pre_submit]
97
100
  respond_to do |format|
98
- format.any(:html, :turbo_stream) do
99
- render :interactive_resource_action, formats: [:html], status: :unprocessable_content
100
- end
101
+ format.turbo_stream { render turbo_stream: turbo_stream.replace("interaction-form", view_context.render(@interaction.build_form)) }
102
+ format.html { render :interactive_resource_action, status: :unprocessable_content }
101
103
  end
102
104
  return
103
105
  end
@@ -109,15 +111,12 @@ module Plutonium
109
111
  if outcome.success?
110
112
  return_url = redirect_url_after_action_on(resource_class)
111
113
 
112
- if helpers.current_turbo_frame == "remote_modal"
113
- format.turbo_stream do
114
- render turbo_stream: [
115
- helpers.turbo_stream_redirect(return_url)
116
- ]
117
- end
114
+ format.turbo_stream do
115
+ render turbo_stream: helpers.turbo_stream_redirect(return_url)
116
+ end
117
+ format.html do
118
+ redirect_to return_url, status: :see_other
118
119
  end
119
-
120
- format.any { redirect_to return_url, status: :see_other }
121
120
  else
122
121
  format.any(:html, :turbo_stream) do
123
122
  render :interactive_resource_action, formats: [:html], status: :unprocessable_content
@@ -133,48 +132,51 @@ module Plutonium
133
132
 
134
133
  # GET /resources/bulk_actions/:interactive_action?ids[]=1&ids[]=2
135
134
  def interactive_bulk_action
136
- raise NotImplementedError
137
- # # TODO: ensure that the selected list matches the returned value
138
- # interactive_bulk
139
- # @interaction = current_interactive_action.interaction.new((params[:interaction] || {}).except(:resources))
135
+ build_interactive_bulk_action_interaction
140
136
 
141
- # if helpers.current_turbo_frame == "remote_modal"
142
- # render layout: false
143
- # else
144
- # render :interactive_bulk_action
145
- # end
137
+ if helpers.current_turbo_frame == "remote_modal"
138
+ render layout: false, formats: [:html]
139
+ else
140
+ render :interactive_bulk_action, formats: [:html]
141
+ end
146
142
  end
147
143
 
148
144
  # POST /resources/bulk_actions/:interactive_action?ids[]=1&ids[]=2
149
145
  def commit_interactive_bulk_action
150
- raise NotImplementedError
151
- # respond_to do |format|
152
- # inputs = interaction_params.merge(resources: interactive_bulk)
153
- # @interaction = current_interactive_action.interaction.run(inputs)
154
-
155
- # if @interaction.valid?
156
- # collection_count = interactive_bulk.size
157
-
158
- # flash[:notice] = "TODO:#{current_interactive_action} #{collection_count} #{resource_class.model_name.human.pluralize(collection_count)} successfully updated."
159
-
160
- # format.html { redirect_to resource_url_for(resource_class) }
161
- # if helpers.current_turbo_frame == "remote_modal"
162
- # format.turbo_stream do
163
- # render turbo_stream: [
164
- # helpers.turbo_stream_redirect(resource_url_for(resource_class))
165
- # ]
166
- # end
167
- # end
168
- # else
169
- # format.html do
170
- # render :interactive_bulk_action, status: :unprocessable_content
171
- # end
172
- # format.any do
173
- # @errors = @interaction.errors
174
- # render "errors", status: :unprocessable_content
175
- # end
176
- # end
177
- # end
146
+ build_interactive_bulk_action_interaction
147
+
148
+ if params[:pre_submit]
149
+ respond_to do |format|
150
+ format.turbo_stream { render turbo_stream: turbo_stream.replace("interaction-form", view_context.render(@interaction.build_form)) }
151
+ format.html { render :interactive_bulk_action, formats: [:html], status: :unprocessable_content }
152
+ end
153
+ return
154
+ end
155
+
156
+ outcome = @interaction.call
157
+
158
+ outcome.to_response.process(self) do |value|
159
+ respond_to do |format|
160
+ if outcome.success?
161
+ return_url = redirect_url_after_action_on(resource_class)
162
+
163
+ format.turbo_stream do
164
+ render turbo_stream: helpers.turbo_stream_redirect(return_url)
165
+ end
166
+ format.html do
167
+ redirect_to return_url, status: :see_other
168
+ end
169
+ else
170
+ format.any(:html, :turbo_stream) do
171
+ render :interactive_bulk_action, formats: [:html], status: :unprocessable_content
172
+ end
173
+ format.any do
174
+ @errors = @interaction.errors
175
+ render "errors", status: :unprocessable_content
176
+ end
177
+ end
178
+ end
179
+ end
178
180
  end
179
181
 
180
182
  private
@@ -206,6 +208,16 @@ module Plutonium
206
208
  authorize_current! resource_class, to: :"#{interactive_resource_action}?"
207
209
  end
208
210
 
211
+ def authorize_interactive_bulk_action!
212
+ action_name = params[:interactive_action]&.to_sym
213
+ policy_method = :"#{action_name}?"
214
+
215
+ # Authorize each record individually - fail if any record is not authorized
216
+ interactive_bulk.each do |record|
217
+ authorize_current! record, to: policy_method
218
+ end
219
+ end
220
+
209
221
  def interactive_bulk
210
222
  @interactive_bulk ||= current_authorized_scope.from_path_param(params.require(:ids))
211
223
  end
@@ -222,6 +234,12 @@ module Plutonium
222
234
  @interaction
223
235
  end
224
236
 
237
+ def build_interactive_bulk_action_interaction
238
+ @interaction = current_interactive_action.interaction.new(view_context:)
239
+ @interaction.attributes = interaction_params.merge(resources: interactive_bulk)
240
+ @interaction
241
+ end
242
+
225
243
  # Returns the submitted resource parameters
226
244
  # @return [Hash] The submitted resource parameters
227
245
  def submitted_interaction_params
@@ -31,10 +31,12 @@ module Plutonium
31
31
 
32
32
  current_definition.defined_filters.each do |key, value|
33
33
  with = value[:options][:with]
34
- if with.is_a?(Class) && with < Plutonium::Query::Filter
34
+ if with
35
+ # Resolve symbol types (e.g., :text, :select) to filter classes
36
+ filter_class = Plutonium::Query::Filter.lookup(with)
35
37
  options = value[:options].except(:with)
36
38
  options[:key] ||= key
37
- with = with.new(**options)
39
+ with = filter_class.new(**options)
38
40
  end
39
41
  query_object.define_filter key, with, &value[:block]
40
42
  end
@@ -198,7 +198,7 @@ module Plutonium
198
198
  # @return [Symbol] The sort field.
199
199
  # @raise [RuntimeError] If unable to determine sort logic.
200
200
  def determine_sort_field(name)
201
- if resource_class.primary_key == name.to_s || resource_class.content_column_field_names.include?(name)
201
+ if resource_class.column_names.include?(name.to_s)
202
202
  name
203
203
  elsif resource_class.belongs_to_association_field_names.include?(name)
204
204
  resource_class.reflect_on_association(name).foreign_key.to_sym
@@ -6,6 +6,17 @@ module Plutonium
6
6
  include Phlex::Rails::Helpers::LinkTo
7
7
  include Phlex::Rails::Helpers::ButtonTo
8
8
 
9
+ # Color to CSS class mapping for standard and soft button variants
10
+ COLOR_CLASSES = {
11
+ primary: {default: "pu-btn-primary", soft: "pu-btn-soft-primary"},
12
+ success: {default: "pu-btn-success", soft: "pu-btn-soft-success"},
13
+ info: {default: "pu-btn-info", soft: "pu-btn-soft-info"},
14
+ warning: {default: "pu-btn-warning", soft: "pu-btn-soft-warning"},
15
+ danger: {default: "pu-btn-danger", soft: "pu-btn-soft-danger"},
16
+ accent: {default: "pu-btn-accent", soft: "pu-btn-soft-accent"},
17
+ secondary: {default: "pu-btn-secondary", soft: "pu-btn-soft-secondary"}
18
+ }.freeze
19
+
9
20
  def initialize(action, url:, variant: :default)
10
21
  @action = action
11
22
  @url = url
@@ -67,80 +78,27 @@ module Plutonium
67
78
 
68
79
  def button_classes
69
80
  tokens(
70
- base_classes,
71
- color_classes,
72
- size_classes,
73
- -> { @action.icon && @variant != :table } => "space-x-1"
81
+ "pu-btn",
82
+ size_class,
83
+ color_class,
84
+ -> { @action.icon } => "gap-1.5"
74
85
  )
75
86
  end
76
87
 
77
- def base_classes
78
- if @variant == :table
79
- "inline-flex items-center justify-center py-1 px-2 rounded-lg focus:outline-none focus:ring-2"
80
- else
81
- "flex items-center justify-center px-4 py-2 text-sm font-medium rounded-lg focus:outline-none focus:ring-4"
82
- end
88
+ def size_class
89
+ (@variant == :table) ? "pu-btn-xs" : "pu-btn-md"
83
90
  end
84
91
 
85
92
  def icon_classes
86
- if @variant == :table
87
- "h-4 w-4 mr-1"
88
- else
89
- "h-3.5 w-3.5 -ml-1"
90
- end
91
- end
92
-
93
- def size_classes
94
- (@variant == :table) ? "text-xs" : "text-sm"
93
+ (@variant == :table) ? "h-4 w-4" : "h-3.5 w-3.5"
95
94
  end
96
95
 
97
- def color_classes
98
- case @action.color || @action.category.to_sym
99
- when :primary
100
- variant_class(
101
- "bg-primary-700 text-white hover:bg-primary-800 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800",
102
- table: "bg-primary-100 text-primary-700 hover:bg-primary-200 focus:ring-primary-300 dark:bg-primary-700 dark:text-primary-100 dark:hover:bg-primary-600 dark:focus:ring-primary-600"
103
- )
104
- when :success
105
- variant_class(
106
- "bg-success-700 text-white hover:bg-success-800 focus:ring-success-300 dark:bg-success-600 dark:hover:bg-success-700 dark:focus:ring-success-800",
107
- table: "bg-success-100 text-success-700 hover:bg-success-200 focus:ring-success-300 dark:bg-success-700 dark:text-success-100 dark:hover:bg-success-600 dark:focus:ring-success-600"
108
- )
109
- when :info
110
- variant_class(
111
- "bg-info-700 text-white hover:bg-info-800 focus:ring-info-300 dark:bg-info-600 dark:hover:bg-info-700 dark:focus:ring-info-800",
112
- table: "bg-info-100 text-info-700 hover:bg-info-200 focus:ring-info-300 dark:bg-info-700 dark:text-info-100 dark:hover:bg-info-600 dark:focus:ring-info-600"
113
- )
114
- when :warning
115
- variant_class(
116
- "bg-warning-700 text-white hover:bg-warning-800 focus:ring-warning-300 dark:bg-warning-600 dark:hover:bg-warning-700 dark:focus:ring-warning-800",
117
- table: "bg-warning-100 text-warning-700 hover:bg-warning-200 focus:ring-warning-300 dark:bg-warning-700 dark:text-warning-100 dark:hover:bg-warning-600 dark:focus:ring-warning-600"
118
- )
119
- when :danger
120
- variant_class(
121
- "bg-danger-700 text-white hover:bg-danger-800 focus:ring-danger-300 dark:bg-danger-600 dark:hover:bg-danger-700 dark:focus:ring-danger-800",
122
- table: "bg-danger-100 text-danger-700 hover:bg-danger-200 focus:ring-danger-300 dark:bg-danger-700 dark:text-danger-100 dark:hover:bg-danger-600 dark:focus:ring-danger-600"
123
- )
124
- when :accent
125
- variant_class(
126
- "bg-accent-700 text-white hover:bg-accent-800 focus:ring-accent-300 dark:bg-accent-600 dark:hover:bg-accent-700 dark:focus:ring-accent-800",
127
- table: "bg-accent-100 text-accent-700 hover:bg-accent-200 focus:ring-accent-300 dark:bg-accent-700 dark:text-accent-100 dark:hover:bg-accent-600 dark:focus:ring-accent-600"
128
- )
129
- else
130
- variant_class(
131
- "bg-secondary-700 text-white hover:bg-secondary-800 focus:ring-secondary-300 dark:bg-secondary-600 dark:hover:bg-secondary-700 dark:focus:ring-secondary-800",
132
- table: "bg-secondary-100 text-secondary-700 hover:bg-secondary-200 focus:ring-secondary-300 dark:bg-secondary-700 dark:text-secondary-100 dark:hover:bg-secondary-600 dark:focus:ring-secondary-600"
133
- )
134
- end
135
- end
96
+ def color_class
97
+ color_key = (@action.color || @action.category)&.to_sym || :secondary
98
+ color_mapping = COLOR_CLASSES[color_key] || COLOR_CLASSES[:secondary]
136
99
 
137
- def variant_class(default, table:)
138
- case @variant
139
- when :table
140
- table
141
- else
142
- default
143
- end
100
+ # Table variant uses soft (tinted) buttons, default uses solid buttons
101
+ (@variant == :table) ? color_mapping[:soft] : color_mapping[:default]
144
102
  end
145
103
  end
146
104
  end