plutonium 0.15.4 → 0.15.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/plutonium.css +1 -1
  3. data/app/views/components/table_search_input/table_search_input_component.html.erb +3 -3
  4. data/app/views/resource/_resource_table.html.erb +0 -321
  5. data/lib/plutonium/definition/base.rb +8 -0
  6. data/lib/plutonium/definition/defineable_props.rb +1 -1
  7. data/lib/plutonium/definition/presentable.rb +71 -0
  8. data/lib/plutonium/interaction/README.md +1 -1
  9. data/lib/plutonium/interaction/base.rb +6 -6
  10. data/lib/plutonium/lib/deep_freezer.rb +31 -0
  11. data/lib/plutonium/query/adhoc_block.rb +19 -0
  12. data/lib/plutonium/query/base.rb +29 -0
  13. data/lib/plutonium/query/filter.rb +12 -0
  14. data/lib/plutonium/query/filters/text.rb +77 -0
  15. data/lib/plutonium/query/model_scope.rb +19 -0
  16. data/lib/plutonium/resource/controller.rb +0 -3
  17. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +26 -0
  18. data/lib/plutonium/resource/controllers/crud_actions.rb +2 -5
  19. data/lib/plutonium/resource/controllers/defineable.rb +0 -2
  20. data/lib/plutonium/resource/controllers/queryable.rb +36 -20
  21. data/lib/plutonium/resource/policy.rb +1 -1
  22. data/lib/plutonium/resource/query_object.rb +61 -147
  23. data/lib/plutonium/{refinements/parameter_refinements.rb → support/parameters.rb} +5 -7
  24. data/lib/plutonium/ui/component/methods.rb +1 -1
  25. data/lib/plutonium/ui/display/resource.rb +19 -15
  26. data/lib/plutonium/ui/form/query.rb +171 -0
  27. data/lib/plutonium/ui/form/resource.rb +21 -17
  28. data/lib/plutonium/ui/table/components/scopes_bar.rb +1 -1
  29. data/lib/plutonium/ui/table/components/search_bar.rb +6 -139
  30. data/lib/plutonium/ui/table/resource.rb +10 -9
  31. data/lib/plutonium/version.rb +1 -1
  32. data/package-lock.json +2 -2
  33. data/package.json +1 -1
  34. metadata +12 -4
  35. data/lib/plutonium/interaction/concerns/presentable.rb +0 -73
@@ -0,0 +1,77 @@
1
+ module Plutonium
2
+ module Query
3
+ module Filters
4
+ class Text < Filter
5
+ VALID_PREDICATES = [
6
+ :eq, # Equal
7
+ :not_eq, # Not equal
8
+ :matches, # LIKE with wildcards
9
+ :not_matches, # NOT LIKE with wildcards
10
+ :starts_with, # LIKE with suffix wildcard
11
+ :ends_with, # LIKE with prefix wildcard
12
+ :contains, # LIKE with wildcards on both sides
13
+ :not_contains # NOT LIKE with wildcards on both sides
14
+ ].freeze
15
+
16
+ def initialize(predicate: :eq, **)
17
+ super(**)
18
+ unless VALID_PREDICATES.include?(predicate)
19
+ raise ArgumentError, "unsupported predicate #{predicate}. Valid predicates are: #{VALID_PREDICATES.join(", ")}"
20
+ end
21
+ @predicate = predicate
22
+ end
23
+
24
+ def apply(scope, query:)
25
+ case @predicate
26
+ when :eq
27
+ scope.where(key => query)
28
+ when :not_eq
29
+ scope.where.not(key => query)
30
+ when :matches
31
+ scope.where("#{key} LIKE ?", query.tr("*", "%"))
32
+ when :not_matches
33
+ scope.where.not("#{key} LIKE ?", query.tr("*", "%"))
34
+ when :starts_with
35
+ scope.where("#{key} LIKE ?", "#{sanitize_like(query)}%")
36
+ when :ends_with
37
+ scope.where("#{key} LIKE ?", "%#{sanitize_like(query)}")
38
+ when :contains
39
+ scope.where("#{key} LIKE ?", "%#{sanitize_like(query)}%")
40
+ when :not_contains
41
+ scope.where.not("#{key} LIKE ?", "%#{sanitize_like(query)}%")
42
+ else
43
+ raise NotImplementedError, "text filter predicate #{@predicate}"
44
+ end
45
+ end
46
+
47
+ def customize_inputs
48
+ input :query
49
+ field :query, placeholder: generate_placeholder
50
+ end
51
+
52
+ private
53
+
54
+ def generate_placeholder
55
+ base = key.to_s.humanize
56
+ case @predicate
57
+ when :matches, :not_matches
58
+ "#{base} (use * as wildcard)"
59
+ when :starts_with
60
+ "#{base} starts with..."
61
+ when :ends_with
62
+ "#{base} ends with..."
63
+ when :contains, :not_contains
64
+ "#{base} contains..."
65
+ else
66
+ base
67
+ end
68
+ end
69
+
70
+ def sanitize_like(string)
71
+ # Escape special LIKE characters: %, _, and \
72
+ string.gsub(/[%_\\]/) { |char| "\\#{char}" }
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,19 @@
1
+ module Plutonium
2
+ module Query
3
+ class ModelScope < Base
4
+ attr_reader :name
5
+
6
+ # Initializes a ModelScope with a given name.
7
+ #
8
+ # @param name [Symbol] The name of the scope.
9
+ def initialize(name)
10
+ super()
11
+ @name = name
12
+ end
13
+
14
+ def apply(scope, **)
15
+ scope.public_send(name, **)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -1,9 +1,6 @@
1
1
  require "action_controller"
