plutonium 0.37.0 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-controller/SKILL.md +38 -2
- data/.claude/skills/plutonium-definition-actions/SKILL.md +13 -0
- data/.claude/skills/plutonium-definition-fields/SKILL.md +33 -0
- data/.claude/skills/plutonium-nested-resources/SKILL.md +85 -23
- data/.claude/skills/plutonium-policy/SKILL.md +93 -6
- data/CHANGELOG.md +42 -0
- data/CLAUDE.md +8 -10
- data/CONTRIBUTING.md +6 -8
- data/Rakefile +16 -1
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +9371 -11492
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +55 -55
- data/app/assets/plutonium.min.js.map +4 -4
- data/docs/guides/custom-actions.md +14 -0
- data/docs/guides/index.md +5 -0
- data/docs/guides/nested-resources.md +139 -32
- data/docs/guides/troubleshooting.md +82 -0
- data/docs/og-image.html +84 -0
- data/docs/public/og-image.png +0 -0
- data/docs/reference/controller/index.md +6 -2
- data/docs/reference/definition/actions.md +14 -0
- data/docs/reference/definition/fields.md +33 -0
- data/docs/reference/model/index.md +1 -1
- data/docs/reference/policy/index.md +77 -6
- data/gemfiles/rails_7.gemfile.lock +5 -5
- data/gemfiles/rails_8.0.gemfile.lock +5 -5
- data/gemfiles/rails_8.1.gemfile.lock +5 -5
- data/lib/generators/pu/rodauth/install_generator.rb +7 -11
- data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +3 -5
- data/lib/plutonium/auth/sequel_adapter.rb +76 -0
- data/lib/plutonium/core/controller.rb +143 -19
- data/lib/plutonium/core/controllers/association_resolver.rb +86 -0
- data/lib/plutonium/helpers/display_helper.rb +12 -0
- data/lib/plutonium/query/filters/association.rb +25 -3
- data/lib/plutonium/resource/controller.rb +91 -9
- data/lib/plutonium/resource/controllers/authorizable.rb +17 -4
- data/lib/plutonium/resource/controllers/crud_actions.rb +7 -5
- data/lib/plutonium/resource/controllers/interactive_actions.rb +9 -0
- data/lib/plutonium/resource/controllers/presentable.rb +15 -11
- data/lib/plutonium/resource/policy.rb +85 -2
- data/lib/plutonium/resource/record/routes.rb +31 -1
- data/lib/plutonium/routing/mapper_extensions.rb +49 -10
- data/lib/plutonium/routing/route_set_extensions.rb +3 -0
- data/lib/plutonium/ui/action_button.rb +72 -11
- data/lib/plutonium/ui/actions_dropdown.rb +3 -25
- data/lib/plutonium/ui/breadcrumbs.rb +2 -2
- data/lib/plutonium/ui/component/methods.rb +10 -3
- data/lib/plutonium/ui/display/resource.rb +5 -2
- data/lib/plutonium/ui/form/base.rb +1 -1
- data/lib/plutonium/ui/form/components/key_value_store.rb +17 -5
- data/lib/plutonium/ui/form/interaction.rb +5 -5
- data/lib/plutonium/ui/form/query.rb +1 -1
- data/lib/plutonium/ui/form/resource.rb +1 -1
- data/lib/plutonium/ui/layout/base.rb +1 -1
- data/lib/plutonium/ui/layout/basic_layout.rb +2 -2
- data/lib/plutonium/ui/layout/resource_layout.rb +2 -2
- data/lib/plutonium/ui/layout/rodauth_layout.rb +2 -2
- data/lib/plutonium/ui/page/index.rb +1 -1
- data/lib/plutonium/ui/page/interactive_action.rb +1 -1
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +3 -25
- data/lib/plutonium/version.rb +1 -1
- data/lib/tasks/release.rake +1 -1
- data/package.json +6 -5
- data/plutonium.gemspec +2 -2
- data/src/js/controllers/key_value_store_controller.js +6 -0
- data/src/js/controllers/resource_drop_down_controller.js +3 -3
- data/yarn.lock +1465 -693
- metadata +10 -7
- data/app/javascript/controllers/key_value_store_controller.js +0 -119
|
@@ -12,10 +12,15 @@ module Plutonium
|
|
|
12
12
|
# @example With multiple selection
|
|
13
13
|
# filter :tags, with: :association, class_name: Tag, multiple: true
|
|
14
14
|
#
|
|
15
|
+
# @example With custom scope
|
|
16
|
+
# filter :user, with: :association, class_name: User, scope: ->(s) { s.active }
|
|
17
|
+
#
|
|
15
18
|
class Association < Filter
|
|
16
|
-
def initialize(class_name: nil, multiple: false, **)
|
|
19
|
+
def initialize(class_name: nil, resource_class: nil, scope: nil, multiple: false, **)
|
|
17
20
|
super(**)
|
|
18
|
-
@
|
|
21
|
+
@class_name = class_name
|
|
22
|
+
@resource_class = resource_class
|
|
23
|
+
@scope_proc = scope
|
|
19
24
|
@multiple = multiple
|
|
20
25
|
end
|
|
21
26
|
|
|
@@ -41,7 +46,24 @@ module Plutonium
|
|
|
41
46
|
private
|
|
42
47
|
|
|
43
48
|
def association_class
|
|
44
|
-
@association_class ||
|
|
49
|
+
@association_class ||= resolve_class_name || detect_class_from_reflection || infer_class_from_key
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resolve_class_name
|
|
53
|
+
return nil unless @class_name
|
|
54
|
+
|
|
55
|
+
@class_name.is_a?(String) ? @class_name.constantize : @class_name
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def detect_class_from_reflection
|
|
59
|
+
return nil unless @resource_class
|
|
60
|
+
|
|
61
|
+
reflection = @resource_class.reflect_on_association(key)
|
|
62
|
+
reflection&.klass
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def infer_class_from_key
|
|
66
|
+
key.to_s.classify.constantize
|
|
45
67
|
end
|
|
46
68
|
end
|
|
47
69
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "action_controller"
|
|
2
2
|
require "pagy"
|
|
3
|
+
require_relative "../routing/mapper_extensions"
|
|
3
4
|
|
|
4
5
|
module Plutonium
|
|
5
6
|
module Resource
|
|
@@ -19,7 +20,7 @@ module Plutonium
|
|
|
19
20
|
# https://github.com/ddnexus/pagy/blob/master/docs/extras/headers.md#headers
|
|
20
21
|
after_action { pagy_headers_merge(@pagy) if @pagy }
|
|
21
22
|
|
|
22
|
-
helper_method :current_parent, :resource_record!, :resource_record?, :resource_param_key, :resource_class
|
|
23
|
+
helper_method :current_parent, :current_nested_association, :resource_record!, :resource_record?, :resource_param_key, :resource_class
|
|
23
24
|
|
|
24
25
|
# Use class_attribute for proper inheritance
|
|
25
26
|
class_attribute :_resource_class, instance_accessor: false
|
|
@@ -39,9 +40,22 @@ module Plutonium
|
|
|
39
40
|
def resource_class
|
|
40
41
|
return _resource_class if _resource_class
|
|
41
42
|
|
|
43
|
+
base_name = name.to_s.gsub(/^#{current_package}::/, "").gsub(/Controller$/, "")
|
|
44
|
+
singularized_name = base_name.singularize.camelize
|
|
45
|
+
|
|
42
46
|
# Use singularize + camelize to respect custom inflections
|
|
43
|
-
|
|
47
|
+
singularized_name.constantize
|
|
44
48
|
rescue NameError
|
|
49
|
+
# Check if inflection is the issue (e.g., PostMetadata -> PostMetadatum)
|
|
50
|
+
if base_name != singularized_name && base_name.camelize.safe_constantize
|
|
51
|
+
raise NameError, <<~MSG.squish
|
|
52
|
+
Failed to determine the resource class for #{name}.
|
|
53
|
+
Rails singularized "#{base_name}" to "#{singularized_name}", but "#{base_name.camelize}" exists.
|
|
54
|
+
Add an inflection rule to config/initializers/inflections.rb.
|
|
55
|
+
See: https://radioactive-labs.github.io/plutonium-core/guides/troubleshooting
|
|
56
|
+
MSG
|
|
57
|
+
end
|
|
58
|
+
|
|
45
59
|
raise NameError, "Failed to determine the resource class. Please call `controller_for(MyResource)` in #{name}."
|
|
46
60
|
end
|
|
47
61
|
# memoize_unless_reloading :resource_class
|
|
@@ -49,13 +63,29 @@ module Plutonium
|
|
|
49
63
|
|
|
50
64
|
private
|
|
51
65
|
|
|
66
|
+
# Override to prepend parent label for nested resources in the browser tab title.
|
|
67
|
+
# e.g., "John Doe › Authored Comments"
|
|
68
|
+
def set_page_title(page_title)
|
|
69
|
+
@page_title = if current_parent
|
|
70
|
+
"#{current_parent.to_label} › #{page_title}"
|
|
71
|
+
else
|
|
72
|
+
page_title
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
52
76
|
def resource_class
|
|
53
|
-
|
|
77
|
+
if current_parent
|
|
78
|
+
# Nested route: resource_class must come from route config
|
|
79
|
+
current_resource_route_config&.dig(:resource_class) or
|
|
80
|
+
raise "No resource_class found in route config for nested route"
|
|
81
|
+
else
|
|
82
|
+
self.class.resource_class
|
|
83
|
+
end
|
|
54
84
|
end
|
|
55
85
|
|
|
56
86
|
def resource_record_relation
|
|
57
87
|
@resource_record_relation ||= begin
|
|
58
|
-
resource_route_config =
|
|
88
|
+
resource_route_config = current_resource_route_config
|
|
59
89
|
if resource_route_config[:route_type] == :resource
|
|
60
90
|
current_authorized_scope
|
|
61
91
|
elsif params[:id]
|
|
@@ -66,6 +96,33 @@ module Plutonium
|
|
|
66
96
|
end
|
|
67
97
|
end
|
|
68
98
|
|
|
99
|
+
def current_resource_route_config
|
|
100
|
+
@current_resource_route_config ||= if current_parent
|
|
101
|
+
current_engine.routes.resource_route_config_lookup["#{current_parent.class.model_name.plural}/#{current_nested_association}"]
|
|
102
|
+
else
|
|
103
|
+
current_engine.routes.resource_route_config_for(resource_class.model_name.plural)[0]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Extracts the association name from the current nested route
|
|
108
|
+
# e.g., for route /posts/:post_id/nested_comments, returns :comments
|
|
109
|
+
# @return [Symbol, nil] The association name
|
|
110
|
+
def current_nested_association
|
|
111
|
+
return unless parent_route_param
|
|
112
|
+
|
|
113
|
+
# Extract from request path: find the nested_* segment after the parent param
|
|
114
|
+
# e.g., /posts/123/nested_comments/456 => "comments"
|
|
115
|
+
# Note: Strip format extension (.json, .xml, etc.) from the segment
|
|
116
|
+
prefix = Plutonium::Routing::NESTED_ROUTE_PREFIX
|
|
117
|
+
path_segments = request.path.split("/")
|
|
118
|
+
nested_segment = path_segments.find { |seg| seg.start_with?(prefix) }
|
|
119
|
+
return unless nested_segment
|
|
120
|
+
|
|
121
|
+
# Remove prefix and any format extension (e.g., "nested_versions.json" -> "versions")
|
|
122
|
+
association_name = nested_segment.delete_prefix(prefix).sub(/\.\w+\z/, "")
|
|
123
|
+
association_name.to_sym
|
|
124
|
+
end
|
|
125
|
+
|
|
69
126
|
def resource_record!
|
|
70
127
|
@resource_record ||= resource_record_relation.first!
|
|
71
128
|
end
|
|
@@ -77,7 +134,10 @@ module Plutonium
|
|
|
77
134
|
# Returns the submitted resource parameters
|
|
78
135
|
# @return [Hash] The submitted resource parameters
|
|
79
136
|
def submitted_resource_params
|
|
80
|
-
|
|
137
|
+
# Use existing record (cloned) for context during param extraction, or new instance for create
|
|
138
|
+
# Pass form_action: false to prevent form from trying to generate URL (cloned record has id: nil)
|
|
139
|
+
extraction_record = resource_record?&.dup || resource_class.new
|
|
140
|
+
@submitted_resource_params ||= build_form(extraction_record, form_action: false).extract_input(params, view_context:)[resource_param_key.to_sym].compact
|
|
81
141
|
end
|
|
82
142
|
|
|
83
143
|
# Returns the resource parameters, including scoped and parent parameters
|
|
@@ -93,9 +153,9 @@ module Plutonium
|
|
|
93
153
|
end
|
|
94
154
|
|
|
95
155
|
# Returns the resource parameter key
|
|
96
|
-
# @return [Symbol] The resource parameter key
|
|
156
|
+
# @return [Symbol] The resource parameter key (for form params)
|
|
97
157
|
def resource_param_key
|
|
98
|
-
resource_class.model_name.
|
|
158
|
+
resource_class.model_name.param_key
|
|
99
159
|
end
|
|
100
160
|
|
|
101
161
|
# Creates a resource context
|
|
@@ -146,12 +206,30 @@ module Plutonium
|
|
|
146
206
|
@parent_route_param ||= request.path_parameters.keys.reverse.find { |key| /_id$/.match? key }
|
|
147
207
|
end
|
|
148
208
|
|
|
149
|
-
# Returns the parent input parameter
|
|
209
|
+
# Returns the parent input parameter (the belongs_to association name on the child)
|
|
210
|
+
# Finds the belongs_to association on the child that matches the parent's foreign key
|
|
150
211
|
# @return [Symbol, nil] The parent input parameter
|
|
151
212
|
def parent_input_param
|
|
152
213
|
return unless current_parent
|
|
153
214
|
|
|
154
|
-
|
|
215
|
+
unless current_nested_association
|
|
216
|
+
raise "parent exists but current_nested_association is nil - routing misconfiguration"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
parent_assoc = current_parent.class.reflect_on_association(current_nested_association)
|
|
220
|
+
unless parent_assoc
|
|
221
|
+
raise "#{current_parent.class} does not have association :#{current_nested_association}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Try inverse_of first (if explicitly set)
|
|
225
|
+
return parent_assoc.inverse_of.name.to_sym if parent_assoc.inverse_of
|
|
226
|
+
|
|
227
|
+
# Fall back to finding belongs_to by foreign key
|
|
228
|
+
foreign_key = parent_assoc.foreign_key.to_s
|
|
229
|
+
child_assoc = resource_class.reflect_on_all_associations(:belongs_to).find do |assoc|
|
|
230
|
+
assoc.foreign_key.to_s == foreign_key && assoc.klass == current_parent.class
|
|
231
|
+
end
|
|
232
|
+
child_assoc&.name&.to_sym
|
|
155
233
|
end
|
|
156
234
|
|
|
157
235
|
# Ensures the method is a GET request
|
|
@@ -195,6 +273,10 @@ module Plutonium
|
|
|
195
273
|
# @return [Array] The URL arguments
|
|
196
274
|
def resource_url_args_for(*, **kwargs)
|
|
197
275
|
kwargs[:parent] = current_parent unless kwargs.key?(:parent)
|
|
276
|
+
# Pass the current association when in a nested context
|
|
277
|
+
if kwargs[:parent] && !kwargs.key?(:association) && current_nested_association
|
|
278
|
+
kwargs[:association] = current_nested_association
|
|
279
|
+
end
|
|
198
280
|
super
|
|
199
281
|
end
|
|
200
282
|
end
|
|
@@ -108,11 +108,16 @@ module Plutonium
|
|
|
108
108
|
authorized_scope(resource_class.all, context: current_policy_context)
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
-
#
|
|
111
|
+
# Returns the policy context for the current resource
|
|
112
|
+
# Separates parent scoping (nested routes) from entity scoping (multi-tenancy)
|
|
112
113
|
#
|
|
113
|
-
# @return [Hash]
|
|
114
|
+
# @return [Hash] context containing parent, parent_association, and entity_scope
|
|
114
115
|
def current_policy_context
|
|
115
|
-
{
|
|
116
|
+
{
|
|
117
|
+
parent: current_parent,
|
|
118
|
+
parent_association: current_nested_association,
|
|
119
|
+
entity_scope: entity_scope_for_authorize
|
|
120
|
+
}
|
|
116
121
|
end
|
|
117
122
|
|
|
118
123
|
# Authorizes the current action for the given record of the current resource
|
|
@@ -130,7 +135,15 @@ module Plutonium
|
|
|
130
135
|
#
|
|
131
136
|
# @return [Array<Symbol>] the list of permitted attributes for the current action
|
|
132
137
|
def permitted_attributes
|
|
133
|
-
@permitted_attributes ||=
|
|
138
|
+
@permitted_attributes ||= permitted_attributes_for(action_name)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Returns the list of permitted attributes for a specific action on the current resource
|
|
142
|
+
#
|
|
143
|
+
# @param action [String, Symbol] the action to get permitted attributes for
|
|
144
|
+
# @return [Array<Symbol>] the list of permitted attributes for the action
|
|
145
|
+
def permitted_attributes_for(action)
|
|
146
|
+
current_policy.send_with_report(:"permitted_attributes_for_#{action}").freeze
|
|
134
147
|
end
|
|
135
148
|
|
|
136
149
|
# Returns the list of permitted associations for the current resource
|
|
@@ -12,7 +12,7 @@ module Plutonium
|
|
|
12
12
|
# GET /resources(.{format})
|
|
13
13
|
def index
|
|
14
14
|
authorize_current! resource_class
|
|
15
|
-
set_page_title resource_class
|
|
15
|
+
set_page_title helpers.nestable_resource_name_plural(resource_class)
|
|
16
16
|
|
|
17
17
|
setup_index_action!
|
|
18
18
|
|
|
@@ -53,7 +53,7 @@ module Plutonium
|
|
|
53
53
|
|
|
54
54
|
respond_to do |format|
|
|
55
55
|
if params[:pre_submit]
|
|
56
|
-
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form)) }
|
|
56
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :new))) }
|
|
57
57
|
format.html { render :new, status: :unprocessable_content }
|
|
58
58
|
elsif resource_record!.save
|
|
59
59
|
format.turbo_stream do
|
|
@@ -71,7 +71,8 @@ module Plutonium
|
|
|
71
71
|
location: redirect_url_after_submit
|
|
72
72
|
end
|
|
73
73
|
else
|
|
74
|
-
format.
|
|
74
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :new))), status: :unprocessable_content }
|
|
75
|
+
format.html { render :new, status: :unprocessable_content }
|
|
75
76
|
format.any do
|
|
76
77
|
@errors = resource_record!.errors
|
|
77
78
|
render "errors", status: :unprocessable_content
|
|
@@ -99,7 +100,7 @@ module Plutonium
|
|
|
99
100
|
|
|
100
101
|
respond_to do |format|
|
|
101
102
|
if params[:pre_submit]
|
|
102
|
-
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form)) }
|
|
103
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :edit))) }
|
|
103
104
|
format.html { render :edit, status: :unprocessable_content }
|
|
104
105
|
elsif resource_record!.save
|
|
105
106
|
format.turbo_stream do
|
|
@@ -115,7 +116,8 @@ module Plutonium
|
|
|
115
116
|
render :show, status: :ok, location: redirect_url_after_submit
|
|
116
117
|
end
|
|
117
118
|
else
|
|
118
|
-
format.
|
|
119
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :edit))), status: :unprocessable_content }
|
|
120
|
+
format.html { render :edit, status: :unprocessable_content }
|
|
119
121
|
format.any do
|
|
120
122
|
@errors = resource_record!.errors
|
|
121
123
|
render "errors", status: :unprocessable_content
|
|
@@ -62,6 +62,9 @@ module Plutonium
|
|
|
62
62
|
format.html do
|
|
63
63
|
redirect_to return_url, status: :see_other
|
|
64
64
|
end
|
|
65
|
+
format.any do
|
|
66
|
+
render :show, status: :ok, location: return_url
|
|
67
|
+
end
|
|
65
68
|
else
|
|
66
69
|
format.any(:html, :turbo_stream) do
|
|
67
70
|
render :interactive_record_action, formats: [:html], status: :unprocessable_content
|
|
@@ -117,6 +120,9 @@ module Plutonium
|
|
|
117
120
|
format.html do
|
|
118
121
|
redirect_to return_url, status: :see_other
|
|
119
122
|
end
|
|
123
|
+
format.any do
|
|
124
|
+
head :no_content, location: return_url
|
|
125
|
+
end
|
|
120
126
|
else
|
|
121
127
|
format.any(:html, :turbo_stream) do
|
|
122
128
|
render :interactive_resource_action, formats: [:html], status: :unprocessable_content
|
|
@@ -166,6 +172,9 @@ module Plutonium
|
|
|
166
172
|
format.html do
|
|
167
173
|
redirect_to return_url, status: :see_other
|
|
168
174
|
end
|
|
175
|
+
format.any do
|
|
176
|
+
head :no_content, location: return_url
|
|
177
|
+
end
|
|
169
178
|
else
|
|
170
179
|
format.any(:html, :turbo_stream) do
|
|
171
180
|
render :interactive_bulk_action, formats: [:html], status: :unprocessable_content
|
|
@@ -24,16 +24,18 @@ module Plutonium
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def submittable_attributes
|
|
27
|
-
@submittable_attributes ||=
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
27
|
+
@submittable_attributes ||= submittable_attributes_for(action_name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def submittable_attributes_for(action)
|
|
31
|
+
submittable_attributes = permitted_attributes_for(action)
|
|
32
|
+
if current_parent && !submit_parent?
|
|
33
|
+
submittable_attributes -= [parent_input_param, :"#{parent_input_param}_id"]
|
|
34
|
+
end
|
|
35
|
+
if scoped_to_entity? && !submit_scoped_entity?
|
|
36
|
+
submittable_attributes -= [scoped_entity_param_key, :"#{scoped_entity_param_key}_id"]
|
|
36
37
|
end
|
|
38
|
+
submittable_attributes
|
|
37
39
|
end
|
|
38
40
|
|
|
39
41
|
def build_collection
|
|
@@ -44,8 +46,10 @@ module Plutonium
|
|
|
44
46
|
current_definition.detail_class.new(resource_record!, resource_fields: presentable_attributes, resource_associations: permitted_associations, resource_definition: current_definition)
|
|
45
47
|
end
|
|
46
48
|
|
|
47
|
-
def build_form(record = resource_record
|
|
48
|
-
|
|
49
|
+
def build_form(record = resource_record!, action: action_name, form_action: nil, **)
|
|
50
|
+
form_options = {resource_fields: submittable_attributes_for(action), resource_definition: current_definition, **}
|
|
51
|
+
form_options[:action] = form_action unless form_action.nil?
|
|
52
|
+
current_definition.form_class.new(record, **form_options)
|
|
49
53
|
end
|
|
50
54
|
|
|
51
55
|
def present_parent? = false
|
|
@@ -8,11 +8,82 @@ module Plutonium
|
|
|
8
8
|
class Policy < ::ActionPolicy::Base
|
|
9
9
|
authorize :user, allow_nil: false
|
|
10
10
|
authorize :entity_scope, allow_nil: true
|
|
11
|
+
authorize :parent, optional: true
|
|
12
|
+
authorize :parent_association, optional: true
|
|
11
13
|
|
|
12
14
|
relation_scope do |relation|
|
|
13
|
-
|
|
15
|
+
default_relation_scope(relation)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Wraps apply_scope to verify default_relation_scope was called.
|
|
19
|
+
# This prevents accidental multi-tenancy leaks when overriding relation_scope.
|
|
20
|
+
def apply_scope(relation, type:, **options)
|
|
21
|
+
@_default_relation_scope_applied = false
|
|
22
|
+
result = super
|
|
23
|
+
verify_default_relation_scope_applied! if type == :active_record_relation
|
|
24
|
+
result
|
|
25
|
+
end
|
|
14
26
|
|
|
15
|
-
|
|
27
|
+
# Explicitly skip the default relation scope verification.
|
|
28
|
+
#
|
|
29
|
+
# Call this when you intentionally want to bypass parent/entity scoping.
|
|
30
|
+
# This should be rare - consider using a separate portal instead.
|
|
31
|
+
#
|
|
32
|
+
# @example Skipping default scoping (use sparingly)
|
|
33
|
+
# relation_scope do |relation|
|
|
34
|
+
# skip_default_relation_scope!
|
|
35
|
+
# relation.where(featured: true) # No parent/entity scoping
|
|
36
|
+
# end
|
|
37
|
+
def skip_default_relation_scope!
|
|
38
|
+
@_default_relation_scope_applied = true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Applies Plutonium's default scoping (parent or entity) to a relation.
|
|
42
|
+
#
|
|
43
|
+
# This method MUST be called in any custom relation_scope to ensure proper
|
|
44
|
+
# parent/entity scoping. Failure to call it will raise an error.
|
|
45
|
+
#
|
|
46
|
+
# @example Overriding inherited scope while keeping default scoping
|
|
47
|
+
# # Parent policy has custom filtering you want to replace
|
|
48
|
+
# class AdminPostPolicy < PostPolicy
|
|
49
|
+
# relation_scope do |relation|
|
|
50
|
+
# # Replace inherited scope but keep Plutonium's parent/entity scoping
|
|
51
|
+
# default_relation_scope(relation)
|
|
52
|
+
# end
|
|
53
|
+
# end
|
|
54
|
+
#
|
|
55
|
+
# @example Adding filtering on top of default scoping
|
|
56
|
+
# relation_scope do |relation|
|
|
57
|
+
# default_relation_scope(relation).where(published: true)
|
|
58
|
+
# end
|
|
59
|
+
#
|
|
60
|
+
# @param relation [ActiveRecord::Relation] The relation to scope
|
|
61
|
+
# @return [ActiveRecord::Relation] The scoped relation
|
|
62
|
+
def default_relation_scope(relation)
|
|
63
|
+
@_default_relation_scope_applied = true
|
|
64
|
+
|
|
65
|
+
if parent || parent_association
|
|
66
|
+
unless parent && parent_association
|
|
67
|
+
raise ArgumentError, "parent and parent_association must both be provided together"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Parent association scoping (nested routes)
|
|
71
|
+
# The parent was already entity-scoped during authorization, so children
|
|
72
|
+
# accessed through the parent don't need additional entity scoping
|
|
73
|
+
assoc_reflection = parent.class.reflect_on_association(parent_association)
|
|
74
|
+
if assoc_reflection.collection?
|
|
75
|
+
# has_many: merge with the association's scope
|
|
76
|
+
parent.public_send(parent_association).merge(relation)
|
|
77
|
+
else
|
|
78
|
+
# has_one: scope by foreign key
|
|
79
|
+
relation.where(assoc_reflection.foreign_key => parent.id)
|
|
80
|
+
end
|
|
81
|
+
elsif entity_scope
|
|
82
|
+
# Entity scoping (multi-tenancy)
|
|
83
|
+
relation.associated_with(entity_scope)
|
|
84
|
+
else
|
|
85
|
+
relation
|
|
86
|
+
end
|
|
16
87
|
end
|
|
17
88
|
|
|
18
89
|
# Sends a method and raises an error if the method is not implemented.
|
|
@@ -162,6 +233,18 @@ module Plutonium
|
|
|
162
233
|
record.instance_of?(Class) ? record : record.class
|
|
163
234
|
end
|
|
164
235
|
|
|
236
|
+
# Verifies that default_relation_scope was called during scope application.
|
|
237
|
+
# Raises an error if it wasn't, preventing accidental multi-tenancy leaks.
|
|
238
|
+
def verify_default_relation_scope_applied!
|
|
239
|
+
return if @_default_relation_scope_applied
|
|
240
|
+
|
|
241
|
+
raise <<~MSG.squish
|
|
242
|
+
#{self.class.name} did not call `default_relation_scope` in its relation_scope.
|
|
243
|
+
This can cause multi-tenancy leaks. Either call `default_relation_scope(relation)`
|
|
244
|
+
or `super(relation)` in your relation_scope block.
|
|
245
|
+
MSG
|
|
246
|
+
end
|
|
247
|
+
|
|
165
248
|
# Autodetects the permitted fields for a given method.
|
|
166
249
|
#
|
|
167
250
|
# @param method_name [Symbol] The name of the method.
|
|
@@ -12,10 +12,40 @@ module Plutonium
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
class_methods do
|
|
15
|
+
# Returns metadata for has_many associations that can be routed
|
|
16
|
+
# @return [Array<Hash>] Array of hashes with :name, :klass, :plural keys
|
|
17
|
+
def routable_has_many_associations
|
|
18
|
+
return @routable_has_many_associations if defined?(@routable_has_many_associations) && !Rails.env.local?
|
|
19
|
+
|
|
20
|
+
@routable_has_many_associations = reflect_on_all_associations(:has_many).map do |assoc|
|
|
21
|
+
{name: assoc.name, klass: assoc.klass, plural: assoc.klass.model_name.plural}
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns metadata for has_one associations that can be routed
|
|
26
|
+
# @return [Array<Hash>] Array of hashes with :name, :klass, :plural keys
|
|
27
|
+
def routable_has_one_associations
|
|
28
|
+
return @routable_has_one_associations if defined?(@routable_has_one_associations) && !Rails.env.local?
|
|
29
|
+
|
|
30
|
+
@routable_has_one_associations = reflect_on_all_associations(:has_one)
|
|
31
|
+
.reject { |assoc| assoc.options[:through] }
|
|
32
|
+
.map do |assoc|
|
|
33
|
+
{name: assoc.name, klass: assoc.klass, plural: assoc.klass.model_name.plural}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @deprecated Use routable_has_many_associations instead
|
|
15
38
|
def has_many_association_routes
|
|
16
39
|
return @has_many_association_routes if defined?(@has_many_association_routes) && !Rails.env.local?
|
|
17
40
|
|
|
18
|
-
@has_many_association_routes =
|
|
41
|
+
@has_many_association_routes = routable_has_many_associations.map { |info| info[:plural] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @deprecated Use routable_has_one_associations instead
|
|
45
|
+
def has_one_association_routes
|
|
46
|
+
return @has_one_association_routes if defined?(@has_one_association_routes) && !Rails.env.local?
|
|
47
|
+
|
|
48
|
+
@has_one_association_routes = routable_has_one_associations.map { |info| info[:plural] }
|
|
19
49
|
end
|
|
20
50
|
|
|
21
51
|
def all_nested_attributes_options
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Plutonium
|
|
4
4
|
module Routing
|
|
5
|
+
# Prefix used for nested resource routes to disambiguate from user-defined routes
|
|
6
|
+
NESTED_ROUTE_PREFIX = "nested_"
|
|
7
|
+
|
|
5
8
|
# MapperExtensions module provides additional functionality for route mapping in Plutonium applications.
|
|
6
9
|
#
|
|
7
10
|
# This module extends the functionality of Rails' routing mapper to support Plutonium-specific features,
|
|
@@ -77,10 +80,43 @@ module Plutonium
|
|
|
77
80
|
# @param resource [Class] The parent resource class.
|
|
78
81
|
# @return [void]
|
|
79
82
|
def define_nested_resource_routes(resource)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
# has_many associations use plural routes
|
|
84
|
+
resource.routable_has_many_associations.each do |assoc_info|
|
|
85
|
+
base_config = route_set.resource_route_config_for(assoc_info[:plural])[0]
|
|
86
|
+
next unless base_config
|
|
87
|
+
|
|
88
|
+
# Register with association-based key: "parent_plural/association_name"
|
|
89
|
+
nested_key = "#{resource.model_name.plural}/#{assoc_info[:name]}"
|
|
90
|
+
nested_config = base_config.merge(
|
|
91
|
+
association_name: assoc_info[:name],
|
|
92
|
+
resource_class: assoc_info[:klass]
|
|
93
|
+
)
|
|
94
|
+
route_set.resource_route_config_lookup[nested_key] = nested_config
|
|
95
|
+
|
|
96
|
+
resources "#{NESTED_ROUTE_PREFIX}#{assoc_info[:name]}", **base_config[:route_options].except(:path) do
|
|
97
|
+
instance_exec(&base_config[:block]) if base_config[:block]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# has_one associations use singular routes
|
|
102
|
+
resource.routable_has_one_associations.each do |assoc_info|
|
|
103
|
+
base_config = route_set.resource_route_config_for(assoc_info[:plural])[0]
|
|
104
|
+
next unless base_config
|
|
105
|
+
|
|
106
|
+
# Register with association-based key and singular route type
|
|
107
|
+
nested_key = "#{resource.model_name.plural}/#{assoc_info[:name]}"
|
|
108
|
+
nested_config = base_config.merge(
|
|
109
|
+
route_type: :resource,
|
|
110
|
+
association_name: assoc_info[:name],
|
|
111
|
+
resource_class: assoc_info[:klass]
|
|
112
|
+
)
|
|
113
|
+
route_set.resource_route_config_lookup[nested_key] = nested_config
|
|
114
|
+
|
|
115
|
+
resource "#{NESTED_ROUTE_PREFIX}#{assoc_info[:name]}", **base_config[:route_options].except(:path) do
|
|
116
|
+
original_collection = method(:collection)
|
|
117
|
+
define_singleton_method(:collection) { |&_| } # no-op for singular resources
|
|
118
|
+
instance_exec(&base_config[:block]) if base_config[:block]
|
|
119
|
+
define_singleton_method(:collection, original_collection)
|
|
84
120
|
end
|
|
85
121
|
end
|
|
86
122
|
end
|
|
@@ -91,8 +127,9 @@ module Plutonium
|
|
|
91
127
|
def define_member_interactive_actions
|
|
92
128
|
member do
|
|
93
129
|
get "record_actions/:interactive_action", action: :interactive_record_action,
|
|
94
|
-
as: :
|
|
95
|
-
post "record_actions/:interactive_action", action: :commit_interactive_record_action
|
|
130
|
+
as: :interactive_record_action
|
|
131
|
+
post "record_actions/:interactive_action", action: :commit_interactive_record_action,
|
|
132
|
+
as: :commit_interactive_record_action
|
|
96
133
|
end
|
|
97
134
|
end
|
|
98
135
|
|
|
@@ -102,12 +139,14 @@ module Plutonium
|
|
|
102
139
|
def define_collection_interactive_actions
|
|
103
140
|
collection do
|
|
104
141
|
get "bulk_actions/:interactive_action", action: :interactive_bulk_action,
|
|
105
|
-
as: :
|
|
106
|
-
post "bulk_actions/:interactive_action", action: :commit_interactive_bulk_action
|
|
142
|
+
as: :interactive_bulk_action
|
|
143
|
+
post "bulk_actions/:interactive_action", action: :commit_interactive_bulk_action,
|
|
144
|
+
as: :commit_interactive_bulk_action
|
|
107
145
|
|
|
108
146
|
get "resource_actions/:interactive_action", action: :interactive_resource_action,
|
|
109
|
-
as: :
|
|
110
|
-
post "resource_actions/:interactive_action", action: :commit_interactive_resource_action
|
|
147
|
+
as: :interactive_resource_action
|
|
148
|
+
post "resource_actions/:interactive_action", action: :commit_interactive_resource_action,
|
|
149
|
+
as: :commit_interactive_resource_action
|
|
111
150
|
end
|
|
112
151
|
end
|
|
113
152
|
|
|
@@ -83,6 +83,8 @@ module Plutonium
|
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
# @return [Hash] A lookup table for resource route configurations.
|
|
86
|
+
# Keys are either plural names (e.g., "profiles") for top-level routes
|
|
87
|
+
# or "parent_plural/child_plural" (e.g., "users/profiles") for nested routes.
|
|
86
88
|
def resource_route_config_lookup
|
|
87
89
|
@resource_route_config_lookup ||= {}
|
|
88
90
|
end
|
|
@@ -107,6 +109,7 @@ module Plutonium
|
|
|
107
109
|
# @return [Hash] The complete resource configuration.
|
|
108
110
|
def create_resource_config(resource, route_name, concern_name, options = {}, &block)
|
|
109
111
|
{
|
|
112
|
+
resource_class: resource,
|
|
110
113
|
route_type: options[:singular] ? :resource : :resources,
|
|
111
114
|
route_name: route_name,
|
|
112
115
|
concern_name: concern_name,
|