admin_suite 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 (128) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/Gemfile +13 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +7 -0
  6. data/Rakefile +11 -0
  7. data/app/assets/admin_suite.css +444 -0
  8. data/app/assets/admin_suite_tailwind.css +8 -0
  9. data/app/assets/builds/admin_suite_tailwind.css +8 -0
  10. data/app/assets/rouge.css +218 -0
  11. data/app/assets/tailwind/admin_suite.css +22 -0
  12. data/app/controllers/admin_suite/application_controller.rb +118 -0
  13. data/app/controllers/admin_suite/dashboard_controller.rb +258 -0
  14. data/app/controllers/admin_suite/docs_controller.rb +155 -0
  15. data/app/controllers/admin_suite/portals_controller.rb +22 -0
  16. data/app/controllers/admin_suite/resources_controller.rb +238 -0
  17. data/app/helpers/admin_suite/base_helper.rb +1199 -0
  18. data/app/helpers/admin_suite/icon_helper.rb +61 -0
  19. data/app/helpers/admin_suite/panels_helper.rb +52 -0
  20. data/app/helpers/admin_suite/resources_helper.rb +15 -0
  21. data/app/helpers/admin_suite/theme_helper.rb +99 -0
  22. data/app/javascript/controllers/admin_suite/click_actions_controller.js +73 -0
  23. data/app/javascript/controllers/admin_suite/clipboard_controller.js +57 -0
  24. data/app/javascript/controllers/admin_suite/code_editor_controller.js +45 -0
  25. data/app/javascript/controllers/admin_suite/file_upload_controller.js +238 -0
  26. data/app/javascript/controllers/admin_suite/json_editor_controller.js +62 -0
  27. data/app/javascript/controllers/admin_suite/live_filter_controller.js +71 -0
  28. data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +67 -0
  29. data/app/javascript/controllers/admin_suite/searchable_select_controller.js +171 -0
  30. data/app/javascript/controllers/admin_suite/sidebar_controller.js +33 -0
  31. data/app/javascript/controllers/admin_suite/tag_select_controller.js +193 -0
  32. data/app/javascript/controllers/admin_suite/toggle_switch_controller.js +66 -0
  33. data/app/views/admin_suite/dashboard/index.html.erb +21 -0
  34. data/app/views/admin_suite/docs/index.html.erb +86 -0
  35. data/app/views/admin_suite/panels/_cards.html.erb +107 -0
  36. data/app/views/admin_suite/panels/_chart.html.erb +47 -0
  37. data/app/views/admin_suite/panels/_health.html.erb +44 -0
  38. data/app/views/admin_suite/panels/_recent.html.erb +56 -0
  39. data/app/views/admin_suite/panels/_stat.html.erb +64 -0
  40. data/app/views/admin_suite/panels/_table.html.erb +36 -0
  41. data/app/views/admin_suite/portals/show.html.erb +75 -0
  42. data/app/views/admin_suite/resources/_form.html.erb +32 -0
  43. data/app/views/admin_suite/resources/edit.html.erb +24 -0
  44. data/app/views/admin_suite/resources/index.html.erb +315 -0
  45. data/app/views/admin_suite/resources/new.html.erb +22 -0
  46. data/app/views/admin_suite/resources/show.html.erb +184 -0
  47. data/app/views/admin_suite/shared/_flash.html.erb +30 -0
  48. data/app/views/admin_suite/shared/_form.html.erb +60 -0
  49. data/app/views/admin_suite/shared/_json_editor_field.html.erb +52 -0
  50. data/app/views/admin_suite/shared/_sidebar.html.erb +94 -0
  51. data/app/views/admin_suite/shared/_toggle_cell.html.erb +34 -0
  52. data/app/views/admin_suite/shared/_topbar.html.erb +47 -0
  53. data/app/views/layouts/admin_suite/application.html.erb +79 -0
  54. data/lib/admin/base/action_executor.rb +155 -0
  55. data/lib/admin/base/action_handler.rb +31 -0
  56. data/lib/admin/base/filter_builder.rb +121 -0
  57. data/lib/admin/base/resource.rb +541 -0
  58. data/lib/admin_suite/configuration.rb +42 -0
  59. data/lib/admin_suite/engine.rb +101 -0
  60. data/lib/admin_suite/markdown_renderer.rb +115 -0
  61. data/lib/admin_suite/portal_definition.rb +64 -0
  62. data/lib/admin_suite/portal_registry.rb +32 -0
  63. data/lib/admin_suite/theme_palette.rb +36 -0
  64. data/lib/admin_suite/ui/dashboard_definition.rb +69 -0
  65. data/lib/admin_suite/ui/field_renderer_registry.rb +119 -0
  66. data/lib/admin_suite/ui/form_field_renderer.rb +48 -0
  67. data/lib/admin_suite/ui/show_formatter_registry.rb +120 -0
  68. data/lib/admin_suite/ui/show_value_formatter.rb +70 -0
  69. data/lib/admin_suite/version.rb +10 -0
  70. data/lib/admin_suite.rb +54 -0
  71. data/lib/generators/admin_suite/install/install_generator.rb +23 -0
  72. data/lib/generators/admin_suite/install/templates/admin_suite.rb +60 -0
  73. data/lib/generators/admin_suite/resource/resource_generator.rb +83 -0
  74. data/lib/generators/admin_suite/resource/templates/resource.rb.tt +47 -0
  75. data/lib/generators/admin_suite/scaffold/scaffold_generator.rb +28 -0
  76. data/lib/tasks/admin_suite_tailwind.rake +28 -0
  77. data/lib/tasks/admin_suite_test.rake +11 -0
  78. data/test/dummy/Gemfile +21 -0
  79. data/test/dummy/README.md +24 -0
  80. data/test/dummy/Rakefile +6 -0
  81. data/test/dummy/app/assets/stylesheets/application.css +10 -0
  82. data/test/dummy/app/controllers/application_controller.rb +4 -0
  83. data/test/dummy/app/helpers/application_helper.rb +2 -0
  84. data/test/dummy/app/models/application_record.rb +2 -0
  85. data/test/dummy/app/views/layouts/application.html.erb +28 -0
  86. data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
  87. data/test/dummy/app/views/pwa/service-worker.js +26 -0
  88. data/test/dummy/bin/ci +6 -0
  89. data/test/dummy/bin/dev +2 -0
  90. data/test/dummy/bin/rails +4 -0
  91. data/test/dummy/bin/rake +4 -0
  92. data/test/dummy/bin/setup +35 -0
  93. data/test/dummy/config/application.rb +43 -0
  94. data/test/dummy/config/boot.rb +3 -0
  95. data/test/dummy/config/ci.rb +19 -0
  96. data/test/dummy/config/database.yml +31 -0
  97. data/test/dummy/config/environment.rb +5 -0
  98. data/test/dummy/config/environments/development.rb +57 -0
  99. data/test/dummy/config/environments/production.rb +67 -0
  100. data/test/dummy/config/environments/test.rb +42 -0
  101. data/test/dummy/config/initializers/assets.rb +7 -0
  102. data/test/dummy/config/initializers/content_security_policy.rb +29 -0
  103. data/test/dummy/config/initializers/filter_parameter_logging.rb +8 -0
  104. data/test/dummy/config/initializers/inflections.rb +16 -0
  105. data/test/dummy/config/locales/en.yml +31 -0
  106. data/test/dummy/config/puma.rb +39 -0
  107. data/test/dummy/config/routes.rb +16 -0
  108. data/test/dummy/config.ru +6 -0
  109. data/test/dummy/db/seeds.rb +9 -0
  110. data/test/dummy/log/test.log +441 -0
  111. data/test/dummy/public/400.html +135 -0
  112. data/test/dummy/public/404.html +135 -0
  113. data/test/dummy/public/406-unsupported-browser.html +135 -0
  114. data/test/dummy/public/422.html +135 -0
  115. data/test/dummy/public/500.html +135 -0
  116. data/test/dummy/public/icon.png +0 -0
  117. data/test/dummy/public/icon.svg +3 -0
  118. data/test/dummy/public/robots.txt +1 -0
  119. data/test/dummy/test/test_helper.rb +15 -0
  120. data/test/dummy/tmp/local_secret.txt +1 -0
  121. data/test/fixtures/docs/progress/PROGRESS_REPORT.md +6 -0
  122. data/test/integration/dashboard_test.rb +13 -0
  123. data/test/integration/docs_test.rb +46 -0
  124. data/test/integration/theme_test.rb +27 -0
  125. data/test/lib/markdown_renderer_test.rb +20 -0
  126. data/test/lib/theme_palette_test.rb +24 -0
  127. data/test/test_helper.rb +11 -0
  128. metadata +264 -0