2
2
  require "pagy"
3
3
 
4
- require File.expand_path("refinements/parameter_refinements", Plutonium.lib_root)
5
- using Plutonium::Refinements::ParameterRefinements
6
-
7
4
  module Plutonium
8
5
  module Resource
9
6
  # Controller module to handle resource actions and concerns
@@ -0,0 +1,26 @@
1
+ module Plutonium
2
+ module Resource
3
+ module Controllers
4
+ module CrudActions
5
+ module IndexAction
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def setup_index_action!
11
+ @pagy, @resource_records = pagy filtered_resource_collection
12
+ end
13
+
14
+ def filtered_resource_collection
15
+ query_params = current_definition
16
+ .query_form.new(nil, query_object: current_query_object, page_size: nil)
17
+ .extract_input(params)[:q]
18
+
19
+ base_query = current_authorized_scope
20
+ current_query_object.apply(base_query, query_params)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -3,6 +3,7 @@ module Plutonium
3
3
  module Controllers
4
4
  module CrudActions
5
5
  extend ActiveSupport::Concern
6
+ include IndexAction
6
7
 
7
8
  included do
8
9
  helper_method :preferred_action_after_submit
@@ -13,11 +14,7 @@ module Plutonium
13
14
  authorize_current! resource_class
14
15
  set_page_title resource_class.model_name.human.pluralize.titleize
15
16
 
16
- @search_object = current_query_object
17
- base_query = current_authorized_scope
18
- base_query = @search_object.apply(base_query)
19
- # base_query = base_query.public_send(params[:scope].to_sym) if params[:scope].present?
20
- @pagy, @resource_records = pagy base_query
17
+ setup_index_action!
21
18
 
22
19
  render :index
23
20
  end
@@ -1,5 +1,3 @@
1
- using Plutonium::Refinements::ParameterRefinements
2
-
3
1
  module Plutonium
4
2
  module Resource
5
3
  module Controllers
@@ -1,5 +1,3 @@
1
- using Plutonium::Refinements::ParameterRefinements
2
-
3
1
  module Plutonium
4
2
  module Resource
5
3
  module Controllers
@@ -7,7 +5,7 @@ module Plutonium
7
5
  extend ActiveSupport::Concern
8
6
 
9
7
  included do
10
- helper_method :resource_query_params, :current_query_object
8
+ helper_method :raw_resource_query_params, :current_query_object
11
9
  end
12
10
 
13
11
  def resource_query_object(resource_class, params)
@@ -16,28 +14,46 @@ module Plutonium
16
14
  end
17
15
 
