flexi_admin 0.0.5 → 0.0.6

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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +254 -18
  3. data/lib/flexi_admin/components/helpers/action_helper.rb +21 -0
  4. data/lib/flexi_admin/components/helpers/frontend_helper.rb +23 -0
  5. data/lib/flexi_admin/components/helpers/link_helper.rb +35 -2
  6. data/lib/flexi_admin/components/helpers/resource_helper.rb +79 -14
  7. data/lib/flexi_admin/components/helpers/url_helper.rb +15 -0
  8. data/lib/flexi_admin/components/nav/breadcrumbs_component.html.slim +13 -0
  9. data/lib/flexi_admin/components/nav/breadcrumbs_component.rb +86 -0
  10. data/lib/flexi_admin/components/resource/autocomplete_component.html.slim +19 -7
  11. data/lib/flexi_admin/components/resource/autocomplete_component.rb +41 -11
  12. data/lib/flexi_admin/components/resource/button_select_component.html.slim +2 -2
  13. data/lib/flexi_admin/components/resource/button_select_component.rb +8 -1
  14. data/lib/flexi_admin/components/resource/filter_component.html.slim +47 -0
  15. data/lib/flexi_admin/components/resource/filter_component.rb +51 -0
  16. data/lib/flexi_admin/components/resource/form_component.rb +16 -1
  17. data/lib/flexi_admin/components/resource/form_element_component.rb +1 -1
  18. data/lib/flexi_admin/components/resource/form_mixin.rb +186 -42
  19. data/lib/flexi_admin/components/resource/link_action_component.html.slim +2 -2
  20. data/lib/flexi_admin/components/resource/link_action_component.rb +3 -2
  21. data/lib/flexi_admin/components/resource/view_component.html.slim +14 -4
  22. data/lib/flexi_admin/components/resource/view_component.rb +11 -2
  23. data/lib/flexi_admin/components/resources/bulk_action/button_component.rb +8 -1
  24. data/lib/flexi_admin/components/resources/bulk_action/modal_component.html.slim +21 -15
  25. data/lib/flexi_admin/components/resources/bulk_action/modal_component.rb +16 -8
  26. data/lib/flexi_admin/components/resources/bulk_action/row_action_button_component.html.slim +16 -0
  27. data/lib/flexi_admin/components/resources/bulk_action/row_action_button_component.rb +78 -0
  28. data/lib/flexi_admin/components/resources/bulk_action/row_actions_dropdown_component.html.slim +15 -0
  29. data/lib/flexi_admin/components/resources/bulk_action/row_actions_dropdown_component.rb +33 -0
  30. data/lib/flexi_admin/components/resources/grid_view/card_component.html.slim +4 -1
  31. data/lib/flexi_admin/components/resources/grid_view/card_component.rb +4 -1
  32. data/lib/flexi_admin/components/resources/grid_view/grid_component.html.slim +3 -1
  33. data/lib/flexi_admin/components/resources/grid_view/grid_component.rb +4 -2
  34. data/lib/flexi_admin/components/resources/grid_view_component.rb +7 -2
  35. data/lib/flexi_admin/components/resources/index_page_component.html.slim +5 -4
  36. data/lib/flexi_admin/components/resources/index_page_component.rb +1 -0
  37. data/lib/flexi_admin/components/resources/list_view/table_component.html.slim +1 -1
  38. data/lib/flexi_admin/components/resources/list_view_component.rb +2 -1
  39. data/lib/flexi_admin/components/resources/pagination_component.html.slim +51 -39
  40. data/lib/flexi_admin/components/resources/pagination_component.rb +52 -47
  41. data/lib/flexi_admin/components/resources/switch_view_component.html.slim +2 -2
  42. data/lib/flexi_admin/components/resources/view_component.html.slim +15 -6
  43. data/lib/flexi_admin/components/shared/medium_component.html.slim +1 -1
  44. data/lib/flexi_admin/components/shared/table/header_item_component.html.slim +1 -1
  45. data/lib/flexi_admin/components/shared/table/header_item_component.rb +59 -61
  46. data/lib/flexi_admin/components/shared/tabs_component.html.slim +26 -0
  47. data/lib/flexi_admin/components/shared/tabs_component.rb +68 -0
  48. data/lib/flexi_admin/components.rb +11 -3
  49. data/lib/flexi_admin/config.rb +21 -3
  50. data/lib/flexi_admin/controllers/modals_controller.rb +4 -2
  51. data/lib/flexi_admin/controllers/resources_controller.rb +246 -51
  52. data/lib/flexi_admin/engine.rb +4 -0
  53. data/lib/flexi_admin/helpers/application_helper.rb +3 -0
  54. data/lib/flexi_admin/javascript/controllers/add_row_controller.js +118 -0
  55. data/lib/flexi_admin/javascript/controllers/autocomplete_controller.js +17 -1
  56. data/lib/flexi_admin/javascript/controllers/bulk_action_controller.js +192 -33
  57. data/lib/flexi_admin/javascript/controllers/copy_to_clipboard_controller.js +16 -0
  58. data/lib/flexi_admin/javascript/controllers/datalist_controller.js +1 -1
  59. data/lib/flexi_admin/javascript/controllers/delete_controller.js +40 -0
  60. data/lib/flexi_admin/javascript/controllers/expandable_field_controller.js +15 -0
  61. data/lib/flexi_admin/javascript/controllers/filter_auto_submit_controller.js +93 -0
  62. data/lib/flexi_admin/javascript/controllers/index.js +18 -0
  63. data/lib/flexi_admin/javascript/controllers/pagination_controller.js +8 -0
  64. data/lib/flexi_admin/javascript/controllers/tabs_controller.js +24 -0
  65. data/lib/flexi_admin/models/concerns/application_resource.rb +6 -0
  66. data/lib/flexi_admin/models/concerns/parentable.rb +3 -0
  67. data/lib/flexi_admin/models/context_params.rb +35 -10
  68. data/lib/flexi_admin/models/resources/context.rb +4 -0
  69. data/lib/flexi_admin/routes.rb +7 -1
  70. data/lib/flexi_admin/services/create_resource.rb +6 -6
  71. data/lib/flexi_admin/version.rb +1 -1
  72. data/lib/flexi_admin/views/shared/_reload_frame.slim +19 -0
  73. data/lib/flexi_admin.rb +0 -3
  74. metadata +171 -10
