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,52 @@
1
+ <%# JSON Editor Field for Admin Suite Forms
2
+ Parameters:
3
+ - f: Form builder object (required)
4
+ - field: FieldDefinition object (required)
5
+ - resource: The resource being edited (required)
6
+ -%>
7
+ <%
8
+ value = resource.public_send(field.name)
9
+ rows = field.rows || 10
10
+ formatted_value = if value.is_a?(Hash) || value.is_a?(Array)
11
+ JSON.pretty_generate(value)
12
+ elsif value.present?
13
+ value.to_s
14
+ else
15
+ "{}"
16
+ end
17
+ %>
18
+
19
+ <div data-controller="admin-suite--json-editor">
20
+ <% if field.help.present? %>
21
+ <p class="text-sm text-slate-500 dark:text-slate-400 mb-2"><%= field.help %></p>
22
+ <% else %>
23
+ <p class="text-sm text-slate-500 dark:text-slate-400 mb-2">Enter valid JSON. Use the Format button to pretty-print.</p>
24
+ <% end %>
25
+
26
+ <div class="relative">
27
+ <%= f.text_area field.name,
28
+ rows: rows,
29
+ class: "form-input w-full font-mono text-sm",
30
+ value: formatted_value,
31
+ readonly: field.readonly,
32
+ data: {
33
+ "admin-suite--json-editor-target": "input",
34
+ action: "input->admin-suite--json-editor#validate"
35
+ } %>
36
+
37
+ <div class="absolute top-2 right-2">
38
+ <button
39
+ type="button"
40
+ data-action="click->admin-suite--json-editor#format"
41
+ class="px-3 py-1.5 text-xs font-medium text-slate-700 dark:text-slate-300 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 rounded transition-colors"
42
+ >
43
+ Format JSON
44
+ </button>
45
+ </div>
46
+ </div>
47
+
48
+ <div
49
+ data-admin-suite--json-editor-target="error"
50
+ class="hidden mt-2 text-sm text-red-600 dark:text-red-400"
51
+ ></div>
52
+ </div>
@@ -0,0 +1,94 @@
1
+ <%# Admin Suite Sidebar - mirrors internal/developer styling %>
2
+
3
+ <% sorted_portals = navigation_items.sort_by { |(_key, meta)| (meta[:order] || 100).to_i } %>
4
+
5
+ <aside class="admin-suite-sidebar flex flex-col w-72 h-full">
6
+ <!-- Header -->
7
+ <div class="flex items-center justify-between h-16 px-4 border-b border-white/10">
8
+ <%= link_to root_path, class: "flex items-center gap-3" do %>
9
+ <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-white/10 border border-white/15">
10
+ <%= admin_suite_icon("code", class: "w-5 h-5 text-white") %>
11
+ </div>
12
+ <div>
13
+ <span class="text-lg font-bold text-white">Developer</span>
14
+ <span class="block text-xs text-white/80">Admin Suite</span>
15
+ </div>
16
+ <% end %>
17
+ </div>
18
+
19
+ <!-- Quick Links -->
20
+ <div class="px-3 py-3 border-b border-white/10">
21
+ <div class="flex gap-2">
22
+ <%= link_to root_path,
23
+ class: "flex-1 px-3 py-2 text-xs font-medium text-white bg-white/10 rounded-lg hover:bg-white/20 text-center transition-colors" do %>
24
+ Dashboard
25
+ <% end %>
26
+ <% docs = AdminSuite.config.docs_url %>
27
+ <% docs_url = docs.respond_to?(:call) ? docs.call(self) : docs %>
28
+ <% if docs_url.present? %>
29
+ <%= link_to docs_url,
30
+ class: "flex-1 px-3 py-2 text-xs font-medium text-white bg-white/10 rounded-lg hover:bg-white/20 text-center transition-colors",
31
+ target: "_blank",
32
+ rel: "noopener noreferrer" do %>
33
+ Docs
34
+ <% end %>
35
+ <% else %>
36
+ <%= link_to docs_path,
37
+ class: "flex-1 px-3 py-2 text-xs font-medium text-white bg-white/10 rounded-lg hover:bg-white/20 text-center transition-colors" do %>
38
+ Docs
39
+ <% end %>
40
+ <% end %>
41
+ </div>
42
+ </div>
43
+
44
+ <!-- Portal Navigation -->
45
+ <nav class="flex-1 overflow-y-auto px-3 py-4 space-y-2">
46
+ <% sorted_portals.each do |portal_key, portal| %>
47
+ <% portal_home = portal_path(portal: portal_key) %>
48
+ <% portal_prefix = File.join(request.script_name.to_s, portal_key.to_s) %>
49
+ <% active = request.path.start_with?(portal_prefix) %>
50
+ <% color = portal_color(portal_key) %>
51
+
52
+ <div>
53
+ <%= link_to (portal_home || root_path),
54
+ class: "flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors #{active ? "bg-white/20 text-white" : "text-white/90 hover:bg-white/10 hover:text-white"}" do %>
55
+ <div class="admin-suite-portal-accent admin-suite-portal-accent--<%= color %> admin-suite-portal-chip flex items-center justify-center w-8 h-8 rounded-lg <%= active ? "" : "bg-white/10" %>">
56
+ <%= portal_icon(portal_key, class: "admin-suite-portal-icon w-4 h-4") %>
57
+ </div>
58
+ <span class="font-medium"><%= portal[:label] %></span>
59
+ <% end %>
60
+
61
+ <% if active %>
62
+ <div class="mt-1 ml-11 space-y-0.5 text-sm">
63
+ <% portal[:sections].sort_by { |(_k, s)| s[:label].to_s }.each do |_section_key, section| %>
64
+ <div class="text-xs font-medium text-white/70 uppercase tracking-wider px-3 pt-2 pb-1">
65
+ <%= section[:label] %>
66
+ </div>
67
+ <% section[:items].sort_by { |it| [ (it[:order] || 100).to_i, it[:label].to_s ] }.each do |item| %>
68
+ <% item_active = request.path.start_with?(item[:path].to_s) %>
69
+ <%= link_to item[:path],
70
+ class: "flex items-center gap-2 px-3 py-1.5 rounded #{item_active ? "text-white bg-white/20" : "text-white/90 hover:bg-white/10 hover:text-white"}",
71
+ data: { turbo_frame: "_top" } do %>
72
+ <% if item[:icon].present? %>
73
+ <%= admin_suite_icon(item[:icon], class: "w-3.5 h-3.5 text-white/90") %>
74
+ <% end %>
75
+ <span class="truncate"><%= item[:label] %></span>
76
+ <% end %>
77
+ <% end %>
78
+ <% end %>
79
+ </div>
80
+ <% end %>
81
+ </div>
82
+ <% end %>
83
+ </nav>
84
+
85
+ <!-- Footer -->
86
+ <div class="p-3 border-t border-white/10">
87
+ <div class="px-3 py-2 text-xs text-white/70">
88
+ <div class="flex items-center justify-between">
89
+ <span>Admin Suite</span>
90
+ <span class="font-medium text-white/90"><%= sorted_portals.size %> Portals</span>
91
+ </div>
92
+ </div>
93
+ </div>
94
+ </aside>
@@ -0,0 +1,34 @@
1
+ <%# Toggle switch for boolean fields on index pages %>
2
+ <%#
3
+ Required locals:
4
+ - record: The record to toggle
5
+ - field: The boolean field name (e.g., :value, :enabled, :active)
6
+ - toggle_url: (optional) Custom URL for the toggle action
7
+ %>
8
+ <%
9
+ value = record.public_send(field)
10
+ toggle_url ||= begin
11
+ url_for(action: :toggle, id: record.id, field: field)
12
+ rescue ActionController::UrlGenerationError
13
+ resource_toggle_path(portal: params[:portal], resource_name: params[:resource_name], id: record.id, field: field)
14
+ rescue StandardError
15
+ nil
16
+ end
17
+ %>
18
+
19
+ <%= turbo_frame_tag dom_id(record, :toggle), class: "admin-suite-toggle-wrap inline-flex align-middle" do %>
20
+ <% if toggle_url %>
21
+ <%= button_to toggle_url,
22
+ method: :post,
23
+ form: { data: { turbo_frame: dom_id(record, :toggle) }, class: "m-0 inline-flex align-middle items-center" },
24
+ class: "admin-suite-toggle-track #{value ? 'is-on' : ''}" do %>
25
+ <span class="sr-only">Toggle <%= field.to_s.humanize %></span>
26
+ <span class="admin-suite-toggle-thumb"></span>
27
+ <% end %>
28
+ <% else %>
29
+ <%# Read-only toggle display when no toggle URL available %>
30
+ <span class="admin-suite-toggle-track <%= value ? 'is-on' : '' %>">
31
+ <span class="admin-suite-toggle-thumb"></span>
32
+ </span>
33
+ <% end %>
34
+ <% end %>
@@ -0,0 +1,47 @@
1
+ <%# Admin Suite Top Bar (mirrors internal/developer styling) %>
2
+
3
+ <header class="bg-white border-b border-slate-200 h-16 flex-shrink-0">
4
+ <div class="flex items-center justify-between h-full px-4 sm:px-6">
5
+ <!-- Left side -->
6
+ <div class="flex items-center gap-4">
7
+ <!-- Mobile menu button -->
8
+ <button type="button"
9
+ class="lg:hidden p-2 rounded-lg text-slate-500 hover:bg-slate-100"
10
+ data-action="click->admin-suite--sidebar#toggle">
11
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
12
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
13
+ </svg>
14
+ </button>
15
+
16
+ <!-- Breadcrumb / Current section -->
17
+ <div class="hidden sm:flex items-center gap-2 text-sm">
18
+ <span class="text-slate-400">Admin Suite</span>
19
+ <%= admin_suite_icon("chevron-right", class: "w-4 h-4 text-slate-300") %>
20
+ <span class="font-medium text-slate-900"><%= controller_name.humanize %></span>
21
+ </div>
22
+ </div>
23
+
24
+ <!-- Right side -->
25
+ <div class="flex items-center gap-3">
26
+ <!-- Environment Badge -->
27
+ <span class="hidden sm:inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium <%= theme_badge_primary_class %>">
28
+ <span class="admin-suite-env-dot w-1.5 h-1.5 rounded-full mr-1.5 animate-pulse"></span>
29
+ <%= Rails.env.to_s.humanize %>
30
+ </span>
31
+
32
+ <!-- Actor display -->
33
+ <div class="flex items-center gap-3 pl-3 border-l border-slate-200">
34
+ <div class="text-right hidden sm:block">
35
+ <% actor = admin_suite_actor %>
36
+ <p class="text-sm font-medium text-slate-900">
37
+ <%= actor&.respond_to?(:name) ? actor.name : actor&.respond_to?(:email) ? actor.email : actor&.respond_to?(:email_address) ? actor.email_address : "—" %>
38
+ </p>
39
+ <p class="text-xs text-slate-500">Actor</p>
40
+ </div>
41
+ <div class="admin-suite-avatar flex items-center justify-center w-9 h-9 rounded-full font-semibold text-sm">
42
+ <%= (admin_suite_actor&.respond_to?(:name) && admin_suite_actor.name.present?) ? admin_suite_actor.name.first.upcase : "A" %>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ </div>
47
+ </header>
@@ -0,0 +1,79 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title><%= content_for(:title) || "Admin Suite" %></title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="application-name" content="Admin Suite">
8
+ <meta name="mobile-web-app-capable" content="yes">
9
+ <%= csrf_meta_tags %>
10
+ <%= csp_meta_tag %>
11
+
12
+ <%= yield :head %>
13
+
14
+ <%# AdminSuite ships a baseline stylesheet so it can run without host Tailwind. %>
15
+ <%= stylesheet_link_tag "admin_suite", "data-turbo-track": "reload" %>
16
+
17
+ <%# Engine-build mode: AdminSuite ships its own compiled Tailwind CSS. %>
18
+ <%= stylesheet_link_tag "admin_suite_tailwind", "data-turbo-track": "reload" %>
19
+
20
+ <%# Scoped theme variables for AdminSuite (no host dependency). %>
21
+ <%= admin_suite_theme_style_tag %>
22
+
23
+ <%# Optional: allow host apps to include their own stylesheet after AdminSuite
24
+ (for brand tweaks/overrides). Not required for the engine to function. %>
25
+ <% host_stylesheet = AdminSuite.config.respond_to?(:host_stylesheet) ? AdminSuite.config.host_stylesheet : nil %>
26
+ <% if host_stylesheet.present? %>
27
+ <%= stylesheet_link_tag host_stylesheet, "data-turbo-track": "reload" %>
28
+ <% end %>
29
+
30
+ <%= stylesheet_link_tag "rouge", "data-turbo-track": "reload" %>
31
+
32
+ <%# EasyMDE Markdown Editor %>
33
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde@2.18.0/dist/easymde.min.css" data-turbo-track="reload">
34
+ <script src="https://cdn.jsdelivr.net/npm/easymde@2.18.0/dist/easymde.min.js" data-turbo-track="reload"></script>
35
+
36
+ <% if respond_to?(:javascript_importmap_tags) %>
37
+ <%= javascript_importmap_tags %>
38
+ <% end %>
39
+ </head>
40
+
41
+ <body class="admin-suite bg-slate-50">
42
+ <div class="flex h-screen overflow-hidden" data-controller="admin-suite--sidebar">
43
+ <!-- Sidebar -->
44
+ <div class="hidden lg:flex lg:flex-shrink-0">
45
+ <%= render "admin_suite/shared/sidebar" %>
46
+ </div>
47
+
48
+ <!-- Mobile sidebar overlay -->
49
+ <div data-admin-suite--sidebar-target="overlay"
50
+ class="hidden fixed inset-0 z-40 bg-slate-600 bg-opacity-75 lg:hidden"
51
+ data-action="click->admin-suite--sidebar#close">
52
+ </div>
53
+
54
+ <!-- Mobile sidebar -->
55
+ <div data-admin-suite--sidebar-target="mobileSidebar"
56
+ class="hidden fixed inset-y-0 left-0 z-50 w-72 transform transition-transform duration-300 ease-in-out lg:hidden">
57
+ <%= render "admin_suite/shared/sidebar" %>
58
+ </div>
59
+
60
+ <!-- Main content area -->
61
+ <div class="flex flex-col flex-1 overflow-hidden">
62
+ <!-- Top bar -->
63
+ <%= render "admin_suite/shared/topbar" %>
64
+
65
+ <!-- Main content -->
66
+ <main class="flex-1 overflow-y-auto bg-slate-50">
67
+ <!-- Flash messages -->
68
+ <% flash_partial = AdminSuite.config.partials[:flash].presence || "admin_suite/shared/flash" %>
69
+ <%= render flash_partial %>
70
+
71
+ <!-- Page content -->
72
+ <div class="py-6">
73
+ <%= yield %>
74
+ </div>
75
+ </main>
76
+ </div>
77
+ </div>
78
+ </body>
79
+ </html>
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ module Base
5
+ class ActionExecutor
6
+ attr_reader :resource_class, :action_name, :actor
7
+
8
+ alias_method :current_user, :actor
9
+
10
+ Result = Struct.new(:success, :message, :redirect_url, :errors, keyword_init: true) do
11
+ def success? = success
12
+ def failure? = !success
13
+ end
14
+
15
+ def initialize(resource_class, action_name, actor)
16
+ @resource_class = resource_class
17
+ @action_name = action_name
18
+ @actor = actor
19
+ end
20
+
21
+ def execute_member(record, params = {})
22
+ action = find_member_action
23
+ return failure_result("Action not found") unless action
24
+ return failure_result("Condition not met") unless condition_met?(action, record)
25
+ execute_action(action, record, params)
26
+ end
27
+
28
+ def execute_bulk(records, params = {})
29
+ action = find_bulk_action
30
+ return failure_result("Action not found") unless action
31
+
32
+ results = records.map { |record| execute_action(action, record, params) }
33
+ success_count = results.count(&:success?)
34
+ failure_count = results.count(&:failure?)
35
+
36
+ if failure_count.zero?
37
+ success_result("Successfully processed #{success_count} records")
38
+ elsif success_count.zero?
39
+ failure_result("Failed to process all #{failure_count} records")
40
+ else
41
+ success_result("Processed #{success_count} records, #{failure_count} failed")
42
+ end
43
+ end
44
+
45
+ def execute_collection(scope, params = {})
46
+ action = find_collection_action
47
+ return failure_result("Action not found") unless action
48
+ execute_action(action, scope, params)
49
+ end
50
+
51
+ def action_definition
52
+ find_member_action || find_bulk_action || find_collection_action
53
+ end
54
+
55
+ private
56
+
57
+ def actions_config = @resource_class.actions_config
58
+
59
+ def find_member_action
60
+ return nil unless actions_config
61
+ actions_config.member_actions.find { |a| a.name == action_name }
62
+ end
63
+
64
+ def find_bulk_action
65
+ return nil unless actions_config
66
+ actions_config.bulk_actions.find { |a| a.name == action_name }
67
+ end
68
+
69
+ def find_collection_action
70
+ return nil unless actions_config
71
+ actions_config.collection_actions.find { |a| a.name == action_name }
72
+ end
73
+
74
+ def condition_met?(action, record)
75
+ return evaluate_condition(action.if_condition, record) if action.if_condition.present?
76
+ return !evaluate_condition(action.unless_condition, record) if action.unless_condition.present?
77
+ true
78
+ end
79
+
80
+ def evaluate_condition(condition_proc, record)
81
+ condition_proc.arity.zero? ? record.instance_exec(&condition_proc) : condition_proc.call(record)
82
+ end
83
+
84
+ def execute_action(action, target, params)
85
+ result =
86
+ if target.respond_to?(action.name)
87
+ execute_model_method(target, action)
88
+ elsif target.respond_to?("#{action.name}!")
89
+ execute_model_method(target, action, bang: true)
90
+ else
91
+ handler_class = find_handler_class(action)
92
+ handler_class ? execute_handler(handler_class, target, params) : failure_result("No handler found for action: #{action.name}")
93
+ end
94
+
95
+ notify_action_executed(action, target, params, result)
96
+ result
97
+ rescue StandardError => e
98
+ result = failure_result("Error: #{e.message}")
99
+ notify_action_executed(action, target, params, result)
100
+ result
101
+ end
102
+
103
+ def execute_model_method(record, action, bang: false)
104
+ method_name = bang ? "#{action.name}!" : action.name
105
+ record.public_send(method_name)
106
+ success_result("#{action.label} completed successfully")
107
+ rescue ActiveRecord::RecordInvalid => e
108
+ failure_result("Validation failed: #{e.record.errors.full_messages.join(', ')}")
109
+ rescue AASM::InvalidTransition => e
110
+ failure_result("Invalid state transition: #{e.message}")
111
+ end
112
+
113
+ def find_handler_class(action)
114
+ if defined?(AdminSuite) && AdminSuite.config.resolve_action_handler.present?
115
+ resolved = AdminSuite.config.resolve_action_handler.call(resource_class, action.name)
116
+ return resolved if resolved
117
+ end
118
+
119
+ handler_name = "#{resource_class.resource_name.camelize}#{action.name.to_s.camelize}Action"
120
+ "Admin::Actions::#{handler_name}".constantize
121
+ rescue NameError
122
+ nil
123
+ end
124
+
125
+ def execute_handler(handler_class, target, params)
126
+ handler_class.new(target, actor, params).call
127
+ end
128
+
129
+ def success_result(message, redirect_url: nil)
130
+ Result.new(success: true, message: message, redirect_url: redirect_url, errors: [])
131
+ end
132
+
133
+ def failure_result(message, errors: [])
134
+ Result.new(success: false, message: message, redirect_url: nil, errors: errors)
135
+ end
136
+
137
+ def notify_action_executed(action, target, params, result)
138
+ return unless defined?(AdminSuite)
139
+ hook = AdminSuite.config.on_action_executed
140
+ return unless hook
141
+
142
+ hook.call(
143
+ actor: actor,
144
+ action_name: action.name,
145
+ resource_class: resource_class,
146
+ subject: target,
147
+ params: params,
148
+ result: result
149
+ )
150
+ rescue StandardError
151
+ nil
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ module Base
5
+ class ActionHandler
6
+ attr_reader :record, :actor, :params
7
+
8
+ alias_method :current_user, :actor
9
+
10
+ def initialize(record, actor, params = {})
11
+ @record = record
12
+ @actor = actor
13
+ @params = params
14
+ end
15
+
16
+ def call
17
+ raise NotImplementedError, "Subclasses must implement #call"
18
+ end
19
+
20
+ protected
21
+
22
+ def success(message, redirect_url: nil)
23
+ Admin::Base::ActionExecutor::Result.new(success: true, message: message, redirect_url: redirect_url, errors: [])
24
+ end
25
+
26
+ def failure(message, errors: [])
27
+ Admin::Base::ActionExecutor::Result.new(success: false, message: message, redirect_url: nil, errors: errors)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ module Base
5
+ class FilterBuilder
6
+ attr_reader :resource_class, :params
7
+
8
+ def initialize(resource_class, params)
9
+ @resource_class = resource_class
10
+ @params = params
11
+ end
12
+
13
+ def apply(scope)
14
+ scope = apply_search(scope)
15
+ scope = apply_filters(scope)
16
+ scope = apply_sort(scope)
17
+ scope
18
+ end
19
+
20
+ def filter_params
21
+ return {} unless index_config
22
+
23
+ permitted_keys = [ :search, :sort, :sort_direction, :page ]
24
+ permitted_keys += index_config.filters_list.map(&:name)
25
+
26
+ params.permit(*permitted_keys).to_h.symbolize_keys
27
+ end
28
+
29
+ private
30
+
31
+ def index_config
32
+ @resource_class.index_config
33
+ end
34
+
35
+ def apply_search(scope)
36
+ return scope unless index_config
37
+ return scope if params[:search].blank?
38
+ return scope if index_config.searchable_fields.empty?
39
+ return scope if params[:search].to_s.length < 3
40
+
41
+ search_term = "%#{params[:search]}%"
42
+ conditions = index_config.searchable_fields.map { |field| "#{field} ILIKE :search" }.join(" OR ")
43
+ scope.where(conditions, search: search_term)
44
+ end
45
+
46
+ def apply_filters(scope)
47
+ return scope unless index_config
48
+
49
+ index_config.filters_list.each do |filter|
50
+ scope = apply_filter(scope, filter)
51
+ end
52
+ scope
53
+ end
54
+
55
+ def apply_filter(scope, filter)
56
+ # Some "filters" in the UI are really just controls (e.g. sort dropdown).
57
+ # They are handled elsewhere (`apply_sort`) and must not be turned into SQL.
58
+ return scope if %i[sort sort_direction direction page search].include?(filter.name.to_sym)
59
+
60
+ value = params[filter.name]
61
+ return scope if value.blank?
62
+
63
+ if filter.respond_to?(:apply) && filter.apply.present?
64
+ return apply_custom_filter(scope, filter.apply, value)
65
+ end
66
+
67
+ case filter.type
68
+ when :text, :search
69
+ scope.where("#{filter.field} ILIKE ?", "%#{value}%")
70
+ when :select
71
+ scope.where(filter.field => value)
72
+ when :toggle, :boolean
73
+ bool_value = ActiveModel::Type::Boolean.new.cast(value)
74
+ scope.where(filter.field => bool_value)
75
+ when :number
76
+ scope.where(filter.field => value.to_i)
77
+ when :date
78
+ date = Date.parse(value) rescue nil
79
+ return scope unless date
80
+ scope.where(filter.field => date.all_day)
81
+ when :date_range
82
+ from_date = params["#{filter.name}_from"].presence
83
+ to_date = params["#{filter.name}_to"].presence
84
+ scope = scope.where("#{filter.field} >= ?", Date.parse(from_date)) if from_date.present?
85
+ scope = scope.where("#{filter.field} <= ?", Date.parse(to_date).end_of_day) if to_date.present?
86
+ scope
87
+ when :association
88
+ scope.where("#{filter.field}_id" => value)
89
+ else
90
+ scope
91
+ end
92
+ end
93
+
94
+ def apply_custom_filter(scope, filter_proc, value)
95
+ filter_proc.arity == 2 ? filter_proc.call(scope, value) : filter_proc.call(scope, value, params)
96
+ end
97
+
98
+ def apply_sort(scope)
99
+ return scope unless index_config
100
+
101
+ sort_field = params[:sort].presence || index_config.default_sort
102
+ return scope unless sort_field
103
+
104
+ unless index_config.sortable_fields.include?(sort_field.to_sym)
105
+ sort_field = index_config.default_sort
106
+ end
107
+ return scope unless sort_field
108
+
109
+ direction_param = params[:sort_direction].presence || params[:direction].presence
110
+ direction =
111
+ if direction_param.present?
112
+ direction_param.to_sym == :desc ? :desc : :asc
113
+ else
114
+ (index_config.default_sort_direction || :desc).to_sym
115
+ end
116
+
117
+ scope.order(sort_field => direction)
118
+ end
119
+ end
120
+ end
121
+ end