18
16
  def current_query_object
19
- @current_query_object ||= Plutonium::Resource::QueryObject.new(resource_context, resource_query_params) do |query_object|
20
- if current_definition.search_definition
21
- query_object.define_search proc { |scope, search:|
22
- current_definition.search_definition.call(scope, search)
23
- }
24
- end
25
-
26
- current_definition.defined_scopes.each do |key, value|
27
- query_object.define_scope key, value[:block], **value[:options]
17
+ @current_query_object ||=
18
+ Plutonium::Resource::QueryObject.new(resource_class, raw_resource_query_params) do |query_object|
19
+ if current_definition.search_definition
20
+ query_object.define_search proc { |scope, search:|
21
+ current_definition.search_definition.call(scope, search)
22
+ }
23
+ end
24
+
25
+ current_definition.defined_scopes.each do |key, value|
26
+ query_object.define_scope key, value[:block], **value[:options]
27
+ end
28
+
29
+ current_definition.defined_sorts.each do |key, value|
30
+ query_object.define_sorter key, value[:block], **value[:options]
31
+ end
32
+
33
+ current_definition.defined_filters.each do |key, value|
34
+ with = value[:options][:with]
35
+ if with.is_a?(Class) && with < Plutonium::Query::Filter
36
+ options = value[:options].except(:with)
37
+ options[:key] ||= key
38
+ with = with.new(**options)
39
+ end
40
+ query_object.define_filter key, with, &value[:block]
41
+ end
42
+
43
+ query_object
28
44
  end
45
+ end
29
46
 
30
- current_definition.defined_sorts.each do |key, value|
31
- query_object.define_sorter key, value[:block], **value[:options]
47
+ def raw_resource_query_params
48
+ @raw_resource_query_params ||= begin
49
+ query_params = params[:q]
50
+ if query_params.is_a?(ActionController::Parameters)
51
+ query_params.to_unsafe_h
52
+ else
53
+ {}.with_indifferent_access
32
54
  end
33
-
34
- query_object
35
55
  end
36
56
  end
37
-
38
- def resource_query_params
39
- (params[:q]&.nilify&.to_unsafe_h || {}).with_indifferent_access
40
- end
41
57
  end
42
58
  end
43
59
  end
@@ -87,7 +87,7 @@ module Plutonium
87
87
  update?
88
88
  end
89
89
 
90
- # Checks if the search action is permitted.
90
+ # Checks if record search is permitted.
91
91
  #
92
92
  # @return [Boolean] Delegates to index?.
93
93
  def search?
@@ -1,127 +1,27 @@
1
1
  module Plutonium
2
2
  module Resource
3
3
  class QueryObject
4
- class << self
5
- end
6
-
7
- class Query
8
- include Plutonium::Core::Definers::FieldInputDefiner
9
-
10
- # Applies the query to the given scope using the provided parameters.
11
- #
12
- # @param scope [Object] The initial scope to which the query will be applied.
13
- # @param params [Hash] The parameters for the query.
14
- # @return [Object] The modified scope.
15
- def apply(scope, params)
16
- params = extract_query_params(params)
17
-
18
- if input_definitions.size == params.size
19
- apply_internal(scope, params)
20
- else
21
- scope
22
- end
23
- end
24
-
25
- private
26
-
27
- # Abstract method to apply the query logic to the scope.
28
- # Should be implemented by subclasses.
29
- #
30
- # @param scope [Object] The initial scope.
31
- # @param params [Hash] The parameters for the query.
32
- # @raise [NotImplementedError] If the method is not implemented.
33
- def apply_internal(scope, params)
34
- raise NotImplementedError, "#{self.class}#apply_internal"
35
- end
36
-
37
- # Extracts query parameters based on the defined inputs.
38
- #
39
- # @param params [Hash] The parameters to extract.
40
- # @return [Hash] The extracted and symbolized parameters.
41
- def extract_query_params(params)
42
- input_definitions.collect_all(params).compact.symbolize_keys
43
- end
44
-
45
- # @return [nil] The resource class (default implementation returns nil).
46
- def resource_class = nil
47
- end
48
-
49
- class ScopeQuery < Query
50
- attr_reader :name
51
-
52
- # Initializes a ScopeQuery with a given name.
53
- #
54
- # @param name [Symbol] The name of the scope.
55
- def initialize(name)
56
- @name = name
57
- yield self if block_given?
58
- end
59
-
60
- private
61
-
62
- # Applies the scope query to the given scope.
63
- #
64
- # @param scope [Object] The initial scope.
65
- # @param params [Hash] The parameters for the query.
66
- # @return [Object] The modified scope.
67
- def apply_internal(scope, params)
68
- scope.public_send(name, **params)
69
- end
70
- end
71
-
72
- class BlockQuery < Query
73
- attr_reader :body
74
-
75
- # Initializes a BlockQuery with a given block of code.
76
- #
77
- # @param body [Proc] The block of code for the query.
78
- def initialize(body)
79
- @body = body
80
- yield self if block_given?
81
- end
82
-
83
- private
84
-
85
- # Applies the block query to the given scope.
86
- #
87
- # @param scope [Object] The initial scope.
88
- # @param params [Hash] The parameters for the query.
89
- # @return [Object] The modified scope.
90
- def apply_internal(scope, params)
91
- if body.arity == 1
92
- body.call(scope)
93
- else
94
- body.call(scope, **params)
95
- end
96
- end
97
- end
98
-
99
4
  attr_reader :search_filter, :search_query