@@ -2,19 +2,25 @@
2
2
 
3
3
  module FlexiAdmin::Controllers::ResourcesController
4
4
  extend ActiveSupport::Concern
5
+
6
+ DEFAULT_CONTROLLER_PARAMS_KEYS = %i[controller action subdomain commit button].freeze
5
7
  # rescue_from RuntimeError, with: :handle_runtime_error
6
8
 
7
9
  included do
10
+ helper FlexiAdmin::Helpers::ApplicationHelper
8
11
  before_action :context_params
9
12
 
10
- rescue_from CanCan::AccessDenied do |exception|
11
- flash[:error] = FlexiAdmin::Models::Toast.new(exception.message)
12
- respond_to do |format|
13
- format.html do
14
- render 'shared/not_authorized'
15
- end
16
- format.turbo_stream do
17
- render_toasts
13
+ if defined?(CanCan::AccessDenied)
14
+ rescue_from CanCan::AccessDenied do |exception|
15
+ flash[:error] = FlexiAdmin::Models::Toast.new(exception.message)
16
+ respond_to do |format|
17
+ format.html do
18
+ render 'shared/not_authorized'
19
+ end
20
+
21
+ format.turbo_stream do
22
+ render_toasts
23
+ end
18
24
  end
19
25
  end
20
26
  end
@@ -29,20 +35,32 @@ module FlexiAdmin::Controllers::ResourcesController
29
35
  end
30
36
 
31
37
  def render_index(resources, target: nil)
32
- target ||= context_params.frame
38
+ target = target || (fa_sorting_active? ? resource_class.model_name.plural : context_params.frame)
39
+
33
40
  respond_to do |format|
34
41
  format.html do
35
- component_class = "Admin::#{resource_class.name}::IndexPageComponent".constantize
36
- puts "component_class: #{component_class}"
37
- render component_class.new(resources, context_params:, scope: resource_class.to_s.downcase)
42
+ component_class = namespaced_class('namespace', resource_class.name, "IndexPageComponent")
43
+ puts("component_class: #{component_class}") if defined?(Rails) && Rails.env.development?
44
+ render component_class.new(resources, context_params: context_params,
45
+ scope: resource_class.model_name.plural)
38
46
  end
