easy-admin-rails 0.1.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 (203) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +28 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/builds/easy_admin.base.js +43505 -0
  6. data/app/assets/builds/easy_admin.base.js.map +7 -0
  7. data/app/assets/builds/easy_admin.css +6141 -0
  8. data/app/assets/config/easy_admin_manifest.js +1 -0
  9. data/app/assets/images/jsoneditor-icons.svg +749 -0
  10. data/app/assets/stylesheets/easy_admin/application.tailwind.css +390 -0
  11. data/app/components/easy_admin/base_component.rb +35 -0
  12. data/app/components/easy_admin/batch_action_bar_component.rb +125 -0
  13. data/app/components/easy_admin/batch_action_form_component.rb +124 -0
  14. data/app/components/easy_admin/combined_filters_component.rb +232 -0
  15. data/app/components/easy_admin/confirmation_modal_component.rb +61 -0
  16. data/app/components/easy_admin/context_menu_component.rb +161 -0
  17. data/app/components/easy_admin/dashboards/base_card_component.rb +152 -0
  18. data/app/components/easy_admin/dashboards/card_error_component.rb +23 -0
  19. data/app/components/easy_admin/dashboards/card_factory.rb +90 -0
  20. data/app/components/easy_admin/dashboards/card_stream_component.rb +22 -0
  21. data/app/components/easy_admin/dashboards/cards/base_card_component.rb +54 -0
  22. data/app/components/easy_admin/dashboards/cards/chart_card_component.rb +175 -0
  23. data/app/components/easy_admin/dashboards/cards/custom_card_component.rb +50 -0
  24. data/app/components/easy_admin/dashboards/cards/metric_card_component.rb +164 -0
  25. data/app/components/easy_admin/dashboards/cards/table_card_component.rb +148 -0
  26. data/app/components/easy_admin/dashboards/chart_card_component.rb +44 -0
  27. data/app/components/easy_admin/dashboards/metric_card_component.rb +56 -0
  28. data/app/components/easy_admin/dashboards/refresh_stream_component.rb +279 -0
  29. data/app/components/easy_admin/dashboards/show_component.rb +163 -0
  30. data/app/components/easy_admin/dashboards/table_card_component.rb +52 -0
  31. data/app/components/easy_admin/date_picker_component.rb +188 -0
  32. data/app/components/easy_admin/fields/base_component.rb +101 -0
  33. data/app/components/easy_admin/fields/belongs_to_edit_modal_component.rb +117 -0
  34. data/app/components/easy_admin/fields/form/belongs_to_component.rb +82 -0
  35. data/app/components/easy_admin/fields/form/boolean_component.rb +100 -0
  36. data/app/components/easy_admin/fields/form/date_component.rb +55 -0
  37. data/app/components/easy_admin/fields/form/datetime_component.rb +55 -0
  38. data/app/components/easy_admin/fields/form/email_component.rb +55 -0
  39. data/app/components/easy_admin/fields/form/file_component.rb +190 -0
  40. data/app/components/easy_admin/fields/form/has_many_component.rb +416 -0
  41. data/app/components/easy_admin/fields/form/json_component.rb +81 -0
  42. data/app/components/easy_admin/fields/form/number_component.rb +55 -0
  43. data/app/components/easy_admin/fields/form/select_component.rb +326 -0
  44. data/app/components/easy_admin/fields/form/text_component.rb +55 -0
  45. data/app/components/easy_admin/fields/form/textarea_component.rb +54 -0
  46. data/app/components/easy_admin/fields/index/belongs_to_component.rb +93 -0
  47. data/app/components/easy_admin/fields/index/boolean_component.rb +29 -0
  48. data/app/components/easy_admin/fields/index/date_component.rb +13 -0
  49. data/app/components/easy_admin/fields/index/datetime_component.rb +13 -0
  50. data/app/components/easy_admin/fields/index/email_component.rb +24 -0
  51. data/app/components/easy_admin/fields/index/filters/base_component.rb +48 -0
  52. data/app/components/easy_admin/fields/index/filters/boolean_component.rb +96 -0
  53. data/app/components/easy_admin/fields/index/filters/date_component.rb +182 -0
  54. data/app/components/easy_admin/fields/index/filters/number_component.rb +30 -0
  55. data/app/components/easy_admin/fields/index/filters/select_component.rb +101 -0
  56. data/app/components/easy_admin/fields/index/filters/string_component.rb +32 -0
  57. data/app/components/easy_admin/fields/index/json_component.rb +23 -0
  58. data/app/components/easy_admin/fields/index/number_component.rb +20 -0
  59. data/app/components/easy_admin/fields/index/select_component.rb +25 -0
  60. data/app/components/easy_admin/fields/index/text_component.rb +20 -0
  61. data/app/components/easy_admin/fields/inline_edit_modal_component.rb +135 -0
  62. data/app/components/easy_admin/fields/inline_edit_trigger_component.rb +144 -0
  63. data/app/components/easy_admin/fields/show/belongs_to_component.rb +93 -0
  64. data/app/components/easy_admin/fields/show/boolean_component.rb +21 -0
  65. data/app/components/easy_admin/fields/show/date_component.rb +13 -0
  66. data/app/components/easy_admin/fields/show/datetime_component.rb +13 -0
  67. data/app/components/easy_admin/fields/show/email_component.rb +19 -0
  68. data/app/components/easy_admin/fields/show/file_component.rb +304 -0
  69. data/app/components/easy_admin/fields/show/has_many_component.rb +192 -0
  70. data/app/components/easy_admin/fields/show/json_component.rb +45 -0
  71. data/app/components/easy_admin/fields/show/number_component.rb +20 -0
  72. data/app/components/easy_admin/fields/show/select_component.rb +25 -0
  73. data/app/components/easy_admin/fields/show/text_component.rb +17 -0
  74. data/app/components/easy_admin/fields/show/textarea_component.rb +26 -0
  75. data/app/components/easy_admin/filters_component.rb +120 -0
  76. data/app/components/easy_admin/form_tabs_component.rb +166 -0
  77. data/app/components/easy_admin/infinite_scroll_component.rb +82 -0
  78. data/app/components/easy_admin/lazy_chart_card_component.rb +128 -0
  79. data/app/components/easy_admin/lazy_metric_card_component.rb +76 -0
  80. data/app/components/easy_admin/modal_frame_component.rb +26 -0
  81. data/app/components/easy_admin/navbar_component.rb +226 -0
  82. data/app/components/easy_admin/notification_component.rb +83 -0
  83. data/app/components/easy_admin/pagination_component.rb +188 -0
  84. data/app/components/easy_admin/quick_filters_component.rb +65 -0
  85. data/app/components/easy_admin/resource_pagination_component.rb +14 -0
  86. data/app/components/easy_admin/resources/index_component.rb +211 -0
  87. data/app/components/easy_admin/resources/index_frame_component.rb +88 -0
  88. data/app/components/easy_admin/resources/show_page_actions_component.rb +324 -0
  89. data/app/components/easy_admin/resources/table_cell_component.rb +145 -0
  90. data/app/components/easy_admin/resources/table_component.rb +206 -0
  91. data/app/components/easy_admin/resources/table_row_component.rb +160 -0
  92. data/app/components/easy_admin/row_action_form_component.rb +127 -0
  93. data/app/components/easy_admin/scopes_component.rb +224 -0
  94. data/app/components/easy_admin/settings_sidebar_component.rb +140 -0
  95. data/app/components/easy_admin/show_layout_component.rb +600 -0
  96. data/app/components/easy_admin/sidebar_component.rb +174 -0
  97. data/app/components/easy_admin/turbo/response_component.rb +40 -0
  98. data/app/components/easy_admin/turbo/stream_component.rb +28 -0
  99. data/app/controllers/easy_admin/application_controller.rb +66 -0
  100. data/app/controllers/easy_admin/batch_actions_controller.rb +166 -0
  101. data/app/controllers/easy_admin/confirmation_modal_controller.rb +20 -0
  102. data/app/controllers/easy_admin/dashboard_controller.rb +6 -0
  103. data/app/controllers/easy_admin/dashboards_controller.rb +123 -0
  104. data/app/controllers/easy_admin/passwords_controller.rb +15 -0
  105. data/app/controllers/easy_admin/registrations_controller.rb +52 -0
  106. data/app/controllers/easy_admin/resources_controller.rb +907 -0
  107. data/app/controllers/easy_admin/row_actions_controller.rb +216 -0
  108. data/app/controllers/easy_admin/sessions_controller.rb +32 -0
  109. data/app/controllers/easy_admin/settings_controller.rb +94 -0
  110. data/app/helpers/easy_admin/application_helper.rb +4 -0
  111. data/app/helpers/easy_admin/dashboards_helper.rb +121 -0
  112. data/app/helpers/easy_admin/fields_helper.rb +27 -0
  113. data/app/helpers/easy_admin/pagy_helper.rb +30 -0
  114. data/app/helpers/easy_admin/resources_helper.rb +39 -0
  115. data/app/javascript/easy_admin/application.js +12 -0
  116. data/app/javascript/easy_admin/controllers/batch_modal_controller.js +66 -0
  117. data/app/javascript/easy_admin/controllers/batch_selection_controller.js +223 -0
  118. data/app/javascript/easy_admin/controllers/chart_controller.js +216 -0
  119. data/app/javascript/easy_admin/controllers/collapsible_filters_controller.js +118 -0
  120. data/app/javascript/easy_admin/controllers/confirmation_modal_controller.js +64 -0
  121. data/app/javascript/easy_admin/controllers/context_menu_controller.js +227 -0
  122. data/app/javascript/easy_admin/controllers/date_picker_controller.js +309 -0
  123. data/app/javascript/easy_admin/controllers/dropdown_controller.js +63 -0
  124. data/app/javascript/easy_admin/controllers/event_emitter_controller.js +19 -0
  125. data/app/javascript/easy_admin/controllers/file_controller.js +121 -0
  126. data/app/javascript/easy_admin/controllers/form_tabs_controller.js +100 -0
  127. data/app/javascript/easy_admin/controllers/has_many_search_controller.js +76 -0
  128. data/app/javascript/easy_admin/controllers/infinite_scroll_controller.js +174 -0
  129. data/app/javascript/easy_admin/controllers/ios_alert_controller.js +195 -0
  130. data/app/javascript/easy_admin/controllers/jsoneditor_controller.js +88 -0
  131. data/app/javascript/easy_admin/controllers/modal_controller.js +75 -0
  132. data/app/javascript/easy_admin/controllers/navbar_scroll_controller.js +76 -0
  133. data/app/javascript/easy_admin/controllers/notification_controller.js +48 -0
  134. data/app/javascript/easy_admin/controllers/row_action_controller.js +124 -0
  135. data/app/javascript/easy_admin/controllers/row_modal_controller.js +59 -0
  136. data/app/javascript/easy_admin/controllers/select_field_controller.js +618 -0
  137. data/app/javascript/easy_admin/controllers/settings_button_controller.js +8 -0
  138. data/app/javascript/easy_admin/controllers/settings_sidebar_controller.js +186 -0
  139. data/app/javascript/easy_admin/controllers/sidebar_controller.js +102 -0
  140. data/app/javascript/easy_admin/controllers/sidebar_mobile_controller.js +23 -0
  141. data/app/javascript/easy_admin/controllers/sidebar_nav_controller.js +96 -0
  142. data/app/javascript/easy_admin/controllers/table_controller.js +28 -0
  143. data/app/javascript/easy_admin/controllers/table_row_controller.js +16 -0
  144. data/app/javascript/easy_admin/controllers/toggle_switch_controller.js +22 -0
  145. data/app/javascript/easy_admin/controllers/turbo_stream_redirect.js +9 -0
  146. data/app/javascript/easy_admin/controllers.js +54 -0
  147. data/app/javascript/easy_admin.base.js +4 -0
  148. data/app/models/easy_admin/admin_user.rb +53 -0
  149. data/app/models/easy_admin/application_record.rb +5 -0
  150. data/app/views/easy_admin/dashboard/index.html.erb +3 -0
  151. data/app/views/easy_admin/dashboards/show.html.erb +7 -0
  152. data/app/views/easy_admin/passwords/edit.html.erb +42 -0
  153. data/app/views/easy_admin/passwords/new.html.erb +41 -0
  154. data/app/views/easy_admin/registrations/new.html.erb +65 -0
  155. data/app/views/easy_admin/resources/_redirect.turbo_stream.erb +3 -0
  156. data/app/views/easy_admin/resources/_table_rows.html.erb +46 -0
  157. data/app/views/easy_admin/resources/edit.html.erb +151 -0
  158. data/app/views/easy_admin/resources/index.html.erb +12 -0
  159. data/app/views/easy_admin/resources/index.turbo_stream.erb +139 -0
  160. data/app/views/easy_admin/resources/index_frame.html.erb +142 -0
  161. data/app/views/easy_admin/resources/new.html.erb +100 -0
  162. data/app/views/easy_admin/resources/show.html.erb +31 -0
  163. data/app/views/easy_admin/sessions/new.html.erb +55 -0
  164. data/app/views/easy_admin/settings/_form.html.erb +51 -0
  165. data/app/views/easy_admin/settings/index.html.erb +53 -0
  166. data/app/views/layouts/easy_admin/application.html.erb +48 -0
  167. data/app/views/layouts/easy_admin/auth.html.erb +34 -0
  168. data/config/initializers/easy_admin_card_factory.rb +27 -0
  169. data/config/initializers/pagy.rb +15 -0
  170. data/config/initializers/rack_mini_profiler.rb +67 -0
  171. data/config/routes.rb +70 -0
  172. data/db/migrate/20250101000001_create_easy_admin_admin_users.rb +45 -0
  173. data/lib/easy-admin.rb +32 -0
  174. data/lib/easy_admin/action.rb +159 -0
  175. data/lib/easy_admin/batch_action.rb +134 -0
  176. data/lib/easy_admin/configuration.rb +75 -0
  177. data/lib/easy_admin/dashboard.rb +110 -0
  178. data/lib/easy_admin/dashboard_registry.rb +30 -0
  179. data/lib/easy_admin/delete_action.rb +22 -0
  180. data/lib/easy_admin/engine.rb +54 -0
  181. data/lib/easy_admin/field.rb +118 -0
  182. data/lib/easy_admin/resource.rb +806 -0
  183. data/lib/easy_admin/resource_registry.rb +22 -0
  184. data/lib/easy_admin/types/json_type.rb +25 -0
  185. data/lib/easy_admin/version.rb +3 -0
  186. data/lib/generators/easy_admin/auth_generator.rb +69 -0
  187. data/lib/generators/easy_admin/card/card_generator.rb +94 -0
  188. data/lib/generators/easy_admin/card/templates/card_component.rb.erb +127 -0
  189. data/lib/generators/easy_admin/card/templates/card_component_spec.rb.erb +122 -0
  190. data/lib/generators/easy_admin/install/templates/easy_admin.rb +31 -0
  191. data/lib/generators/easy_admin/install_generator.rb +25 -0
  192. data/lib/generators/easy_admin/rbac/rbac_generator.rb +244 -0
  193. data/lib/generators/easy_admin/rbac/templates/add_rbac_to_admin_users.rb +23 -0
  194. data/lib/generators/easy_admin/rbac/templates/super_admin.rb +34 -0
  195. data/lib/generators/easy_admin/resource_generator.rb +43 -0
  196. data/lib/generators/easy_admin/templates/AUTH_README +35 -0
  197. data/lib/generators/easy_admin/templates/README +27 -0
  198. data/lib/generators/easy_admin/templates/create_easy_admin_admin_users.rb +45 -0
  199. data/lib/generators/easy_admin/templates/devise.rb +267 -0
  200. data/lib/generators/easy_admin/templates/easy_admin.rb +24 -0
  201. data/lib/generators/easy_admin/templates/resource.rb +29 -0
  202. data/lib/tasks/easy_admin_tasks.rake +4 -0
  203. metadata +445 -0