100
5
 
101
- # Initializes a QueryObject with the given context and parameters.
6
+ # Initializes a QueryObject with the given resource_class and parameters.
102
7
  #
103
- # @param context [Object] The context in which the query object is used.
8
+ # @param resource_class [Object] The resource class.
104
9
  # @param params [Hash] The parameters for initialization.
105
- def initialize(context, params, &)
106
- @context = context
10
+ def initialize(resource_class, params, &)
11
+ @resource_class = resource_class
12
+ @params = params
107
13
 
108
14
  define_standard_queries
109
- define_scopes
110
- define_filters
111
- define_sorters
112
-
113
15
  yield self if block_given?
114
-
115
- extract_filter_params(params)
116
- extract_sort_params(params)
16
+ extract_filter_params
17
+ extract_sort_params
117
18
  end
118
19
 
119
20
  # Defines a filter with the given name and body.
120
21
  #
121
22
  # @param name [Symbol] The name of the filter.
122
23
  # @param body [Proc, nil] The body of the filter.
123
- def define_filter(name, body = nil, &)
124
- body ||= name
24
+ def define_filter(name, body, &)
125
25
  filter_definitions[name] = build_query(body, &)
126
26
  end
127
27
 
@@ -145,7 +45,7 @@ module Plutonium
145
45
  end
146
46
 
147
47
  sort_definitions[name] = build_query(body) do |query|
148
- query.define_field_input :direction
48
+ query.input :direction
149
49
  end
150
50
  end
151
51
 
@@ -154,7 +54,7 @@ module Plutonium
154
54
  # @param body [Proc, Symbol] The body of the search filter.
155
55
  def define_search(body)
156
56
  @search_filter = build_query(body) do |query|
157
- query.define_field_input :search
57
+ query.input :search
158
58
  end
159
59
  end
160
60
 
@@ -172,17 +72,21 @@ module Plutonium
172
72
  q[:sort_fields] = selected_sort_fields.dup
173
73
  handle_sort_options!(q, options)
174
74
 
175
- "?#{{q: q}.to_param}"
75
+ q.merge! params.slice(*filter_definitions.keys)
76
+ query_params = deep_compact({q: q}).to_param
77
+ "?#{query_params}"
176
78
  end
177
79
 
178
80
  # Applies the defined filters and sorts to the given scope.
179
81
  #
180
82
  # @param scope [Object] The initial scope to which filters and sorts are applied.
181
83
  # @return [Object] The modified scope.