39
47
  format.turbo_stream do
40
- component_class = "Admin::#{resource_class.name}::ResourcesComponent".constantize
41
- render turbo_stream: turbo_stream.replace(target, component_class.new(resources, context_params:, scope: resource_class.to_s.downcase))
48
+ component_class = namespaced_class('namespace', resource_class.name, "ResourcesComponent")
49
+ render turbo_stream: turbo_stream.replace(target, component_class.new(resources, context_params: context_params,
50
+ scope: resource_class.model_name.plural,
51
+ parent: parent_instance))
42
52
  end
43
53
  end
44
54
  end
45
55
 
56
+ def reload_frame(scope = nil)
57
+ scope ||= context_params.reload_frame.presence || context_params.scope&.tr('/', '_') || resource_class.model_name.plural
58
+ render turbo_stream: [
59
+ turbo_stream.append('system', partial: 'shared/reload_frame', locals: { scope: scope, referrer: request.referrer }),
60
+ turbo_stream.append('toasts', partial: 'shared/toasts')
61
+ ]
62
+ end
63
+
46
64
  # Deprecated
47
65
  def reload_page
48
66
  render turbo_stream: [
@@ -52,17 +70,66 @@ module FlexiAdmin::Controllers::ResourcesController
52
70
  end
53
71
 
54
72
  def redirect_to_path(path)
55
- render turbo_stream: turbo_stream.append('system', partial: 'shared/redirect', locals: { path: })
73
+ render turbo_stream: turbo_stream.append('system', partial: 'shared/redirect', locals: { path: path })
56
74
  end
57
75
 
76
+ # Prefix for the per-scope cookie that remembers a user's page-size choice.
77
+ PER_PAGE_COOKIE_PREFIX = "fa_per_page_"
78
+
58
79
  def context_params
59
- @context_params ||= FlexiAdmin::Models::ContextParams.new(context_permitted_params)
80
+ @context_params ||= FlexiAdmin::Models::ContextParams.new(remembered_per_page_params)
60
81
  end
61
82
 
62
- def context_permitted_params
63
- @context_permitted_params ||= params.permit(*FlexiAdmin::Models::ContextParams.permitted_params_keys)
83
+ # Admin users work through long lists and were re-picking the same page size
84
+ # on every visit, because `fa_per_page` only ever lived in the URL. Persist
85
+ # the choice per scope in a cookie: an explicit `fa_per_page` is recorded,
86
+ # and a request without one inherits whatever that scope was last set to.
87
+ def remembered_per_page_params
88
+ permitted = context_permitted_params
89
+ cookie_key = per_page_cookie_key
90
+
91
+ chosen = permitted[FlexiAdmin::Models::ContextParams::MAP[:per_page]]
92
+ if chosen.present?
93
+ cookies[cookie_key] = { value: chosen.to_s, expires: 1.year.from_now } if valid_per_page?(chosen)
94
+ return permitted
95
+ end
96
+
97
+ remembered = cookies[cookie_key]
98
+ return permitted unless valid_per_page?(remembered)
99
+
100
+ permitted.merge(FlexiAdmin::Models::ContextParams::MAP[:per_page] => remembered)
101
+ end
102
+
103
+ # Keyed by scope where one is given (several scopes can render on one page),
104
+ # falling back to the controller for a plain index.
105
+ def per_page_cookie_key
106
+ scope = params[FlexiAdmin::Models::ContextParams::MAP[:scope]].presence || controller_path
107
+ "#{PER_PAGE_COOKIE_PREFIX}#{scope.to_s.parameterize(separator: '_')}"
64
108
  end
65
109
 
110
+ # A cookie is user-writable, so never let it widen a query beyond the sizes
111
+ # the app itself offers.
112
+ def valid_per_page?(value)
113
+ return false if value.blank?
114
+
115
+ FlexiAdmin::Config.configuration.paginate_per_options.map(&:to_s).include?(value.to_s)
116
+ end
117
+
118
+ def context_permitted_params
119
+ @context_permitted_params ||= begin
120
+ permitted_keys = FlexiAdmin::Models::ContextParams.permitted_params_keys.dup
121
+
122
+ array_param_keys = params.select { |_k, v| v.is_a?(Array) }.keys
123
+ array_params = array_param_keys.map { |k| { k => [] } }
124
+ scalar_params = params.except(:controller, *DEFAULT_CONTROLLER_PARAMS_KEYS).keys - array_param_keys
125
+
126
+ params.permit(
127
+ *permitted_keys,
128
+ *scalar_params,
129
+ *array_params
130
+ )
131
+ end
132
+ end
66
133
 
67
134
  def handle_runtime_error(error)
68
135
  return BugTracker.notify(error) if true
@@ -73,18 +140,29 @@ module FlexiAdmin::Controllers::ResourcesController
73
140
  end
74
141
 
75
142
  def create
76
- authorize! :create, resource_class
143
+ authorize! :create, resource_class if defined?(CanCan)
77
144
 
78
- create_service = begin
79
- "#{resource_class.model_name.plural.camelize}::Services::Create".constantize
80
- rescue NameError
81
- FlexiAdmin::Services::CreateResource
82
- end
145
+ result = nil
146
+ if respond_to?(:create_service)
147
+ Rails.logger.debug "ResourcesController: #{__method__} using custom controller create_service."
148
+ result = create_service(resource: parent_instance, params: resource_params)
149
+ else
150
+ create_service = begin
151
+ class_name = namespaced_class('namespace', resource_class.model_name.plural.camelize, "Services", "Create")
152
+ Rails.logger.debug "ResourcesController: #{__method__} using class_name: #{class_name}"
153
+ class_name
154
+ rescue NameError
155
+ Rails.logger.debug "ResourcesController: #{__method__} using generic FlexiAdmin::Services::CreateResource"
156
+ FlexiAdmin::Services::CreateResource
157
+ end
83
158
 
84
- result = create_service.run(resource_class:, params: create_params)
159
+ result = create_service.run(resource_class:, params: resource_params)
160
+ end
85
161
 
86
162
  if result.valid?
87
- redirect_to_path polymorphic_path([:admin, result.resource])
163
+ namespace = FlexiAdmin::Config.configuration.namespace
164
+ path_segments = namespace.present? ? [namespace.to_sym, result.resource] : [result.resource]
165
+ redirect_to_path polymorphic_path(path_segments)
88
166
  else
89
167
  render_new_resource_form(result.resource)
90
168
  end
@@ -92,12 +170,12 @@ module FlexiAdmin::Controllers::ResourcesController
92
170
 
93
171
  def show
94
172
  @resource = resource_class.find(params[:id])
95
- authorize! :show, @resource
173
+ authorize! :show, @resource if defined?(CanCan)
96
174
 
97
175
  respond_to do |format|
98
176
  format.html do
99
- component ||= "Admin::#{resource_class.name}::Show::PageComponent".constantize
100
- render component.new(@resource, context_params:, scope: resource_class.to_s.downcase)
177
+ component ||= namespaced_class('namespace', resource_class.name, "Show", "PageComponent")
178
+ render component.new(@resource, context_params:, scope: resource_class.model_name.plural)
101
179
  end
102
180
  format.turbo_stream do
103
181
  render turbo_stream: turbo_stream.replace(context_params.frame)
@@ -107,30 +185,63 @@ module FlexiAdmin::Controllers::ResourcesController
107
185
 
108
186
  def edit
109
187
  @resource = resource_class.find(params[:id])
110
- authorize! :update, @resource
188
+ authorize! :update, @resource if defined?(CanCan)
111
189
 
112
190
  render_edit_resource_form(disabled: disabled?(context_params.form_disabled))
113
191
  end
114
192
 
115
193
  def update
116
194
  @resource = resource_class.find(params[:id])
117
- authorize! :update, @resource
195
+ authorize! :update, @resource if defined?(CanCan)
118
196
 
119
- update_service = begin
120
- "#{resource_class.model_name.plural.camelize}::Services::Update".constantize
121
- rescue NameError
122
- FlexiAdmin::Services::UpdateResource
123
- end
197
+ result = nil
198
+ if result = try(:update_service, resource: @resource, params: resource_params)
199
+ Rails.logger.debug "ResourcesController: #{__method__} using custom controller update_service."
200
+ result = update_service(resource: @resource, params: resource_params)
201
+ else
202
+ update_service = begin
203
+ class_name = namespaced_class('namespace', 'namespace',"#{resource_class.model_name.plural.camelize}", "Services", "Update")
204
+ Rails.logger.debug "ResourcesController: #{__method__} using class_name: #{class_name}"
205
+
206
+ class_name
207
+ rescue NameError
208
+ Rails.logger.debug "ResourcesController: #{__method__} using generic FlexiAdmin::Services::UpdateResource"
209
+ FlexiAdmin::Services::UpdateResource
210
+ end
124
211
 
125
- result = update_service.run(resource: @resource, params: resource_params)
212
+ result = update_service.run(resource: @resource, params: resource_params)
213
+ end
126
214
 
127
215
  if result.valid?
128
216
  render_edit_resource_form(disabled: disabled?(true))
129
217
  else
218
+ Rails.logger.debug "ResourcesController: #{__method__} errors: #{result.errors.respond_to?(:full_messages) ? result.errors.full_messages : result.errors}"
130
219
  render_edit_resource_form(disabled: disabled?(false))
131
220
  end
132
221
  end
133
222
 
223
+ def destroy
224
+ @resource = resource_class.find(params[:id])
225
+ authorize! :destroy, @resource if defined?(CanCan)
226
+
227
+ if @resource.destroy
228
+ success_message = I18n.t('flexi_admin.messages.destroy.success',
229
+ resource: resource_class.model_name.human)
230
+ flash[:success] = FlexiAdmin::Models::Toast.new(success_message)
231
+
232
+ namespace = FlexiAdmin::Config.configuration.namespace
233
+ path_segments = namespace.present? ? [namespace.to_sym, resource_class] : [resource_class]
234
+ index_path = polymorphic_path(path_segments)
235
+
236
+ redirect_to_path index_path
237
+ else
238
+ error_message = I18n.t('flexi_admin.messages.destroy.failure',
239
+ resource: resource_class.model_name.human)
240
+ flash[:error] = FlexiAdmin::Models::Toast.new(error_message)
241
+ render_toasts
242
+ end
243
+ end
244
+
134
245
  def render_edit_resource_form(disabled: true)
135
246
  respond_to do |format|
136
247
  format.html do
@@ -159,28 +270,58 @@ module FlexiAdmin::Controllers::ResourcesController
159
270
  # Unscoped is needed to get the resources that are not deleted, archived, etc.
160
271
  # It should be ok, since we control the ids in the frontend
161
272
  @resources = resource_class.unscoped.where(id: ids)
162
- authorize! :edit, @resources
163
273
 
164
- bulk_processor = "#{params[:processor].gsub('-', '/').camelize}::Processor".constantize.new(@resources, params)
274
+ authorize! :edit, @resources.presence&.base_class || resource_class if defined?(CanCan)
275
+
276
+ # TODO: improve this
277
+ params.merge!(current_user: current_user)
278
+ bulk_processor = if params[:processor].gsub('-', '/').camelize.include?(FlexiAdmin::Config.configuration.module_namespace)
279
+ namespaced_class(params[:processor].gsub('-', '/').camelize, "Processor").new(@resources, params)
280
+ else
281
+ namespaced_class(FlexiAdmin::Config.configuration.module_namespace, params[:processor].gsub('-', '/').camelize, "Processor").new(@resources, params)
282
+ end
283
+
165
284
  result = bulk_processor.perform
166
285
 
167
286
  redirect_to_path result.path and return if result.result == :redirect
168
287
 
169
288
  flash[result.result] = FlexiAdmin::Models::Toast.new(result.message)
170
289
 
171
- reload_page
290
+ if result.redirect_to.present?
291
+ redirect_to_path(result.redirect_to)
292
+ elsif result.try(:reload) == :page || context_params.reload == :page
293
+ reload_page
294
+ else
295
+ reload_frame
296
+ end
172
297
  end
173
298
 
174
- def autocomplete(includes: nil)
175
- base_query = resource_class.with_parent(parent_instance)
176
- .fulltext(params[:q])
299
+ def autocomplete(includes: nil, joins: nil, order: nil)
300
+ base_query = if context_params.params[:custom_scope].present?
301
+ Rails.logger.debug "Autocomplete: custom scope: #{context_params.params[:custom_scope]}"
302
+ apply_named_custom_scope(resource_class.with_parent(parent_instance), context_params.params[:custom_scope])
303
+ else
304
+ Rails.logger.debug "Autocomplete: no custom scope, using parent instance"
305
+ resource_class.with_parent(parent_instance)
306
+ end
307
+
308
+ # A focused-but-empty field asks for the default suggestions rather than
309
+ # running a fulltext search for "".
310
+ suggesting = params[:q].blank?
311
+ base_query = base_query.fulltext(params[:q]) unless suggesting
312
+ base_query = base_query.joins(joins) if joins.present?
177
313
  base_query = base_query.includes(includes) if includes.present?
314
+ base_query = base_query.order(order) if order.present?
315
+ Rails.logger.debug "Autocomplete: included #{includes.present? ? includes : "none"}"
316
+ Rails.logger.debug "Autocomplete: base_query SQL #{base_query.to_sql}"
317
+
178
318
  results_count = base_query.count
179
- results = base_query.limit(100)
319
+ results = base_query.limit(suggesting ? FlexiAdmin::Config.configuration.autocomplete_suggestions : 100)
320
+ Rails.logger.debug "Autocomplete: base_query results after fulltext search: #{base_query.count}"
180
321
 
181
322
  render FlexiAdmin::Components::Shared::Autocomplete::ResultsComponent.new(results:,
182
- results_count:,
183
- context_params:), layout: false
323
+ results_count:,
324
+ context_params:), layout: false
184
325
  end