@@ -0,0 +1,907 @@
1
+ module EasyAdmin
2
+ class ResourcesController < ApplicationController
3
+ before_action :load_resource_class
4
+ before_action :load_record, only: [:show, :edit, :update, :destroy, :edit_field, :update_field, :belongs_to_reattach, :belongs_to_edit_attached, :update_belongs_to_attached]
5
+
6
+ def index
7
+ # Set up data needed for both HTML and turbo stream
8
+ sort_field = params[:sort]
9
+ sort_direction = params[:direction] == 'desc' ? 'desc' : 'asc'
10
+
11
+ # Handle scope filtering
12
+ @current_scope = determine_current_scope
13
+
14
+ # Only calculate scope counts for HTML requests, not for infinite scroll (turbo_stream)
15
+ if @resource_class.has_scopes? && !request.format.turbo_stream?
16
+ @scope_counts = calculate_scope_counts
17
+ end
18
+
19
+ # Start with base records (ensure we have a relation, not just a class)
20
+ @records = apply_scope(@resource_class.model_class.all)
21
+
22
+ # Apply eager loading for index action
23
+ if @resource_class.index_includes.any?
24
+ @records = @records.includes(@resource_class.index_includes)
25
+ end
26
+
27
+ # Apply Ransack filtering if present
28
+ if params[:q].present?
29
+ @search_query = @records.ransack(params[:q])
30
+ @records = @search_query.result
31
+ end
32
+
33
+ # Apply text search if present (fallback/additional search)
34
+ if params[:search].present?
35
+ @records = @resource_class.search_records(params[:search], sort_field: sort_field, sort_direction: sort_direction, records: @records)
36
+ else
37
+ @records = apply_sorting(@records, sort_field, sort_direction)
38
+ end
39
+
40
+ # Apply period filtering
41
+ @records = apply_period_filter(@records)
42
+
43
+ # Apply pagination using Pagy with resource-specific configuration
44
+ items_per_page = @resource_class.respond_to?(:items_per_page) ? @resource_class.items_per_page : 20
45
+
46
+ # Reset to page 1 if filtering/searching is applied to avoid overflow
47
+ current_page = if has_active_filters?
48
+ 1
49
+ else
50
+ params[:page] || 1
51
+ end
52
+
53
+ pagination_options = {
54
+ items: items_per_page,
55
+ limit: items_per_page,
56
+ page: current_page,
57
+ overflow: :last_page # Handle overflow by redirecting to last valid page
58
+ }
59
+
60
+ # Use countless pagination if enabled for performance
61
+ if @resource_class.respond_to?(:countless_enabled?) && @resource_class.countless_enabled?
62
+ @pagy, @records = pagy_countless(@records, **pagination_options)
63
+ else
64
+ @pagy, @records = pagy(@records, **pagination_options)
65
+ end
66
+ @current_sort_field = sort_field
67
+ @current_sort_direction = sort_direction
68
+ @current_period = params[:period]
69
+
70
+ respond_to do |format|
71
+ format.html do
72
+ # Check if this is a turbo frame request
73
+ if turbo_frame_request?
74
+ render plain: EasyAdmin::Resources::IndexFrameComponent.new(
75
+ resource_class: @resource_class,
76
+ records: @records,
77
+ pagy: @pagy,
78
+ current_params: params.permit(:search, :scope, :sort, :direction, :period, :page, q: {}),
79
+ current_path: request.path,
80
+ current_user: current_admin_user
81
+ ).call
82
+ else
83
+ # Regular page load - shows skeleton with layout
84
+ # Will render app/views/easy_admin/resources/index.html.erb
85
+ end
86
+ end
87
+ format.turbo_stream do
88
+ # For infinite scroll - append new rows to existing table
89
+ table_rows_html = @records.map do |record|
90
+ EasyAdmin::Resources::TableRowComponent.new(
91
+ record: record,
92
+ resource_class: @resource_class
93
+ ).call
94
+ end.join.html_safe
95
+
96
+ render turbo_stream: [
97
+ turbo_stream.append("records-container", table_rows_html),
98
+ turbo_stream.replace("infinite-scroll-container", EasyAdmin::InfiniteScrollComponent.new(
99
+ pagy: @pagy,
100
+ resource_class: @resource_class,
101
+ current_params: params.permit(:search, :scope, :sort, :direction, :period, :page, q: {}),
102
+ current_path: request.path
103
+ ).call),
104
+ turbo_stream.replace("combined-filters", EasyAdmin::CombinedFiltersComponent.new(
105
+ resource_class: @resource_class,
106
+ current_params: params.permit(:search, :scope, :sort, :direction, :period, :page, q: {}),
107
+ search_params: params[:q] || {},
108
+ current_period: @current_period
109
+ ).call)
110
+ ]
111
+ end
112
+ end
113
+ end
114
+
115
+ def show
116
+ end
117
+
118
+ # Lazy loading endpoints for show page components
119
+ def metric
120
+ metric_config = find_metric_config(params[:metric_id])
121
+ render turbo_stream: turbo_stream.replace(
122
+ "metric-#{params[:metric_id]}",
123
+ EasyAdmin::LazyMetricCardComponent.new(metric_config: metric_config, record: @record)
124
+ )
125
+ end
126
+
127
+ def chart
128
+ chart_config = find_chart_config(params[:chart_id])
129
+ render turbo_stream: turbo_stream.replace(
130
+ "chart-#{params[:chart_id]}",
131
+ EasyAdmin::LazyChartCardComponent.new(chart_config: chart_config, record: @record)
132
+ )
133
+ end
134
+
135
+ def tab_content
136
+ tab_config = find_tab_config(params[:tab_name])
137
+ render turbo_stream: turbo_stream.replace(
138
+ "tab-#{params[:tab_name]}-content",
139
+ render_tab_elements(tab_config[:elements])
140
+ )
141
+ end
142
+
143
+ def new
144
+ @record = @resource_class.model_class.new
145
+ end
146
+
147
+ def create
148
+ @record = @resource_class.model_class.new(record_params)
149
+
150
+ if @record.save
151
+ redirect_to easy_admin.resource_path(@resource_class.route_key, @record),
152
+ notice: "#{@resource_class.singular_title} was successfully created."
153
+ else
154
+ render :new
155
+ end
156
+ end
157
+
158
+ def edit
159
+ end
160
+
161
+ def update
162
+ Rails.logger.debug "Update called with params: #{record_params}"
163
+ Rails.logger.debug "Record before update: #{@record.inspect}"
164
+
165
+ if @record.update(record_params)
166
+ Rails.logger.debug "Record after update: #{@record.reload.inspect}"
167
+ respond_to do |format|
168
+ format.html {
169
+ redirect_to easy_admin.resource_path(@resource_class.route_key, @record),
170
+ notice: "#{@resource_class.singular_title} was successfully updated."
171
+ }
172
+ format.turbo_stream {
173
+ render turbo_stream: [
174
+ turbo_stream.update("notifications",
175
+ EasyAdmin::NotificationComponent.new(
176
+ type: :success,
177
+ message: "#{@resource_class.singular_title} was successfully updated!",
178
+ title: "Success"
179
+ ).call
180
+ )
181
+ ]
182
+ }
183
+ end
184
+ else
185
+ render :edit
186
+ end
187
+ end
188
+
189
+ def destroy
190
+ @record.destroy
191
+ redirect_to easy_admin.resources_path(@resource_class.route_key),
192
+ notice: "#{@resource_class.singular_title} was successfully deleted."
193
+ end
194
+
195
+ # Inline field editing actions
196
+ def edit_field
197
+ @field_name = params[:field]
198
+ @field_config = find_field_config(@field_name)
199
+
200
+ unless @field_config
201
+ render json: { error: "Field '#{@field_name}' not found" }, status: :not_found
202
+ return
203
+ end
204
+
205
+ respond_to do |format|
206
+ format.html do
207
+ render plain: EasyAdmin::Fields::InlineEditModalComponent.new(
208
+ record: @record,
209
+ field_config: @field_config,
210
+ resource_class: @resource_class
211
+ ).call
212
+ end
213
+ format.turbo_stream do
214
+ render turbo_stream: turbo_stream.update("modal",
215
+ EasyAdmin::Fields::InlineEditModalComponent.new(
216
+ record: @record,
217
+ field_config: @field_config,
218
+ resource_class: @resource_class
219
+ ).call
220
+ )
221
+ end
222
+ end
223
+ end
224
+
225
+ def update_field
226
+ @field_name = params[:field]
227
+ @field_config = find_field_config(@field_name)
228
+
229
+ unless @field_config
230
+ render json: { error: "Field '#{@field_name}' not found" }, status: :not_found
231
+ return
232
+ end
233
+
234
+ # For belongs_to fields, we need to handle foreign key updates
235
+ if @field_config[:type] == :belongs_to
236
+ association_name = @field_config[:name]
237
+ foreign_key = get_foreign_key_name(association_name)
238
+ field_value = params.dig(@resource_class.param_key, foreign_key)
239
+ update_attrs = { foreign_key.to_sym => field_value }
240
+ else
241
+ # Create a field object to handle normalization
242
+ field_obj = EasyAdmin::Field.new(@field_name, @field_config[:type], @field_config)
243
+
244
+ # Get the field value from params
245
+ field_value = params.dig(@resource_class.param_key, @field_name)
246
+
247
+ # Normalize the input
248
+ update_attrs = { @field_name => field_value }
249
+ field_obj.normalize_input!(update_attrs)
250
+ end
251
+
252
+ if @record.update(update_attrs)
253
+ respond_to do |format|
254
+ format.turbo_stream do
255
+ # Update the table cell with the new value
256
+ render turbo_stream: [
257
+ turbo_stream.replace(
258
+ "#{helpers.dom_id(@record)}_#{@field_name}",
259
+ EasyAdmin::Resources::TableCellComponent.new(
260
+ record: @record.reload,
261
+ field_config: @field_config,
262
+ resource_class: @resource_class
263
+ ).call
264
+ ),
265
+ turbo_stream.update("notifications",
266
+ EasyAdmin::NotificationComponent.new(
267
+ type: :success,
268
+ message: "#{@field_config[:label]} updated successfully!",
269
+ title: "Success"
270
+ ).call
271
+ )
272
+ ]
273
+ end
274
+ format.json { render json: { status: 'success' } }
275
+ end
276
+ else
277
+ respond_to do |format|
278
+ format.turbo_stream do
279
+ render turbo_stream: turbo_stream.update("notifications",
280
+ EasyAdmin::NotificationComponent.new(
281
+ type: :error,
282
+ message: @record.errors.full_messages.join(', '),
283
+ title: "Error"
284
+ ).call
285
+ )
286
+ end
287
+ format.json { render json: { errors: @record.errors } }
288
+ end
289
+ end
290
+ end
291
+
292
+ def belongs_to_reattach
293
+ @field_name = params[:field]
294
+ @field_config = find_field_config(@field_name)
295
+
296
+ unless @field_config
297
+ render json: { error: "Field '#{@field_name}' not found" }, status: :not_found
298
+ return
299
+ end
300
+
301
+ # Convert belongs_to field to select field format for reattaching
302
+ select_field_config = prepare_belongs_to_as_select(@field_config)
303
+
304
+ respond_to do |format|
305
+ format.html do
306
+ render plain: EasyAdmin::Fields::InlineEditModalComponent.new(
307
+ record: @record,
308
+ field_config: select_field_config,
309
+ resource_class: @resource_class
310
+ ).call
311
+ end
312
+ end
313
+ end
314
+
315
+ def belongs_to_edit_attached
316
+ @field_name = params[:field]
317
+ @field_config = find_field_config(@field_name)
318
+
319
+ unless @field_config
320
+ render json: { error: "Field '#{@field_name}' not found" }, status: :not_found
321
+ return
322
+ end
323
+
324
+ unless @field_config[:type] == :belongs_to
325
+ render json: { error: "Field '#{@field_name}' is not a belongs_to field" }, status: :bad_request
326
+ return
327
+ end
328
+
329
+ # Get the associated record
330
+ associated_record = @record.public_send(@field_config[:name])
331
+
332
+ unless associated_record
333
+ render json: { error: "No associated record found" }, status: :not_found
334
+ return
335
+ end
336
+
337
+ # Get the resource class for the associated record
338
+ association_name = @field_config[:name]
339
+ associated_model_class = @resource_class.model_class.reflect_on_association(association_name).klass
340
+ associated_resource_class = find_resource_class_for_model(associated_model_class)
341
+
342
+ unless associated_resource_class
343
+ render json: { error: "No resource class found for #{associated_model_class.name}" }, status: :not_found
344
+ return
345
+ end
346
+
347
+ respond_to do |format|
348
+ format.html do
349
+ render plain: EasyAdmin::Fields::BelongsToEditModalComponent.new(
350
+ record: associated_record,
351
+ resource_class: associated_resource_class,
352
+ parent_record: @record,
353
+ parent_field: @field_config
354
+ ).call
355
+ end
356
+ format.turbo_stream do
357
+ render turbo_stream: turbo_stream.update("modal",
358
+ EasyAdmin::Fields::BelongsToEditModalComponent.new(
359
+ record: associated_record,
360
+ resource_class: associated_resource_class,
361
+ parent_record: @record,
362
+ parent_field: @field_config
363
+ ).call
364
+ )
365
+ end
366
+ end
367
+ end
368
+
369
+ def update_belongs_to_attached
370
+ @field_name = params[:field]
371
+ @field_config = find_field_config(@field_name)
372
+ @attached_id = params[:attached_id]
373
+
374
+ unless @field_config
375
+ render json: { error: "Field '#{@field_name}' not found" }, status: :not_found
376
+ return
377
+ end
378
+
379
+ # Get the associated record
380
+ association_name = @field_config[:name]
381
+ associated_model_class = @resource_class.model_class.reflect_on_association(association_name).klass
382
+ associated_resource_class = find_resource_class_for_model(associated_model_class)
383
+
384
+ # Find the attached record
385
+ attached_record = associated_model_class.find(@attached_id)
386
+
387
+ unless attached_record
388
+ render json: { error: "Attached record not found" }, status: :not_found
389
+ return
390
+ end
391
+
392
+ # Get the update parameters for the attached record
393
+ raw_params = params.dig(associated_resource_class.param_key) || {}
394
+
395
+ # Get permitted attributes from the resource class
396
+ permitted_attributes = get_permitted_attributes(associated_resource_class)
397
+ update_params = raw_params.permit(*permitted_attributes)
398
+
399
+ if attached_record.update(update_params)
400
+ respond_to do |format|
401
+ format.turbo_stream do
402
+ # Update the parent table cell to reflect any changes in the associated record
403
+ render turbo_stream: [
404
+ turbo_stream.replace(
405
+ "#{helpers.dom_id(@record)}_#{@field_name}",
406
+ EasyAdmin::Resources::TableCellComponent.new(
407
+ record: @record.reload,
408
+ field_config: @field_config,
409
+ resource_class: @resource_class
410
+ ).call
411
+ ),
412
+ turbo_stream.update("notifications",
413
+ EasyAdmin::NotificationComponent.new(
414
+ type: :success,
415
+ message: "#{associated_resource_class.singular_title} updated successfully!",
416
+ title: "Success"
417
+ ).call
418
+ )
419
+ ]
420
+ end
421
+ end
422
+ else
423
+ respond_to do |format|
424
+ format.turbo_stream do
425
+ render turbo_stream: turbo_stream.update("notifications",
426
+ EasyAdmin::NotificationComponent.new(
427
+ type: :error,
428
+ message: attached_record.errors.full_messages.join(', '),
429
+ title: "Error"
430
+ ).call
431
+ )
432
+ end
433
+ end
434
+ end
435
+ end
436
+
437
+ def suggest
438
+ field_config = @resource_class.fields_config.find { |f| f[:name].to_s == params[:field] }
439
+
440
+ Rails.logger.debug "Suggest field_config for #{params[:field]}: #{field_config}"
441
+
442
+ unless field_config && field_config[:suggest]
443
+ render json: { error: "Field not found or suggest not configured" }, status: :not_found
444
+ return
445
+ end
446
+
447
+ search_term = params[:q].to_s.strip
448
+ limit = field_config[:suggest][:limit] || 10
449
+
450
+ results = get_suggest_options(field_config, search_term, limit)
451
+
452
+ render json: { options: results }
453
+ end
454
+
455
+ private
456
+
457
+ def load_resource_class
458
+ resource_name = params[:resource_name]
459
+ @resource_class = EasyAdmin::ResourceRegistry.find_resource(resource_name)
460
+
461
+ unless @resource_class
462
+ raise ActionController::RoutingError, "Resource '#{resource_name}' not found"
463
+ end
464
+ end
465
+
466
+ def load_record
467
+ @record = @resource_class.find_record(params[:id])
468
+ end
469
+
470
+ def record_params
471
+ permitted_attrs = get_permitted_attributes(@resource_class)
472
+ Rails.logger.debug "Permitted attributes: #{permitted_attrs}"
473
+ result = params.require(@resource_class.param_key).permit(*permitted_attrs)
474
+ Rails.logger.debug "Record params after permit: #{result}"
475
+ result
476
+ end
477
+
478
+ def determine_current_scope
479
+ if params[:scope].present?
480
+ scope_config = @resource_class.scopes.find { |s| s[:name].to_s == params[:scope].to_s }
481
+ scope_config ? scope_config[:name] : @resource_class.default_scope&.dig(:name)
482
+ else
483
+ @resource_class.default_scope&.dig(:name)
484
+ end
485
+ end
486
+
487
+ def apply_scope(records)
488
+ return records unless @resource_class.has_scopes? && @current_scope
489
+
490
+ scope_config = @resource_class.scopes.find { |s| s[:name] == @current_scope }
491
+
492
+ if scope_config && scope_config[:name] != :all
493
+ scope_method = scope_config[:scope_method]
494
+ if records.respond_to?(scope_method)
495
+ records.public_send(scope_method)
496
+ else
497
+ records
498
+ end
499
+ else
500
+ records
501
+ end
502
+ end
503
+
504
+ def calculate_scope_counts
505
+ counts = {}
506
+
507
+ @resource_class.scopes.each do |scope_config|
508
+ scope_name = scope_config[:name]
509
+
510
+ if scope_name == :all
511
+ counts[scope_name] = @resource_class.model_class.count
512
+ else
513
+ scope_method = scope_config[:scope_method]
514
+ if @resource_class.model_class.respond_to?(scope_method)
515
+ counts[scope_name] = @resource_class.model_class.public_send(scope_method).count
516
+ else
517
+ counts[scope_name] = 0
518
+ end
519
+ end
520
+ end
521
+
522
+ counts
523
+ end
524
+
525
+ def apply_period_filter(records)
526
+ return records unless params[:period].present? && records.respond_to?(:where)
527
+
528
+ date_range = case params[:period]
529
+ when 'today'
530
+ Date.current.beginning_of_day..Date.current.end_of_day
531
+ when '7d'
532
+ 7.days.ago.beginning_of_day..Date.current.end_of_day
533
+ when '30d'
534
+ 30.days.ago.beginning_of_day..Date.current.end_of_day
535
+ when '90d'
536
+ 90.days.ago.beginning_of_day..Date.current.end_of_day
537
+ when '1y'
538
+ 1.year.ago.beginning_of_day..Date.current.end_of_day
539
+ else
540
+ return records
541
+ end
542
+
543
+ # Try to filter by created_at, updated_at, or the first timestamp field
544
+ timestamp_field = determine_timestamp_field(records)
545
+ return records unless timestamp_field
546
+
547
+ records.where(timestamp_field => date_range)
548
+ end
549
+
550
+ def find_field_config(field_name)
551
+ @resource_class.fields_config.find { |field| field[:name].to_s == field_name.to_s }
552
+ end
553
+
554
+ def find_resource_class_for_model(model_class)
555
+ # Try to find the resource class by convention (e.g., User -> UserResource)
556
+ resource_class_name = "#{model_class.name}Resource"
557
+ begin
558
+ resource_class_name.constantize
559
+ rescue NameError
560
+ # If convention doesn't work, search through all resource classes
561
+ EasyAdmin.resource_registry.values.find { |rc| rc.model_class == model_class }
562
+ end
563
+ end
564
+
565
+ def get_permitted_attributes(resource_class)
566
+ # Get all editable field names from the resource class
567
+ permitted_attrs = []
568
+
569
+ resource_class.form_fields.each do |field_config|
570
+ next if field_config[:readonly]
571
+
572
+ field_name = field_config[:name]
573
+
574
+ # Handle different field types that might need special parameter handling
575
+ case field_config[:type]
576
+ when :has_many
577
+ # For has_many relationships, we need to permit an array of IDs
578
+ permitted_attrs << { "#{field_name.to_s.singularize}_ids" => [] }
579
+ when :belongs_to
580
+ # For belongs_to, we need the foreign key
581
+ if field_config[:multiple]
582
+ permitted_attrs << { "#{field_name}_ids" => [] }
583
+ else
584
+ foreign_key = get_foreign_key_name(field_name)
585
+ permitted_attrs << foreign_key.to_sym
586
+ end
587
+ when :select
588
+ # For select fields with multiple option
589
+ if field_config[:multiple]
590
+ permitted_attrs << { field_name => [] }
591
+ else
592
+ permitted_attrs << field_name
593
+ end
594
+ else
595
+ # For regular fields
596
+ permitted_attrs << field_name
597
+ end
598
+ end
599
+
600
+ permitted_attrs
601
+ end
602
+
603
+ def prepare_belongs_to_as_select(field_config)
604
+ # Convert belongs_to field to select field for reattaching
605
+ association_name = field_config[:name]
606
+ foreign_key = get_foreign_key_name(association_name)
607
+
608
+ # Get available options for the select
609
+ options = get_belongs_to_options(association_name, field_config)
610
+
611
+ # Return field config formatted as a select field, preserving original info
612
+ field_config.merge(
613
+ type: :select,
614
+ name: foreign_key, # Use foreign key instead of association name
615
+ label: "Select #{field_config[:label]}",
616
+ options: options,
617
+ original_type: :belongs_to,
618
+ original_name: association_name
619
+ )
620
+ end
621
+
622
+ def get_foreign_key_name(association_name)
623
+ model_class = @resource_class.model_class
624
+ if model_class.reflect_on_association(association_name)
625
+ model_class.reflect_on_association(association_name).foreign_key
626
+ else
627
+ "#{association_name}_id"
628
+ end
629
+ end
630
+
631
+ def get_belongs_to_options(association_name, field_config)
632
+ model_class = @resource_class.model_class
633
+
634
+ # Get the associated model class
635
+ if model_class.reflect_on_association(association_name)
636
+ association_class = model_class.reflect_on_association(association_name).klass
637
+ else
638
+ association_class = association_name.to_s.classify.constantize rescue nil
639
+ end
640
+
641
+ return [] unless association_class
642
+
643
+ # Get display method from field config
644
+ display_method = field_config[:display_method] || :name
645
+
646
+ # Get all records and format as [label, value] pairs
647
+ association_class.all.limit(100).map do |record|
648
+ label = if record.respond_to?(display_method)
649
+ record.public_send(display_method)
650
+ elsif record.respond_to?(:name)
651
+ record.name
652
+ elsif record.respond_to?(:title)
653
+ record.title
654
+ else
655
+ record.to_s
656
+ end
657
+ [label, record.id]
658
+ end
659
+ end
660
+
661
+ def get_suggest_options(field_config, search_term, limit)
662
+ # Handle different field types that support suggest
663
+ case field_config[:type]
664
+ when :belongs_to
665
+ get_belongs_to_suggest_options(field_config[:name], field_config, search_term, limit)
666
+ when :has_many
667
+ get_has_many_suggest_options(field_config[:name], field_config, search_term, limit)
668
+ when :select
669
+ # For regular select fields, filter static options
670
+ get_static_suggest_options(field_config, search_term, limit)
671
+ else
672
+ []
673
+ end
674
+ end
675
+
676
+ def get_belongs_to_suggest_options(association_name, field_config, search_term, limit)
677
+ model_class = @resource_class.model_class
678
+
679
+ # Get the associated model class
680
+ if model_class.reflect_on_association(association_name)
681
+ association_class = model_class.reflect_on_association(association_name).klass
682
+ else
683
+ association_class = association_name.to_s.classify.constantize rescue nil
684
+ end
685
+
686
+ return [] unless association_class
687
+
688
+ # Get display method and search fields from field config
689
+ display_method = field_config[:display_method] || :name
690
+ suggest_config = field_config[:suggest] || {}
691
+ search_fields = suggest_config[:search_fields] || [display_method]
692
+
693
+ # Build search query
694
+ records = association_class.all
695
+
696
+ if search_term.present?
697
+ # Build WHERE conditions for search across multiple fields
698
+ conditions = search_fields.map do |field|
699
+ if association_class.columns_hash[field.to_s]&.type == :string
700
+ "#{field} LIKE ?"
701
+ else
702
+ "CAST(#{field} AS TEXT) LIKE ?"
703
+ end
704
+ end.join(" OR ")
705
+
706
+ search_pattern = "%#{search_term}%"
707
+ records = records.where(conditions, *([search_pattern] * search_fields.length))
708
+ end
709
+
710
+ # Apply limit and format as [label, value] pairs
711
+ records.limit(limit).map do |record|
712
+ label = if record.respond_to?(display_method)
713
+ record.public_send(display_method)
714
+ elsif record.respond_to?(:name)
715
+ record.name
716
+ elsif record.respond_to?(:title)
717
+ record.title
718
+ else
719
+ record.to_s
720
+ end
721
+ [label, record.id]
722
+ end
723
+ end
724
+
725
+ def get_has_many_suggest_options(association_name, field_config, search_term, limit)
726
+ model_class = @resource_class.model_class
727
+
728
+ # Get the associated model class
729
+ if model_class.reflect_on_association(association_name)
730
+ association_class = model_class.reflect_on_association(association_name).klass
731
+ else
732
+ association_class = association_name.to_s.singularize.classify.constantize rescue nil
733
+ end
734
+
735
+ return [] unless association_class
736
+
737
+ # Get display method and search fields from field config
738
+ display_method = field_config[:display_method] || :name
739
+ suggest_config = field_config[:suggest] || {}
740
+ search_fields = suggest_config[:search_fields] || [display_method]
741
+
742
+ Rails.logger.debug "HasMany suggest: association=#{association_name}, display_method=#{display_method}, search_fields=#{search_fields}"
743
+
744
+ # Build search query
745
+ records = association_class.all
746
+
747
+ if search_term.present?
748
+ # Build WHERE conditions for search across multiple fields
749
+ conditions = search_fields.map do |field|
750
+ if association_class.columns_hash[field.to_s]&.type == :string
751
+ "#{field} LIKE ?"
752
+ else
753
+ "CAST(#{field} AS TEXT) LIKE ?"
754
+ end
755
+ end.join(" OR ")
756
+
757
+ search_pattern = "%#{search_term}%"
758
+ records = records.where(conditions, *([search_pattern] * search_fields.length))
759
+ end
760
+
761
+ # Apply limit and format as [label, value] pairs
762
+ results = records.limit(limit).map do |record|
763
+ label = if record.respond_to?(display_method)
764
+ raw_label = record.public_send(display_method)
765
+ Rails.logger.debug "HasMany record #{record.id}: raw_label=#{raw_label.class}:#{raw_label}"
766
+ raw_label.to_s
767
+ elsif record.respond_to?(:name)
768
+ record.name.to_s
769
+ elsif record.respond_to?(:title)
770
+ record.title.to_s
771
+ else
772
+ record.to_s
773
+ end
774
+ [label, record.id]
775
+ end
776
+
777
+ Rails.logger.debug "HasMany suggest final results: #{results}"
778
+ results
779
+ end
780
+
781
+ def get_static_suggest_options(field_config, search_term, limit)
782
+ options = field_config[:options] || []
783
+
784
+ if search_term.present?
785
+ # Filter options based on search term
786
+ filtered_options = options.select do |option|
787
+ text = option.is_a?(Array) ? option[0] : option.to_s
788
+ text.downcase.include?(search_term.downcase)
789
+ end
790
+ filtered_options.take(limit)
791
+ else
792
+ options.take(limit)
793
+ end
794
+ end
795
+
796
+ def determine_timestamp_field(records)
797
+ # Get the model class from the relation or class
798
+ model_class = if records.is_a?(Class) && records < ActiveRecord::Base
799
+ # If records is already an ActiveRecord model class
800
+ records
801
+ elsif records.respond_to?(:klass)
802
+ # For ActiveRecord relations
803
+ records.klass
804
+ elsif records.respond_to?(:model)
805
+ records.model
806
+ else
807
+ records.class
808
+ end
809
+
810
+ # Try common timestamp fields in order of preference
811
+ %w[created_at updated_at published_at date timestamp].find do |field|
812
+ model_class.column_names.include?(field)
813
+ end
814
+ end
815
+
816
+ # Helper methods for lazy loading
817
+ def find_metric_config(metric_id)
818
+ find_element_in_layout(@resource_class.show_layout, :metric_card) do |element|
819
+ element.object_id.to_s == metric_id
820
+ end
821
+ end
822
+
823
+ def find_chart_config(chart_id)
824
+ find_element_in_layout(@resource_class.show_layout, :chart_card) do |element|
825
+ element.object_id.to_s == chart_id
826
+ end
827
+ end
828
+
829
+ def find_tab_config(tab_name)
830
+ tabs_container = find_element_in_layout(@resource_class.show_layout, :tabs)
831
+ tabs_container[:tabs].find { |tab| tab[:name].downcase == tab_name }
832
+ end
833
+
834
+ def find_element_in_layout(layout, element_type, &block)
835
+ layout.each do |row|
836
+ next unless row[:type] == :row
837
+
838
+ row[:columns].each do |column|
839
+ column[:elements].each do |element|
840
+ case element[:type]
841
+ when element_type
842
+ return element if block.nil? || block.call(element)
843
+ when :card
844
+ result = find_element_in_card(element, element_type, &block)
845
+ return result if result
846
+ when :tabs
847
+ result = find_element_in_tabs(element, element_type, &block)
848
+ return result if result
849
+ end
850
+ end
851
+ end
852
+ end
853
+ nil
854
+ end
855
+
856
+ def find_element_in_card(card, element_type, &block)
857
+ card[:elements].each do |element|
858
+ case element[:type]
859
+ when element_type
860
+ return element if block.nil? || block.call(element)
861
+ when :tabs
862
+ result = find_element_in_tabs(element, element_type, &block)
863
+ return result if result
864
+ end
865
+ end
866
+ nil
867
+ end
868
+
869
+ def find_element_in_tabs(tabs, element_type, &block)
870
+ tabs[:tabs].each do |tab|
871
+ tab[:elements].each do |element|
872
+ return element if element[:type] == element_type && (block.nil? || block.call(element))
873
+ end
874
+ end
875
+ nil
876
+ end
877
+
878
+ def render_tab_elements(elements)
879
+ # This would render the tab elements - for now return a simple div
880
+ # In a real implementation, this would use the ShowLayoutComponent logic
881
+ content_tag(:div, class: "tab-content-loaded") do
882
+ "Tab content loaded"
883
+ end
884
+ end
885
+
886
+ def apply_sorting(records, sort_field, sort_direction)
887
+ return records unless sort_field.present?
888
+
889
+ # Get the model class from the relation
890
+ model_class = records.respond_to?(:klass) ? records.klass : records.class
891
+
892
+ if records.respond_to?(:order) && model_class.column_names.include?(sort_field.to_s)
893
+ records.order("#{sort_field} #{sort_direction}")
894
+ else
895
+ records
896
+ end
897
+ end
898
+
899
+ def has_active_filters?
900
+ # Check if any filtering parameters are present
901
+ params[:q].present? ||
902
+ params[:search].present? ||
903
+ params[:period].present? ||
904
+ (params[:scope].present? && params[:scope] != determine_current_scope[:scope])
905
+ end
906
+ end
907
+ end