182
- def apply(scope)
183
- scope = search_filter.apply(scope, {search: search_query}) if search_filter.present?
184
- scope = scope_definitions[selected_scope_filter].apply(scope, {}) if selected_scope_filter.present?
185
- apply_sorts(scope)
84
+ def apply(scope, params)
85
+ params = deep_compact(params.with_indifferent_access)
86
+ scope = search_filter.apply(scope, search: params[:search]) if search_filter && params[:search]
87
+ scope = scope_definitions[params[:scope]].apply(scope, **{}) if scope_definitions[params[:scope]]
88
+ scope = apply_sorts(scope, params)
89
+ apply_filters(scope, params)
186
90
  end
187
91
 
188
92
  def scope_definitions = @scope_definitions ||= {}.with_indifferent_access
@@ -208,22 +112,7 @@ module Plutonium
208
112
 
209
113
  private
210
114
 
211
- attr_reader :context, :selected_sort_fields, :selected_sort_directions, :selected_scope_filter
212
-
213
- # Defines standard filters.
214
- def define_filters
215
- # Implement filter definitions if needed
216
- end
217
-
218
- # Defines standard scopes.
219
- def define_scopes
220
- # Implement scope definitions if needed
221
- end
222
-
223
- # Defines standard sorters.
224
- def define_sorters
225
- # Implement sorter definitions if needed
226
- end
115
+ attr_reader :resource_class, :params, :selected_sort_fields, :selected_sort_directions, :selected_scope_filter
227
116
 
228
117
  # Defines standard queries for search and scope.
229
118
  def define_standard_queries
@@ -233,7 +122,7 @@ module Plutonium
233
122
  # Extracts filter parameters from the given params.
234
123
  #
235
124
  # @param params [Hash] The parameters to extract.
236
- def extract_filter_params(params)
125
+ def extract_filter_params
237
126
  @search_query = params[:search]
238
127
  @selected_scope_filter = params[:scope]
239
128
  end
@@ -241,7 +130,7 @@ module Plutonium
241
130
  # Extracts sort parameters from the given params.
242
131
  #
243
132
  # @param params [Hash] The parameters to extract.
244
- def extract_sort_params(params)
133
+ def extract_sort_params
245
134
  @selected_sort_fields = Array(params[:sort_fields])
246
135
  @selected_sort_fields &= sort_definitions.keys
247
136
 
@@ -251,17 +140,24 @@ module Plutonium
251
140
  # Builds a query object based on the given body and optional block.
252
141
  #
253
142
  # @param body [Proc, Symbol] The body of the query.
254
- # @yieldparam query [Query] The query object.
255
- # @return [Query] The constructed query object.
256
- def build_query(body, &)
257
- case body
143
+ # @yieldparam query [Plutonium::Query::Base] The query object.
144
+ # @return [Plutonium::Query::Base] The constructed query object.
145
+ def build_query(body)
146
+ query = case body
258
147
  when Symbol
259
148
  raise "Cannot find scope :#{body} on #{resource_class}" unless resource_class.respond_to?(body)
260
149
 
261
- ScopeQuery.new(body, &)
150
+ Plutonium::Query::ModelScope.new(body)
151
+ when Proc
152
+ Plutonium::Query::AdhocBlock.new(body)
153
+ when Plutonium::Query::Filter
154
+ body
262
155
  else
263
- BlockQuery.new(body, &)
156
+ raise NotImplementedError, "Unsupported query body: #{body.class} -> #{body}"
264
157
  end
158
+
159
+ yield query if block_given?
160
+ query
265
161
  end
266
162
 
267
163
  # Determines the sort field for the given name.
@@ -284,7 +180,7 @@ module Plutonium
284
180
  # @param params [Hash] The parameters to extract.
285
181
  # @return [Hash] The extracted sort directions.
286
182
  def extract_sort_directions(params)
287
- params[:sort_directions]&.slice(*sort_definitions.keys) || {}
183
+ params[:sort_directions]&.slice(*sort_definitions.keys.map(&:to_sym)) || {}
288
184
  end
289
185
 
290
186
  # Handles the sort options for building the URL.
@@ -322,19 +218,37 @@ module Plutonium
322
218
  #
323
219
  # @param scope [Object] The initial scope.
324
220
  # @return [Object] The modified scope.
325
- def apply_sorts(scope)
221
+ def apply_sorts(scope, params)
222
+ selected_sort_directions = extract_sort_directions(params)
326
223
  selected_sort_fields.each do |name|