185
326
 
186
327
  def datalist
@@ -189,22 +330,25 @@ module FlexiAdmin::Controllers::ResourcesController
189
330
  .order(*context_params.params[:ac_fields].map(&:to_sym))
190
331
  .pluck(*context_params.params[:ac_fields].map(&:to_sym))
191
332
  .uniq
333
+
192
334
  render FlexiAdmin::Components::Shared::Autocomplete::ResultsComponent.new(results:,
193
- context_params:), layout: false
335
+ context_params:), layout: false
194
336
  end
195
337
 
196
338
  private
197
339
 
198
340
  def parent_instance
199
341
  @parent_instance ||= locate_resource(context_params.parent)
342
+ Rails.logger.debug "Parent instance: #{@parent_instance.present? ? format("@parent_instance: %s##{@parent_instance.id}", @parent_instance.class.name) : 'nil'}"
343
+ @parent_instance
200
344
  end
201
345
 
202
346
  def edit_form_component_instance(disabled)
203
- [FlexiAdmin::NAMESPACE, "#{@resource.class.name}::Show::EditFormComponent"].join("::").constantize.new(@resource, disabled: disabled?(disabled))
347
+ namespaced_class('namespace', "#{@resource.class.name}", "Show", "EditFormComponent").new(@resource, disabled: disabled?(disabled))
204
348
  end