@@ -0,0 +1,541 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ module Base
5
+ # Base class for admin resource definitions
6
+ #
7
+ # Provides a declarative DSL for defining admin resources with:
8
+ # - Index configuration (columns, filters, search, sort, stats)
9
+ # - Form configuration (fields with various types)
10
+ # - Actions (single and bulk)
11
+ # - Show page sections
12
+ # - Export capabilities
13
+ #
14
+ # @example
15
+ # class Admin::Resources::CompanyResource < Admin::Base::Resource
16
+ # model Company
17
+ # portal :ops
18
+ # section :content
19
+ #
20
+ # index do
21
+ # searchable :name, :website
22
+ # sortable :name, :created_at, default: :name
23
+ #
24
+ # columns do
25
+ # column :name
26
+ # column :job_listings, -> (c) { c.job_listings.count }
27
+ # end
28
+ #
29
+ # filters do
30
+ # filter :search, type: :text
31
+ # filter :status, type: :select, options: %w[active inactive]
32
+ # end
33
+ # end
34
+ #
35
+ # form do
36
+ # field :name, required: true
37
+ # field :website, type: :url
38
+ # field :is_active, type: :toggle
39
+ # end
40
+ # end
41
+ class Resource
42
+ class << self
43
+ # Model configuration
44
+ attr_reader :model_class, :portal_name, :section_name, :nav_label, :nav_icon, :nav_order
45
+
46
+ # Index configuration
47
+ attr_reader :index_config
48
+
49
+ # Form configuration
50
+ attr_reader :form_config
51
+
52
+ # Show configuration
53
+ attr_reader :show_config
54
+
55
+ # Actions configuration
56
+ attr_reader :actions_config
57
+
58
+ # Export configuration
59
+ attr_reader :export_formats
60
+
61
+ # Sets the model class for this resource
62
+ #
63
+ # @param klass [Class] The ActiveRecord model class
64
+ # @return [void]
65
+ def model(klass)
66
+ @model_class = klass
67
+ end
68
+
69
+ # Sets the portal this resource belongs to
70
+ #
71
+ # @param name [Symbol] Portal name (:ops or :ai)
72
+ # @return [void]
73
+ def portal(name)
74
+ @portal_name = name
75
+ end
76
+
77
+ # Sets the section within the portal
78
+ #
79
+ # @param name [Symbol] Section name
80
+ # @return [void]
81
+ def section(name)
82
+ @section_name = name
83
+ end
84
+
85
+ # Navigation metadata for this resource.
86
+ #
87
+ # @param label [String, nil] override label used in nav
88
+ # @param icon [String, Symbol, nil] lucide icon name (or raw svg string)
89
+ # @param order [Integer, nil] sort order within section
90
+ # @return [void]
91
+ def nav(label: nil, icon: nil, order: nil)
92
+ @nav_label = label if label.present?
93
+ @nav_icon = icon if icon.present?
94
+ @nav_order = order unless order.nil?
95
+ end
96
+
97
+ # Convenience setter/getter for nav icon.
98
+ #
99
+ # @param name [String, Symbol, nil]
100
+ # @return [String, Symbol, nil]
101
+ def icon(name = nil)
102
+ @nav_icon = name if name.present?
103
+ @nav_icon
104
+ end
105
+
106
+ # Convenience setter/getter for nav label.
107
+ #
108
+ # @param name [String, nil]
109
+ # @return [String, nil]
110
+ def label(name = nil)
111
+ @nav_label = name if name.present?
112
+ @nav_label
113
+ end
114
+
115
+ # Convenience setter/getter for nav order.
116
+ #
117
+ # @param value [Integer, nil]
118
+ # @return [Integer, nil]
119
+ def order(value = nil)
120
+ @nav_order = value unless value.nil?
121
+ @nav_order
122
+ end
123
+
124
+ # Configures the index view
125
+ #
126
+ # @yield Block for index configuration
127
+ # @return [void]
128
+ def index(&block)
129
+ @index_config = IndexConfig.new
130
+ @index_config.instance_eval(&block) if block_given?
131
+ end
132
+
133
+ # Configures the form (new/edit)
134
+ #
135
+ # @yield Block for form configuration
136
+ # @return [void]
137
+ def form(&block)
138
+ @form_config = FormConfig.new
139
+ @form_config.instance_eval(&block) if block_given?
140
+ end
141
+
142
+ # Configures the show view
143
+ #
144
+ # @yield Block for show configuration
145
+ # @return [void]
146
+ def show(&block)
147
+ @show_config = ShowConfig.new
148
+ @show_config.instance_eval(&block) if block_given?
149
+ end
150
+
151
+ # Configures actions
152
+ #
153
+ # @yield Block for actions configuration
154
+ # @return [void]
155
+ def actions(&block)
156
+ @actions_config = ActionsConfig.new
157
+ @actions_config.instance_eval(&block) if block_given?
158
+ end
159
+
160
+ # Configures export formats
161
+ #
162
+ # @param formats [Array<Symbol>] Export formats (:json, :csv)
163
+ # @return [void]
164
+ def exportable(*formats)
165
+ @export_formats = formats
166
+ end
167
+
168
+ # Returns the resource name derived from class name
169
+ #
170
+ # @return [String]
171
+ def resource_name
172
+ name.demodulize.sub(/Resource$/, "").underscore
173
+ end
174
+
175
+ # Returns the plural resource name
176
+ #
177
+ # @return [String]
178
+ def resource_name_plural
179
+ resource_name.pluralize
180
+ end
181
+
182
+ # Returns the human-readable name
183
+ #
184
+ # @return [String]
185
+ def human_name
186
+ model_class&.model_name&.human || resource_name.humanize
187
+ end
188
+
189
+ # Returns the human-readable plural name
190
+ #
191
+ # @return [String]
192
+ def human_name_plural
193
+ model_class&.model_name&.human(count: 2) || resource_name.pluralize.humanize
194
+ end
195
+
196
+ # Returns all registered resources
197
+ #
198
+ # @return [Array<Class>]
199
+ def registered_resources
200
+ @registered_resources ||= []
201
+ end
202
+
203
+ # Clears the registry (useful for development reloads).
204
+ #
205
+ # @return [void]
206
+ def reset_registry!
207
+ @registered_resources = []
208
+ end
209
+
210
+ # Called when a subclass is created
211
+ def inherited(subclass)
212
+ super
213
+ return if subclass.name&.include?("Base")
214
+
215
+ existing_idx = registered_resources.index { |r| r.name == subclass.name }
216
+ if existing_idx
217
+ registered_resources[existing_idx] = subclass
218
+ else
219
+ registered_resources << subclass
220
+ end
221
+ end
222
+
223
+ # Returns resources for a specific portal
224
+ #
225
+ # @param portal [Symbol] Portal name
226
+ # @return [Array<Class>]
227
+ def resources_for_portal(portal)
228
+ registered_resources.select { |r| r.portal_name == portal }
229
+ end
230
+
231
+ # Returns resources for a specific section
232
+ #
233
+ # @param portal [Symbol] Portal name
234
+ # @param section [Symbol] Section name
235
+ # @return [Array<Class>]
236
+ def resources_for_section(portal, section)
237
+ registered_resources.select { |r| r.portal_name == portal && r.section_name == section }
238
+ end
239
+ end
240
+
241
+ # Index view configuration
242
+ class IndexConfig
243
+ attr_reader :searchable_fields, :sortable_fields, :default_sort, :default_sort_direction,
244
+ :columns_list, :filters_list, :stats_list, :per_page
245
+
246
+ def initialize
247
+ @searchable_fields = []
248
+ @sortable_fields = []
249
+ @default_sort = nil
250
+ @default_sort_direction = :desc
251
+ @columns_list = []
252
+ @filters_list = []
253
+ @stats_list = []
254
+ @per_page = 25
255
+ end
256
+
257
+ def searchable(*fields)
258
+ @searchable_fields = fields
259
+ end
260
+
261
+ def sortable(*fields, default: nil, direction: :desc)
262
+ @sortable_fields = fields if fields.any?
263
+ @default_sort = default || fields.first
264
+ @default_sort_direction = direction
265
+ end
266
+
267
+ def paginate(count)
268
+ @per_page = count
269
+ end
270
+
271
+ def columns(&block)
272
+ builder = ColumnsBuilder.new
273
+ builder.instance_eval(&block) if block_given?
274
+ @columns_list = builder.columns
275
+ end
276
+
277
+ def filters(&block)
278
+ builder = FiltersBuilder.new
279
+ builder.instance_eval(&block) if block_given?
280
+ @filters_list = builder.filters
281
+ end
282
+
283
+ def stats(&block)
284
+ builder = StatsBuilder.new
285
+ builder.instance_eval(&block) if block_given?
286
+ @stats_list = builder.stats
287
+ end
288
+ end
289
+
290
+ class ColumnsBuilder
291
+ attr_reader :columns
292
+
293
+ def initialize
294
+ @columns = []
295
+ end
296
+
297
+ def column(name, content = nil, **options)
298
+ @columns << ColumnDefinition.new(
299
+ name: name,
300
+ content: content,
301
+ render: options[:render],
302
+ header: options[:header] || name.to_s.humanize,
303
+ css_class: options[:class],
304
+ type: options[:type],
305
+ toggle_field: options[:toggle_field],
306
+ label_color: options[:label_color],
307
+ label_size: options[:label_size],
308
+ sortable: options[:sortable] || false
309
+ )
310
+ end
311
+ end
312
+
313
+ ColumnDefinition = Struct.new(:name, :content, :render, :header, :css_class, :type, :toggle_field, :label_color, :label_size, :sortable, keyword_init: true)
314
+
315
+ class FiltersBuilder
316
+ attr_reader :filters
317
+
318
+ def initialize
319
+ @filters = []
320
+ end
321
+
322
+ def filter(name, **options)
323
+ select_options = options.key?(:options) ? options[:options] : options[:collection]
324
+ @filters << FilterDefinition.new(
325
+ name: name,
326
+ type: options[:type] || :text,
327
+ label: options[:label] || name.to_s.humanize,
328
+ placeholder: options[:placeholder],
329
+ options: select_options,
330
+ field: options[:field] || name,
331
+ apply: options[:apply]
332
+ )
333
+ end
334
+ end
335
+
336
+ FilterDefinition = Struct.new(:name, :type, :label, :placeholder, :options, :field, :apply, keyword_init: true)
337
+
338
+ class StatsBuilder
339
+ attr_reader :stats
340
+
341
+ def initialize
342
+ @stats = []
343
+ end
344
+
345
+ def stat(name, calculator, **options)
346
+ @stats << StatDefinition.new(
347
+ name: name,
348
+ calculator: calculator,
349
+ color: options[:color]
350
+ )
351
+ end
352
+ end
353
+
354
+ StatDefinition = Struct.new(:name, :calculator, :color, keyword_init: true)
355
+
356
+ class FormConfig
357
+ attr_reader :fields_list
358
+
359
+ def initialize
360
+ @fields_list = []
361
+ end
362
+
363
+ def field(name, **options)
364
+ @fields_list << FieldDefinition.new(
365
+ name: name,
366
+ type: options[:type] || :text,
367
+ required: options[:required] || false,
368
+ label: options[:label] || name.to_s.humanize,
369
+ help: options[:help],
370
+ placeholder: options[:placeholder],
371
+ collection: options[:collection],
372
+ create_url: options[:create_url],
373
+ accept: options[:accept],
374
+ rows: options[:rows],
375
+ readonly: options[:readonly] || false,
376
+ if_condition: options[:if],
377
+ unless_condition: options[:unless],
378
+ multiple: options[:multiple] || false,
379
+ creatable: options[:creatable] || false,
380
+ preview: options[:preview] != false,
381
+ variants: options[:variants],
382
+ label_color: options[:label_color],
383
+ label_size: options[:label_size]
384
+ )
385
+ end
386
+
387
+ def section(title, **options, &block)
388
+ @fields_list << SectionDefinition.new(
389
+ title: title,
390
+ description: options[:description],
391
+ collapsible: options[:collapsible] || false,
392
+ collapsed: options[:collapsed] || false
393
+ )
394
+ instance_eval(&block) if block_given?
395
+ @fields_list << SectionEnd.new
396
+ end
397
+
398
+ def row(**options, &block)
399
+ @fields_list << RowDefinition.new(cols: options[:cols] || 2)
400
+ instance_eval(&block) if block_given?
401
+ @fields_list << RowEnd.new
402
+ end
403
+ end
404
+
405
+ FieldDefinition = Struct.new(
406
+ :name, :type, :required, :label, :help, :placeholder,
407
+ :collection, :create_url, :accept, :rows, :readonly,
408
+ :if_condition, :unless_condition, :multiple, :creatable,
409
+ :preview, :variants, :label_color, :label_size,
410
+ keyword_init: true
411
+ )
412
+
413
+ SectionDefinition = Struct.new(:title, :description, :collapsible, :collapsed, keyword_init: true)
414
+ SectionEnd = Class.new
415
+
416
+ RowDefinition = Struct.new(:cols, keyword_init: true)
417
+ RowEnd = Class.new
418
+
419
+ class ShowConfig
420
+ attr_reader :sidebar_sections, :main_sections
421
+
422
+ def initialize
423
+ @sidebar_sections = []
424
+ @main_sections = []
425
+ end
426
+
427
+ def section(name, **options)
428
+ @main_sections << build_section(name, options)
429
+ end
430
+
431
+ def sidebar(&block)
432
+ @current_target = :sidebar
433
+ instance_eval(&block) if block_given?
434
+ @current_target = nil
435
+ end
436
+
437
+ def main(&block)
438
+ @current_target = :main
439
+ instance_eval(&block) if block_given?
440
+ @current_target = nil
441
+ end
442
+
443
+ def panel(name, **options)
444
+ section_def = build_section(name, options)
445
+
446
+ case @current_target
447
+ when :sidebar
448
+ @sidebar_sections << section_def
449
+ else
450
+ @main_sections << section_def
451
+ end
452
+ end
453
+
454
+ def sections_list
455
+ @main_sections
456
+ end
457
+
458
+ private
459
+
460
+ def build_section(name, options)
461
+ ShowSectionDefinition.new(
462
+ name: name,
463
+ fields: options[:fields] || [],
464
+ association: options[:association],
465
+ limit: options[:limit],
466
+ render: options[:render],
467
+ title: options[:title] || name.to_s.humanize,
468
+ display: options[:display] || :list,
469
+ columns: options[:columns] || [],
470
+ link_to: options[:link_to],
471
+ resource: options[:resource],
472
+ paginate: options[:paginate] || options[:pagination] || false,
473
+ per_page: options[:per_page],
474
+ collapsible: options[:collapsible] || false,
475
+ collapsed: options[:collapsed] || false
476
+ )
477
+ end
478
+ end
479
+
480
+ ShowSectionDefinition = Struct.new(
481
+ :name, :fields, :association, :limit, :render, :title,
482
+ :display, :columns, :link_to, :resource, :paginate, :per_page, :collapsible, :collapsed,
483
+ keyword_init: true
484
+ )
485
+
486
+ class ActionsConfig
487
+ attr_reader :member_actions, :collection_actions, :bulk_actions
488
+
489
+ def initialize
490
+ @member_actions = []
491
+ @collection_actions = []
492
+ @bulk_actions = []
493
+ end
494
+
495
+ def action(name, **options)
496
+ @member_actions << ActionDefinition.new(
497
+ name: name,
498
+ method: options[:method] || :post,
499
+ confirm: options[:confirm],
500
+ type: options[:type] || :button,
501
+ label: options[:label] || name.to_s.humanize,
502
+ icon: options[:icon],
503
+ color: options[:color],
504
+ if_condition: options[:if],
505
+ unless_condition: options[:unless]
506
+ )
507
+ end
508
+
509
+ def collection_action(name, **options)
510
+ @collection_actions << ActionDefinition.new(
511
+ name: name,
512
+ method: options[:method] || :post,
513
+ confirm: options[:confirm],
514
+ type: options[:type] || :button,
515
+ label: options[:label] || name.to_s.humanize,
516
+ icon: options[:icon],
517
+ color: options[:color]
518
+ )
519
+ end
520
+
521
+ def bulk_action(name, **options)
522
+ @bulk_actions << ActionDefinition.new(
523
+ name: name,
524
+ method: options[:method] || :post,
525
+ confirm: options[:confirm],
526
+ type: options[:type] || :button,
527
+ label: options[:label] || name.to_s.humanize,
528
+ icon: options[:icon],
529
+ color: options[:color]
530
+ )
531
+ end
532
+ end
533
+
534
+ ActionDefinition = Struct.new(
535
+ :name, :method, :confirm, :type, :label, :icon, :color,
536
+ :if_condition, :unless_condition,
537
+ keyword_init: true
538
+ )
539
+ end
540
+ end
541
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ # Configuration object for AdminSuite.
5
+ class Configuration
6
+ attr_accessor :authenticate,
7
+ :current_actor,
8
+ :authorize,
9
+ :resource_globs,
10
+ :portal_globs,
11
+ :portals,
12
+ :custom_renderers,
13
+ :icon_renderer,
14
+ :docs_url,
15
+ :docs_path,
16
+ :partials,
17
+ :theme,
18
+ :host_stylesheet,
19
+ :tailwind_cdn,
20
+ :on_action_executed,
21
+ :resolve_action_handler
22
+
23
+ def initialize
24
+ @authenticate = nil
25
+ @current_actor = nil
26
+ @authorize = nil
27
+ @resource_globs = []
28
+ @portal_globs = []
29
+ @portals = {}
30
+ @custom_renderers = {}
31
+ @icon_renderer = nil
32
+ @docs_url = nil
33
+ @docs_path = Rails.root.join("docs")
34
+ @partials = {}
35
+ @theme = { primary: :indigo, secondary: :purple }
36
+ @host_stylesheet = nil
37
+ @tailwind_cdn = true
38
+ @on_action_executed = nil
39
+ @resolve_action_handler = nil
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module AdminSuite
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace AdminSuite
8
+
9
+ initializer "admin_suite.inflections" do
10
+ # Engine namespace uses `UI` (all-caps). Without this, Zeitwerk expects `Ui`.
11
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
12
+ inflect.acronym "UI"
13
+ end
14
+ end
15
+
16
+ initializer "admin_suite.host_dsl_ignore" do
17
+ # Host apps sometimes store AdminSuite DSL files under `app/admin_suite/**`.
18
+ # Those are not constant definitions, so we must prevent Zeitwerk from
19
+ # expecting constants like `Portals::Ai` during eager load.
20
+ host_dsl_dir = Rails.root.join("app/admin_suite")
21
+ next unless host_dsl_dir.exist?
22
+
23
+ Rails.autoloaders.each do |loader|
24
+ loader.ignore(host_dsl_dir)
25
+ end
26
+ end
27
+
28
+ initializer "admin_suite.admin_dsl" do
29
+ # Ensure core DSL types are loaded in all environments (including test).
30
+ require "admin/base/resource"
31
+ require "admin/base/filter_builder"
32
+ require "admin/base/action_executor"
33
+ require "admin/base/action_handler"
34
+ end
35
+
36
+ initializer "admin_suite.watchable_dirs" do |app|
37
+ next unless Rails.env.development?
38
+
39
+ # Make local-engine edits reload without a full server restart.
40
+ app.config.watchable_dirs[root.join("app").to_s] = %w[rb erb js css]
41
+ app.config.watchable_dirs[root.join("lib").to_s] = %w[rb]
42
+ app.config.watchable_dirs[root.join("config").to_s] = %w[rb]
43
+ end
44
+
45
+ initializer "admin_suite.assets", before: "propshaft" do |app|
46
+ # Make engine JS/CSS available to the host asset pipeline (Propshaft/Sprockets).
47
+ app.config.assets.paths << root.join("app/javascript")
48
+ app.config.assets.paths << root.join("app/assets")
49
+ end
50
+
51
+ initializer "admin_suite.importmap", before: "importmap" do |app|
52
+ # Make engine-provided JS available to host apps using importmap-rails.
53
+ if app.config.respond_to?(:importmap) && app.config.importmap.respond_to?(:paths)
54
+ app.config.importmap.paths << root.join("config/importmap.rb")
55
+ end
56
+ end
57
+
58
+ initializer "admin_suite.configuration" do
59
+ # Provide sensible defaults for host apps.
60
+ AdminSuite.configure do |config|
61
+ if config.resource_globs.blank?
62
+ config.resource_globs = [
63
+ Rails.root.join("config/admin_suite/resources/*.rb").to_s,
64
+ Rails.root.join("app/admin/resources/*.rb").to_s
65
+ ]
66
+ end
67
+
68
+ if config.portal_globs.blank?
69
+ config.portal_globs = [
70
+ Rails.root.join("config/admin_suite/portals/*.rb").to_s,
71
+ Rails.root.join("app/admin/portals/*.rb").to_s,
72
+ Rails.root.join("app/admin_suite/portals/*.rb").to_s
73
+ ]
74
+ end
75
+
76
+ config.portals = {
77
+ ops: { label: "Ops Portal", icon: "settings", color: :amber, order: 10 },
78
+ email: { label: "Email Portal", icon: "inbox", color: :emerald, order: 20 },
79
+ ai: { label: "AI Portal", icon: "cpu", color: :cyan, order: 30 },
80
+ assistant: { label: "Assistant Portal", icon: "message-circle", color: :violet, order: 40 }
81
+ } if config.portals.blank?
82
+ end
83
+ end
84
+
85
+ initializer "admin_suite.tailwind_build" do
86
+ next unless Rails.env.development?
87
+
88
+ # In development, ensure the engine stylesheet exists so the UI is usable
89
+ # without requiring host-specific Tailwind setup.
90
+ output = Rails.root.join("app/assets/builds/admin_suite_tailwind.css")
91
+ next if output.exist?
92
+
93
+ input = root.join("app/assets/tailwind/admin_suite.css")
94
+ FileUtils.mkdir_p(output.dirname)
95
+
96
+ system("tailwindcss", "-i", input.to_s, "-o", output.to_s)
97
+ rescue StandardError
98
+ # Best effort only; missing stylesheet will show up immediately in the UI.
99
+ end
100
+ end
101
+ end