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,806 @@
1
+ module EasyAdmin
2
+ class Resource
3
+ class_attribute :model_class_name, :resource_name, :fields_config, :form_tabs_config, :show_layout_config, :scopes_config, :pagination_config, :includes_config, :batch_actions_config, :batch_actions_enabled, :row_actions_config
4
+
5
+ def self.inherited(subclass)
6
+ super
7
+ # Automatically set model class from resource name
8
+ subclass.resource_name = subclass.name.demodulize.gsub(/Resource$/, '')
9
+ subclass.model_class_name = subclass.resource_name
10
+ subclass.fields_config = []
11
+ subclass.form_tabs_config = []
12
+ subclass.show_layout_config = []
13
+ subclass.scopes_config = []
14
+ subclass.pagination_config = {
15
+ type: :standard,
16
+ per_page: 20,
17
+ max_per_page: 100
18
+ }
19
+ subclass.includes_config = {
20
+ index: [],
21
+ show: [],
22
+ form: []
23
+ }
24
+ subclass.batch_actions_config = []
25
+ subclass.batch_actions_enabled = false
26
+
27
+ # Register the resource
28
+ EasyAdmin::ResourceRegistry.register(subclass)
29
+ end
30
+
31
+ # DSL for defining fields
32
+ def self.field(name, type = :string, **options)
33
+ self.fields_config += [{
34
+ name: name,
35
+ type: type,
36
+ label: options[:label] || name.to_s.humanize,
37
+ sortable: options.fetch(:sortable, true),
38
+ searchable: options.fetch(:searchable, false),
39
+ filterable: options.fetch(:filterable, false),
40
+ required: options.fetch(:required, false),
41
+ readonly: options.fetch(:readonly, false),
42
+ format: options[:format],
43
+ options: options[:options], # for select fields
44
+ multiple: options[:multiple], # for select fields
45
+ placeholder: options[:placeholder], # for select fields
46
+ suggest: options[:suggest], # for dynamic option loading
47
+ display_method: options[:display_method], # for association fields
48
+ help_text: options[:help_text],
49
+ editable: options[:editable]
50
+ }]
51
+ end
52
+
53
+ # Convenience methods for common field types
54
+ def self.id_field(**options)
55
+ field(:id, :number, readonly: true, **options)
56
+ end
57
+
58
+ def self.text_field(name, **options)
59
+ field(name, :string, **options)
60
+ end
61
+
62
+ def self.textarea_field(name, **options)
63
+ field(name, :text, **options)
64
+ end
65
+
66
+ def self.number_field(name, **options)
67
+ field(name, :number, **options)
68
+ end
69
+
70
+ def self.email_field(name, **options)
71
+ field(name, :email, **options)
72
+ end
73
+
74
+ def self.date_field(name, **options)
75
+ field(name, :date, **options)
76
+ end
77
+
78
+ def self.datetime_field(name, **options)
79
+ field(name, :datetime, **options)
80
+ end
81
+
82
+ def self.boolean_field(name, **options)
83
+ field(name, :boolean, **options)
84
+ end
85
+
86
+ def self.select_field(name, options:, **other_options)
87
+ field(name, :select, options: options, **other_options)
88
+ end
89
+
90
+ def self.belongs_to_field(name, **options)
91
+ field(name, :belongs_to, **options)
92
+ end
93
+
94
+ def self.has_many_field(name, **options)
95
+ field(name, :has_many, **options)
96
+ end
97
+
98
+ def self.file_field(name, **options)
99
+ field(name, :file, **options)
100
+ end
101
+
102
+ def self.json_field(name, **options)
103
+ field(name, :json, **options)
104
+ end
105
+
106
+ # Scope definition
107
+ def self.scope(name, **options)
108
+ scope_config = {
109
+ name: name,
110
+ label: options[:label] || name.to_s.humanize,
111
+ scope_method: options[:scope] || name,
112
+ default: options.fetch(:default, false),
113
+ icon: options[:icon],
114
+ color: options[:color] || 'blue',
115
+ count: options.fetch(:show_count, true)
116
+ }
117
+
118
+ self.scopes_config += [scope_config]
119
+ end
120
+
121
+ # Pagination configuration
122
+ def self.pagination(type: :standard, per_page: 20, **options)
123
+ self.pagination_config = {
124
+ type: type,
125
+ per_page: per_page,
126
+ max_per_page: options[:max_per_page] || 100,
127
+ infinite_scroll: options[:infinite_scroll] || false,
128
+ countless: options[:countless] || false
129
+ }
130
+ end
131
+
132
+ # Configure eager loading for associations
133
+ def self.includes(associations, actions: [:index, :show, :form])
134
+ actions = Array(actions)
135
+
136
+ actions.each do |action|
137
+ if self.includes_config.key?(action)
138
+ self.includes_config = self.includes_config.merge(
139
+ action => Array(associations)
140
+ )
141
+ end
142
+ end
143
+ end
144
+
145
+ # Convenience methods for pagination configuration
146
+ def self.per_page(count)
147
+ pagination(per_page: count)
148
+ end
149
+
150
+ def self.infinite_scroll(**options)
151
+ pagination(type: :infinite_scroll, infinite_scroll: true, **options)
152
+ end
153
+
154
+ def self.countless_pagination(**options)
155
+ pagination(countless: true, **options)
156
+ end
157
+
158
+ # Get the associated model class
159
+ def self.model_class
160
+ model_class_name.constantize
161
+ rescue NameError
162
+ raise "Model class #{model_class_name} not found. Make sure the model exists."
163
+ end
164
+
165
+ # Get resource title/name
166
+ def self.title
167
+ resource_name.humanize.pluralize
168
+ end
169
+
170
+ def self.singular_title
171
+ resource_name.humanize
172
+ end
173
+
174
+ # Get fields for different contexts
175
+ def self.index_fields
176
+ fields_config.select { |field| ![:text, :has_many, :file].include?(field[:type]) }
177
+ end
178
+
179
+ def self.sortable_fields
180
+ fields_config.select { |field| field[:sortable] }
181
+ end
182
+
183
+ def self.filterable_fields
184
+ fields_config.select { |field| field[:filterable] }
185
+ end
186
+
187
+ def self.show_fields
188
+ fields_config
189
+ end
190
+
191
+ def self.form_fields
192
+ if form_tabs_config.any?
193
+ # If tabs are configured, return fields from all tabs
194
+ form_tabs_config.flat_map { |tab| tab[:fields] }.reject { |field| field[:readonly] }
195
+ else
196
+ # Default behavior: return all non-readonly fields
197
+ fields_config.reject { |field| field[:readonly] }
198
+ end
199
+ end
200
+
201
+ def self.form_tabs
202
+ form_tabs_config
203
+ end
204
+
205
+ def self.has_form_tabs?
206
+ form_tabs_config.any?
207
+ end
208
+
209
+ # DSL for defining form with tabs
210
+ def self.form(&block)
211
+ form_builder = FormBuilder.new(self)
212
+ form_builder.instance_eval(&block)
213
+ end
214
+
215
+ # DSL for defining show layout
216
+ def self.show(&block)
217
+ show_builder = ShowBuilder.new(self)
218
+ show_builder.instance_eval(&block)
219
+ end
220
+
221
+ def self.show_layout
222
+ show_layout_config
223
+ end
224
+
225
+ def self.has_show_layout?
226
+ show_layout_config.any?
227
+ end
228
+
229
+ def self.scopes
230
+ scopes_config
231
+ end
232
+
233
+ def self.has_scopes?
234
+ scopes_config.any?
235
+ end
236
+
237
+ def self.default_scope
238
+ scopes_config.find { |scope| scope[:default] } || scopes_config.first
239
+ end
240
+
241
+ # Pagination helper methods
242
+ def self.items_per_page
243
+ pagination_config[:per_page]
244
+ end
245
+
246
+ def self.max_items_per_page
247
+ pagination_config[:max_per_page]
248
+ end
249
+
250
+ def self.pagination_type
251
+ pagination_config[:type]
252
+ end
253
+
254
+ def self.infinite_scroll_enabled?
255
+ pagination_config[:infinite_scroll]
256
+ end
257
+
258
+ def self.countless_enabled?
259
+ pagination_config[:countless]
260
+ end
261
+
262
+ # Includes configuration helpers
263
+ def self.index_includes
264
+ includes_config[:index] || []
265
+ end
266
+
267
+ def self.show_includes
268
+ includes_config[:show] || []
269
+ end
270
+
271
+ def self.form_includes
272
+ includes_config[:form] || []
273
+ end
274
+
275
+ class FormBuilder
276
+ def initialize(resource_class)
277
+ @resource_class = resource_class
278
+ @current_tab = nil
279
+ end
280
+
281
+ def tab(name = "General", **options, &block)
282
+ tab_config = {
283
+ name: name,
284
+ label: options[:label] || name,
285
+ icon: options[:icon],
286
+ fields: []
287
+ }
288
+
289
+ @current_tab = tab_config
290
+ @resource_class.form_tabs_config << tab_config
291
+
292
+ instance_eval(&block) if block_given?
293
+ @current_tab = nil
294
+ end
295
+
296
+ # Field definition methods within tabs
297
+ def field(name, type = :string, **options)
298
+ field_config = {
299
+ name: name,
300
+ type: type,
301
+ label: options[:label] || name.to_s.humanize,
302
+ sortable: options.fetch(:sortable, true),
303
+ searchable: options.fetch(:searchable, false),
304
+ filterable: options.fetch(:filterable, false),
305
+ required: options.fetch(:required, false),
306
+ readonly: options.fetch(:readonly, false),
307
+ format: options[:format],
308
+ options: options[:options],
309
+ multiple: options[:multiple],
310
+ placeholder: options[:placeholder],
311
+ help_text: options[:help_text],
312
+ editable: options[:editable]
313
+ }
314
+
315
+ if @current_tab
316
+ @current_tab[:fields] << field_config
317
+ end
318
+
319
+ # Also add to main fields_config for compatibility
320
+ @resource_class.fields_config << field_config unless @resource_class.fields_config.any? { |f| f[:name] == name }
321
+ end
322
+
323
+ def text_field(name, **options)
324
+ field(name, :string, **options)
325
+ end
326
+
327
+ def textarea_field(name, **options)
328
+ field(name, :text, **options)
329
+ end
330
+
331
+ def number_field(name, **options)
332
+ field(name, :number, **options)
333
+ end
334
+
335
+ def email_field(name, **options)
336
+ field(name, :email, **options)
337
+ end
338
+
339
+ def date_field(name, **options)
340
+ field(name, :date, **options)
341
+ end
342
+
343
+ def datetime_field(name, **options)
344
+ field(name, :datetime, **options)
345
+ end
346
+
347
+ def boolean_field(name, **options)
348
+ field(name, :boolean, **options)
349
+ end
350
+
351
+ def select_field(name, options:, **other_options)
352
+ field(name, :select, options: options, **other_options)
353
+ end
354
+
355
+ def belongs_to_field(name, **options)
356
+ field(name, :belongs_to, **options)
357
+ end
358
+
359
+ def has_many_field(name, **options)
360
+ field(name, :has_many, **options)
361
+ end
362
+
363
+ def file_field(name, **options)
364
+ field(name, :file, **options)
365
+ end
366
+ end
367
+
368
+ class ShowBuilder
369
+ PREDEFINED_COLORS = %w[primary secondary success danger warning info light dark white transparent].freeze
370
+ COLUMN_SIZES = [1, 2, 3, 4, 6, 8, 12].freeze
371
+ SPACING_OPTIONS = %w[none small medium large].freeze
372
+ PADDING_OPTIONS = %w[none small medium large].freeze
373
+ HEADING_SIZES = %w[small medium large].freeze
374
+ DIVIDER_STYLES = %w[default dashed dotted thick].freeze
375
+ SHADOW_OPTIONS = %w[none small medium large].freeze
376
+
377
+ def initialize(resource_class)
378
+ @resource_class = resource_class
379
+ @context_stack = []
380
+ @current_row = nil
381
+ @current_column = nil
382
+ @current_card = nil
383
+ @current_tab_container = nil
384
+ @current_tab = nil
385
+ end
386
+
387
+ # Layout methods
388
+ def row(columns: 1, spacing: "medium", &block)
389
+ validate_spacing!(spacing)
390
+
391
+ row_config = {
392
+ type: :row,
393
+ columns_count: columns,
394
+ spacing: spacing,
395
+ columns: []
396
+ }
397
+
398
+ # Save current context
399
+ push_context
400
+ @current_row = row_config
401
+
402
+ # Add to parent context
403
+ if @current_column
404
+ @current_column[:elements] << row_config
405
+ else
406
+ @resource_class.show_layout_config << row_config
407
+ end
408
+
409
+ instance_eval(&block) if block_given?
410
+
411
+ # Restore context
412
+ pop_context
413
+ end
414
+
415
+ def column(size: nil, &block)
416
+ raise "column must be called within a row block" unless @current_row
417
+
418
+ # Auto-calculate size if not provided
419
+ calculated_size = size || (12 / [@current_row[:columns_count], 1].max)
420
+ validate_column_size!(calculated_size)
421
+
422
+ column_config = {
423
+ type: :column,
424
+ size: calculated_size,
425
+ elements: []
426
+ }
427
+
428
+ # Save current context
429
+ push_context
430
+ @current_column = column_config
431
+ @current_row[:columns] << column_config
432
+
433
+ instance_eval(&block) if block_given?
434
+
435
+ # Restore context
436
+ pop_context
437
+ end
438
+
439
+ # Card methods
440
+ def card(title: nil, color: "light", padding: "medium", shadow: "small", &block)
441
+ raise "card must be called within a column block" unless @current_column
442
+ validate_color!(color)
443
+ validate_padding!(padding)
444
+ validate_shadow!(shadow)
445
+
446
+ card_config = {
447
+ type: :card,
448
+ title: title,
449
+ color: color,
450
+ padding: padding,
451
+ shadow: shadow,
452
+ elements: []
453
+ }
454
+
455
+ # Save current context
456
+ push_context
457
+ @current_card = card_config
458
+ @current_column[:elements] << card_config
459
+
460
+ instance_eval(&block) if block_given?
461
+
462
+ # Restore context
463
+ pop_context
464
+ end
465
+
466
+ def metric_card(title:, value:, color: "primary", icon: nil, trend: nil)
467
+ raise "metric_card must be called within a column block" unless @current_column
468
+ validate_color!(color)
469
+
470
+ metric_config = {
471
+ type: :metric_card,
472
+ title: title,
473
+ value: value,
474
+ icon: icon,
475
+ color: color,
476
+ trend: trend
477
+ }
478
+
479
+ @current_column[:elements] << metric_config
480
+ end
481
+
482
+ def chart_card(title:, chart_type:, **options, &block)
483
+ raise "chart_card must be called within a column block" unless @current_column
484
+
485
+ chart_config = {
486
+ type: :chart_card,
487
+ title: title,
488
+ chart_type: chart_type, # :line, :bar, :pie, :donut, :area
489
+ height: options[:height] || 350,
490
+ data_source: options[:data_source],
491
+ css_classes: options[:class] || "card",
492
+ config: {}
493
+ }
494
+
495
+ if block_given?
496
+ chart_builder = ChartBuilder.new
497
+ chart_builder.instance_eval(&block)
498
+ chart_config[:config] = chart_builder.config
499
+ end
500
+
501
+ @current_column[:elements] << chart_config
502
+ end
503
+
504
+ # Tab methods
505
+ def tabs(**options, &block)
506
+ container = @current_card || @current_column
507
+ raise "tabs must be called within a card or column block" unless container
508
+
509
+ tabs_config = {
510
+ type: :tabs,
511
+ css_classes: options[:class] || "tabs-menu",
512
+ tabs: []
513
+ }
514
+
515
+ # Save current context
516
+ push_context
517
+ @current_tab_container = tabs_config
518
+ container[:elements] << tabs_config
519
+
520
+ instance_eval(&block) if block_given?
521
+
522
+ # Restore context
523
+ pop_context
524
+ end
525
+
526
+ def tab(name, **options, &block)
527
+ raise "tab must be called within a tabs block" unless @current_tab_container
528
+
529
+ tab_config = {
530
+ name: name,
531
+ label: options[:label] || name,
532
+ icon: options[:icon],
533
+ active: options[:active] || false,
534
+ elements: []
535
+ }
536
+
537
+ # Save current context
538
+ push_context
539
+ @current_tab = tab_config
540
+ @current_tab_container[:tabs] << tab_config
541
+
542
+ instance_eval(&block) if block_given?
543
+
544
+ # Restore context
545
+ pop_context
546
+ end
547
+
548
+ # Field display methods
549
+ def field(name, **options)
550
+ container = @current_tab || @current_card || @current_column
551
+ raise "field must be called within a tab, card, or column block" unless container
552
+
553
+ # Allow hiding labels with label: false
554
+ label_value = if options.key?(:label) && options[:label] == false
555
+ nil
556
+ else
557
+ options[:label] || name.to_s.humanize
558
+ end
559
+
560
+ field_config = {
561
+ type: :field,
562
+ name: name,
563
+ label: label_value,
564
+ field_type: options[:field_type] || :default,
565
+ format: options[:format],
566
+ css_classes: options[:class],
567
+ show_label: options.fetch(:show_label, true)
568
+ }
569
+
570
+ container[:elements] << field_config
571
+ end
572
+
573
+ def fields(*field_names, **options)
574
+ field_names.each do |name|
575
+ field(name, **options)
576
+ end
577
+ end
578
+
579
+ # Content methods
580
+ def content(html = nil, **options, &block)
581
+ container = @current_tab || @current_card || @current_column
582
+ raise "content must be called within a tab, card, or column block" unless container
583
+
584
+ content_config = {
585
+ type: :content,
586
+ html: html,
587
+ css_classes: options[:class],
588
+ block: block
589
+ }
590
+
591
+ container[:elements] << content_config
592
+ end
593
+
594
+ def heading(text, level: 2, size: "medium")
595
+ container = @current_tab || @current_card || @current_column
596
+ raise "heading must be called within a tab, card, or column block" unless container
597
+
598
+ validate_heading_level!(level)
599
+ validate_heading_size!(size)
600
+
601
+ heading_config = {
602
+ type: :heading,
603
+ text: text,
604
+ level: level,
605
+ size: size
606
+ }
607
+
608
+ container[:elements] << heading_config
609
+ end
610
+
611
+ def divider(style: "default")
612
+ container = @current_tab || @current_card || @current_column
613
+ raise "divider must be called within a tab, card, or column block" unless container
614
+
615
+ validate_divider_style!(style)
616
+
617
+ divider_config = {
618
+ type: :divider,
619
+ style: style
620
+ }
621
+
622
+ container[:elements] << divider_config
623
+ end
624
+
625
+ private
626
+
627
+ def validate_color!(color)
628
+ return if PREDEFINED_COLORS.include?(color.to_s)
629
+ raise ArgumentError, "Invalid color '#{color}'. Available colors: #{PREDEFINED_COLORS.join(', ')}"
630
+ end
631
+
632
+ def validate_column_size!(size)
633
+ return if COLUMN_SIZES.include?(size)
634
+ raise ArgumentError, "Invalid column size '#{size}'. Available sizes: #{COLUMN_SIZES.join(', ')}"
635
+ end
636
+
637
+ def validate_spacing!(spacing)
638
+ return if SPACING_OPTIONS.include?(spacing.to_s)
639
+ raise ArgumentError, "Invalid spacing '#{spacing}'. Available options: #{SPACING_OPTIONS.join(', ')}"
640
+ end
641
+
642
+ def validate_padding!(padding)
643
+ return if PADDING_OPTIONS.include?(padding.to_s)
644
+ raise ArgumentError, "Invalid padding '#{padding}'. Available options: #{PADDING_OPTIONS.join(', ')}"
645
+ end
646
+
647
+ def validate_shadow!(shadow)
648
+ return if SHADOW_OPTIONS.include?(shadow.to_s)
649
+ raise ArgumentError, "Invalid shadow '#{shadow}'. Available options: #{SHADOW_OPTIONS.join(', ')}"
650
+ end
651
+
652
+ def validate_heading_level!(level)
653
+ return if (1..6).include?(level)
654
+ raise ArgumentError, "Invalid heading level '#{level}'. Must be between 1 and 6"
655
+ end
656
+
657
+ def validate_heading_size!(size)
658
+ return if HEADING_SIZES.include?(size.to_s)
659
+ raise ArgumentError, "Invalid heading size '#{size}'. Available sizes: #{HEADING_SIZES.join(', ')}"
660
+ end
661
+
662
+ def validate_divider_style!(style)
663
+ return if DIVIDER_STYLES.include?(style.to_s)
664
+ raise ArgumentError, "Invalid divider style '#{style}'. Available styles: #{DIVIDER_STYLES.join(', ')}"
665
+ end
666
+
667
+ def push_context
668
+ @context_stack.push({
669
+ row: @current_row,
670
+ column: @current_column,
671
+ card: @current_card,
672
+ tab_container: @current_tab_container,
673
+ tab: @current_tab
674
+ })
675
+ end
676
+
677
+ def pop_context
678
+ if @context_stack.any?
679
+ context = @context_stack.pop
680
+ @current_row = context[:row]
681
+ @current_column = context[:column]
682
+ @current_card = context[:card]
683
+ @current_tab_container = context[:tab_container]
684
+ @current_tab = context[:tab]
685
+ else
686
+ @current_row = nil
687
+ @current_column = nil
688
+ @current_card = nil
689
+ @current_tab_container = nil
690
+ @current_tab = nil
691
+ end
692
+ end
693
+ end
694
+
695
+ class ChartBuilder
696
+ attr_reader :config
697
+
698
+ def initialize
699
+ @config = {}
700
+ end
701
+
702
+ def series(data)
703
+ @config[:series] = data
704
+ end
705
+
706
+ def categories(data)
707
+ @config[:categories] = data
708
+ end
709
+
710
+ def colors(data)
711
+ @config[:colors] = data
712
+ end
713
+
714
+ def title(text)
715
+ @config[:title] = text
716
+ end
717
+
718
+ def subtitle(text)
719
+ @config[:subtitle] = text
720
+ end
721
+ end
722
+
723
+ # Query scopes
724
+ def self.all_records(sort_field: nil, sort_direction: 'asc')
725
+ records = model_class.all
726
+
727
+ if sort_field.present? && sortable_fields.any? { |f| f[:name].to_s == sort_field.to_s }
728
+ records = records.order("#{sort_field} #{sort_direction}")
729
+ end
730
+
731
+ records
732
+ end
733
+
734
+ def self.find_record(id)
735
+ model_class.find(id)
736
+ end
737
+
738
+ def self.search_records(query, sort_field: nil, sort_direction: 'asc', records: nil)
739
+ records ||= model_class.all
740
+ searchable_fields = fields_config.select { |field| field[:searchable] }
741
+
742
+ if searchable_fields.any? && query.present?
743
+ conditions = searchable_fields.map do |field|
744
+ "#{field[:name]} ILIKE ?"
745
+ end.join(' OR ')
746
+
747
+ values = Array.new(searchable_fields.length, "%#{query}%")
748
+ records = records.where(conditions, *values)
749
+ end
750
+
751
+ if sort_field.present? && sortable_fields.any? { |f| f[:name].to_s == sort_field.to_s }
752
+ records = records.order("#{sort_field} #{sort_direction}")
753
+ end
754
+
755
+ records
756
+ end
757
+
758
+ # Batch Actions DSL
759
+ def self.enable_batch_actions
760
+ self.batch_actions_enabled = true
761
+ end
762
+
763
+ def self.batch_action(action_class, options = {})
764
+ self.batch_actions_config = batch_actions_config + [{
765
+ class: action_class,
766
+ condition: options[:if]
767
+ }]
768
+ end
769
+
770
+ def self.available_batch_actions(context = {})
771
+ return [] unless batch_actions_enabled
772
+
773
+ batch_actions_config.select do |config|
774
+ condition = config[:condition]
775
+ condition.nil? || (condition.respond_to?(:call) ? context.instance_eval(&condition) : condition)
776
+ end.map { |config| config[:class] }
777
+ end
778
+
779
+ # Row Actions DSL (single record actions)
780
+ def self.row_action(action_class, options = {})
781
+ self.row_actions_config ||= []
782
+
783
+ self.row_actions_config = row_actions_config + [{
784
+ class: action_class,
785
+ condition: options[:if]
786
+ }]
787
+ end
788
+
789
+ def self.row_actions
790
+ row_actions_config || []
791
+ end
792
+
793
+ def self.has_row_actions?
794
+ row_actions_config.present?
795
+ end
796
+
797
+ # Route helpers
798
+ def self.route_key
799
+ resource_name.underscore.pluralize
800
+ end
801
+
802
+ def self.param_key
803
+ resource_name.underscore
804
+ end
805
+ end
806
+ end