205
349
 
206
350
  def new_form_component_instance(resource)
207
- "#{resource.class.name}::NewFormComponent".constantize.new(resource, parent: parent_instance)
351
+ namespaced_class('namespace', "#{resource.class.name}", "NewFormComponent").new(resource, parent: parent_instance)
208
352
  end
209
353
 
210
354
  def locate_resource(encoded_gid)
@@ -219,13 +363,30 @@ module FlexiAdmin::Controllers::ResourcesController
219
363
  end
220
364
 
221
365
  def resource_class
222
- controller_name.classify.constantize
366
+ controller_path = self.class.name.deconstantize
367
+ config_namespace = FlexiAdmin::Config.configuration.namespace&.camelize
368
+
369
+ if config_namespace.present? && controller_path.start_with?(config_namespace)
370
+ if controller_path == config_namespace
371
+ controller_name.classify.constantize
372
+ else
373
+ module_path = controller_path.sub(/^#{Regexp.escape(config_namespace)}::/, '')
374
+ class_name = "#{module_path}::#{controller_name.classify}"
375
+ Object.const_get(class_name)
376
+ end
377
+ else
378
+ controller_name.classify.constantize
379
+ end
223
380
  end
224
381
 
225
382
  def fa_sorted?
226
383
  fa_sort.present? && fa_order.present? && fa_order != "default"
227
384
  end
228
385
 
386
+ def fa_sorting_active?
387
+ fa_sort.present? && fa_order.present?
388
+ end
389
+
229
390
  def fa_sort
230
391
  context_params[:sort]
231
392
  end
@@ -235,6 +396,40 @@ module FlexiAdmin::Controllers::ResourcesController
235
396
  end
236
397
 
237
398
  def disabled?(form_disabled = false)
238
- !current_ability&.can?(:update, resource_class) || form_disabled
399
+ if defined?(CanCan)
400
+ !current_ability&.can?(:update, resource_class) || form_disabled
401
+ else
402
+ form_disabled
403
+ end
404
+ end
405
+
406
+ def namespaced_class(*segments)
407
+ config_namespace = FlexiAdmin::Config.configuration.namespace&.camelize
408
+
409
+ modules = segments.compact.flat_map do |segment|
410
+ if segment == 'namespace'
411
+ config_namespace
412
+ elsif segment.include?('::')
413
+ segment.split('::')
414
+ else
415
+ segment
416
+ end
417
+ end.compact
418
+
419
+ modules.join("::").constantize
420
+ rescue NameError => e
421
+ Rails.logger.warn "Failed to find class: #{modules.join("::")}"
422
+ raise NameError, "Failed to find class: #{modules.join("::")}"
423
+ end
424
+
425
+ def apply_named_custom_scope(scoped_resource_class, scope_name)
426
+ scope_name = scope_name.to_sym
427
+
428
+ unless scoped_resource_class.respond_to?(:fa_allowed_scopes) && scoped_resource_class.fa_allowed_scopes.include?(scope_name)
429
+ raise ArgumentError, "Scope '#{scope_name}' is not whitelisted for #{scoped_resource_class}. " \
430
+ "Add it to fa_allowed_scopes in the model."
431
+ end
432
+
433
+ scoped_resource_class.public_send(scope_name)
239
434
  end
240
435
  end
@@ -4,6 +4,10 @@ module FlexiAdmin
4
4
  class Engine < ::Rails::Engine
5
5
  isolate_namespace FlexiAdmin
6
6
 
7
+ initializer "flexi_admin.locale" do
8
+ config.i18n.load_path += Dir[root.join('config', 'locales', '*.yml')]
9
+ end
10
+
7
11
  initializer "flexi_admin.assets" do |app|
8
12
  app.config.assets.precompile += %w[flexi_admin_main.scss flexi_admin.js]
9
13
  app.config.assets.paths << root.join("app/flexi_admin/javascript")
@@ -1,4 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlexiAdmin::Helpers::ApplicationHelper
4
+ def toast_class(flash_type)
5
+ { success: 'bg-primary', error: 'bg-danger', alert: 'bg-warning', notice: 'bg-primary' }[flash_type.to_sym]
6
+ end
4
7
  end
@@ -0,0 +1,118 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = {
5
+ container: String,
6
+ templateSelector: { type: String, default: '.dynamic-rows > .form-row' }
7
+ }
8
+
9
+ connect() {
10
+ // Initialize remove button visibility on page load
11
+ const containerSelector = this.containerValue || this.element.dataset.addRowContainerValue
12
+ if (containerSelector) {
13
+ const container = document.querySelector(containerSelector)
14
+ if (container) {
15
+ this.updateRemoveButtonsVisibility(container)
16
+ }
17
+ }
18
+ }
19
+
20
+ add(event) {
21
+ event.preventDefault()
22
+
23
+ const button = event.target.closest('button')
24
+ const containerSelector = button?.dataset.addRowContainerValue || this.containerValue
25
+ const templateSelector = button?.dataset.addRowTemplateSelectorValue || this.templateSelectorValue
26
+
27
+ // Store the container selector on the controller element for future use
28
+ if (containerSelector && !this.containerValue) {
29
+ this.element.dataset.addRowContainerValue = containerSelector
30
+ this.containerValue = containerSelector
31
+ }
32
+
33
+ const container = document.querySelector(containerSelector)
34
+ if (!container) {
35
+ return
36
+ }
37
+
38
+ // Get only direct children matching the template selector
39
+ const templates = Array.from(container.children).filter(child =>
40
+ child.matches(templateSelector)
41
+ )
42
+
43
+ if (templates.length === 0) {
44
+ return
45
+ }
46
+
47
+ const lastTemplate = templates[templates.length - 1]
48
+ const clone = lastTemplate.cloneNode(true)
49
+
50
+ // Mark the cloned row as removable
51
+ clone.dataset.removable = 'true'
52
+
53
+ const inputs = clone.querySelectorAll('input, select, textarea')
54
+ inputs.forEach(input => {
55
+ if (input.name) {
56
+ input.name = input.name.replace(/\[(\d+)\]/g, (match, num) => {
57
+ return `[${parseInt(num) + 1}]`
58
+ })
59
+ }
60
+ // Clear values for new row
61
+ if (input.tagName === 'SELECT') {
62
+ input.selectedIndex = 0
63
+ } else {
64
+ input.value = ''
65
+ }
66
+
67
+ input.classList.remove('is-invalid')
68
+ const feedback = input.closest('.field-wrapper')?.querySelector('.invalid-feedback')
69
+ if (feedback) feedback.remove()
70
+ })
71
+
72
+ const labels = clone.querySelectorAll('label')
73
+ labels.forEach(label => {
74
+ if (label.textContent) {
75
+ label.textContent = label.textContent.replace(/(\d+)/g, (match, num) => {
76
+ return parseInt(num) + 1
77
+ })
78
+ }
79
+ })
80
+
81
+ container.appendChild(clone)
82
+ this.updateRemoveButtonsVisibility(container)
83
+ }
84
+
85
+ remove(event) {
86
+ event.preventDefault()
87
+
88
+ const button = event.target.closest('button')
89
+ const templateSelector = button?.dataset.addRowTemplateSelectorValue || this.templateSelectorValue || '.form-row'
90
+
91
+ const row = event.target.closest(templateSelector)
92
+ if (row) {
93
+ const container = row.parentElement
94
+ const rows = container.querySelectorAll(templateSelector)
95
+
96
+ // Prevent removing the last row
97
+ if (rows.length <= 1) {
98
+ return
99
+ }
100
+
101
+ row.remove()
102
+ this.updateRemoveButtonsVisibility(container)
103
+ }
104
+ }
105
+
106
+ updateRemoveButtonsVisibility(container) {
107
+ const templateSelector = this.templateSelectorValue || '.form-row'
108
+ const rows = Array.from(container.children).filter(child =>
109
+ child.matches(templateSelector)
110
+ )
111
+ const removeButtons = container.querySelectorAll('button[data-action*="add-row#remove"]')
112
+
113
+ const shouldShow = rows.length > 1
114
+ removeButtons.forEach(button => {
115
+ button.style.display = shouldShow ? '' : 'none'
116
+ })
117
+ }
118
+ }
@@ -30,6 +30,17 @@ export default class extends Controller {
30
30
  this.hideResults();
31
31
  }