327
- sorter = sort_definitions[name]
328
- next unless sorter.present?
224
+ next unless (sorter = sort_definitions[name])
225
+
226
+ direction = selected_sort_directions[name] || "ASC"
227
+ scope = sorter.apply(scope, direction:)
228
+ end
229
+ scope
230
+ end
231
+
232
+ def apply_filters(scope, params)
233
+ filter_definitions.each do |name, filter|
234
+ name = name.to_sym
235
+ filter_params = params[name]
236
+ next if filter_params.blank?
329
237
 
330
- params = {direction: selected_sort_directions[name] || "ASC"}
331
- scope = sorter.apply(scope, params)
238
+ scope = filter.apply(scope, **filter_params.symbolize_keys)
332
239
  end
333
240
  scope
334
241
  end
335
242
 
336
- # @return [Object] The resource class from the context.
337
- def resource_class = context.resource_class
243
+ def deep_compact(hash)
244
+ hash.transform_values do |value|
245
+ if value.respond_to?(:transform_values)
246
+ deep_compact(value).presence
247
+ else
248
+ value.presence
249
+ end
250
+ end.compact
251
+ end
338
252
  end
339
253
  end
340
254
  end
@@ -1,11 +1,9 @@
1
- require "action_controller"
2
-
3
1
  module Plutonium
4
- module Refinements
5
- module ParameterRefinements
6
- refine ActionController::Parameters do
7
- def nilify
8
- transform_values { |value| nilify_internal value }
2
+ module Support
3
+ module Parameters
4
+ class << self
5
+ def nilify(params)
6
+ params.transform_values { |value| nilify_internal(value) }
9
7
  end
10
8
 
11
9
  private
@@ -41,7 +41,7 @@ module Plutonium
41
41
  :resource_url_for,
42
42
  :current_definition,
43
43
  :current_query_object,
44
- :resource_query_params,
44
+ :raw_resource_query_params,
45
45
  :current_policy,
46
46
  :current_turbo_frame,
47
47
  :current_interactive_action,
@@ -37,27 +37,31 @@ module Plutonium
37
37
  end
38
38
 
39
39
  def render_resource_field(name)
40
- # display :name, as: :string
41
- # display :description, class: "col-span-full"
42
- # display :age, field: {class: "max-h-fit"}
43
- # display :dob do |f|
44
- # f.date_tag
45
- # end
46
-
47
40
  when_permitted(name) do
41
+ # field :name, as: :string
42
+ # display :name, as: :string
43
+ # display :description, class: "col-span-full"
44
+ # display :age, tag: {class: "max-h-fit"}
45
+ # display :dob do |f|
46
+ # f.date_tag
47
+ # end
48
+
49
+ field_options = resource_definition.defined_fields[name] ? resource_definition.defined_fields[name][:options] : {}
50
+
48
51
  display_definition = resource_definition.defined_displays[name] || {}
49
52
  display_options = display_definition[:options] || {}
50
- display_field_as = display_options.delete(:as)
51
53
 
52
- display_field_options = display_options.delete(:field) || {}
53
- display_block = display_definition[:block] || ->(f) {
54
- display_field_as ||= f.inferred_field_component
55
- f.send(:"#{display_field_as}_tag", **display_field_options)
54
+ tag = field_options[:as] || display_options[:as]
55
+ tag_attributes = display_options[:tag] || {}
56
+ tag_block = display_definition[:block] || ->(f) {
57
+ tag ||= f.inferred_field_component
58
+ f.send(:"#{tag}_tag", **tag_attributes)
56
59
  }
57
60
 
58
- field_options = resource_definition.defined_fields[name] ? resource_definition.defined_fields[name][:options] : {}
59
- render field(name, **field_options).wrapped(**display_options) do |f|
60
- render display_block.call(f)
61
+ field_options = field_options.except(:as)
62
+ wrapper_options = display_options.except(:tag, :as)
63
+ render field(name, **field_options).wrapped(**wrapper_options) do |f|
64
+ render tag_block.call(f)
61
65
  end
62
66
  end
63
67
  end