32
32
 
33
+ // Opening an empty field used to show nothing at all, so a user faced with a
34
+ // long list had to guess a search term before seeing a single option. Focus
35
+ // now asks the backend for its default suggestions.
36
+ focus() {
37
+ if (this.inputTarget.disabled) return;
38
+ if (this.inputTarget.value.length > 0) return;
39
+
40
+ this.listTarget.classList.remove("d-none");
41
+ this._search("");
42
+ }
43
+
33
44
  keyup(event) {
34
45
  if (this.inputTarget.value.length > 0) {
35
46
  this.listTarget.classList.remove("d-none");
@@ -45,7 +56,7 @@ export default class extends Controller {
45
56
 
46
57
  this.timeout = setTimeout(() => {
47
58
  this._search(this.inputTarget.value);
48
- }, 200);
59
+ }, 300);
49
60
  }
50
61
 
51
62
  hideResults() {
@@ -118,6 +129,11 @@ export default class extends Controller {
118
129
  const url = new URL(path, window.location.origin);
119
130
  url.searchParams.set("q", string);
120
131
 
132
+ // An app may mark one record as the one the user is working on, so the
133
+ // backend can float it to the top of an otherwise arbitrary list.
134
+ const priority = this.inputTarget.dataset.autocompletePriorityValue;
135
+ if (priority) url.searchParams.set("ac_priority", priority);
136
+
121
137
  try {
122
138
  this.loadingIconTarget.classList.remove("d-none"); // Show loading icon
123
139
  const response = await fetch(url.toString(), {