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,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ class DocsController < ApplicationController
5
+ DocNotFound = Class.new(StandardError)
6
+
7
+ before_action :set_docs_root
8
+
9
+ # GET /docs
10
+ # GET /docs?path=relative/path.md
11
+ def index
12
+ @files = grouped_markdown_files
13
+ @selected_path = params[:path].presence
14
+
15
+ if @selected_path.present?
16
+ load_doc_content(@selected_path)
17
+ elsif @files.values.flatten.any?
18
+ @selected_path = @files.values.flatten.first
19
+ load_doc_content(@selected_path)
20
+ end
21
+ end
22
+
23
+ # GET /docs/*path
24
+ def show
25
+ relative_path = params[:path].to_s
26
+ if params[:format].present? && !relative_path.end_with?(".#{params[:format]}")
27
+ relative_path = "#{relative_path}.#{params[:format]}"
28
+ end
29
+
30
+ # Even when the doc path ends with `.md`, we always render HTML.
31
+ request.format = :html
32
+ file_path = resolve_doc_path!(relative_path)
33
+
34
+ @files = grouped_markdown_files
35
+ @selected_path = relative_path
36
+ @title = File.basename(file_path, ".md").tr("_", " ").tr("-", " ").titleize
37
+ @raw_markdown = File.read(file_path)
38
+
39
+ rendered = markdown_renderer.new(@raw_markdown).render
40
+ @content_html = rendered[:html]
41
+ @toc = rendered[:toc]
42
+ @reading_time = rendered[:reading_time_minutes]
43
+
44
+ render :index, formats: [ :html ]
45
+ rescue DocNotFound
46
+ redirect_to docs_path, alert: "Doc not found."
47
+ end
48
+
49
+ private
50
+
51
+ def set_docs_root
52
+ @docs_root = docs_root
53
+ end
54
+
55
+ def load_doc_content(relative_path)
56
+ file_path = resolve_doc_path!(relative_path)
57
+ @title = File.basename(file_path, ".md").tr("_", " ").tr("-", " ").titleize
58
+ @raw_markdown = File.read(file_path)
59
+
60
+ rendered = markdown_renderer.new(@raw_markdown).render
61
+ @content_html = rendered[:html]
62
+ @toc = rendered[:toc]
63
+ @reading_time = rendered[:reading_time_minutes]
64
+ rescue DocNotFound
65
+ @title = nil
66
+ @content_html = nil
67
+ @toc = []
68
+ @reading_time = nil
69
+ end
70
+
71
+ def markdown_renderer
72
+ AdminSuite::MarkdownRenderer
73
+ rescue NameError
74
+ # In development, new engine lib files can be added without a server restart.
75
+ # Make the docs viewer resilient by loading the renderer on demand.
76
+ require "admin_suite/markdown_renderer"
77
+ AdminSuite::MarkdownRenderer
78
+ end
79
+
80
+ def grouped_markdown_files
81
+ base = docs_root_realpath
82
+ files = Dir.glob(base.join("**/*.md")).sort.map do |abs|
83
+ abs_path = Pathname.new(abs)
84
+ abs_path.relative_path_from(base).to_s
85
+ end
86
+
87
+ groups = files.group_by { |path| group_name_for_path(path) }
88
+ groups.sort_by { |k, _| k.to_s }.to_h
89
+ rescue StandardError
90
+ {}
91
+ end
92
+
93
+ def group_name_for_path(relative_path)
94
+ folder = relative_path.to_s.split(File::SEPARATOR).first
95
+ if folder.present? && folder != File.basename(relative_path.to_s)
96
+ return humanize_folder_name(folder)
97
+ end
98
+
99
+ "Docs"
100
+ end
101
+
102
+ def humanize_folder_name(folder)
103
+ normalized = folder.to_s.tr("_", " ").tr("-", " ").strip
104
+ acronyms = {
105
+ "cicd" => "CICD",
106
+ "ci cd" => "CICD",
107
+ "ai" => "AI",
108
+ "ops" => "Ops",
109
+ "oauth" => "OAuth",
110
+ "ui" => "UI",
111
+ "ux" => "UX",
112
+ "api" => "API"
113
+ }
114
+
115
+ key = normalized.downcase
116
+ return acronyms[key] if acronyms.key?(key)
117
+
118
+ normalized.titleize
119
+ end
120
+
121
+ def docs_root
122
+ value =
123
+ if AdminSuite.config.respond_to?(:docs_path)
124
+ AdminSuite.config.docs_path
125
+ else
126
+ Rails.root.join("docs")
127
+ end
128
+ value = value.call(self) if value.respond_to?(:call)
129
+ value = Rails.root.join("docs") if value.blank?
130
+ Pathname.new(value.to_s)
131
+ end
132
+
133
+ def resolve_doc_path!(relative_path)
134
+ raise DocNotFound if relative_path.blank?
135
+ raise DocNotFound if relative_path.include?("..")
136
+
137
+ base = docs_root_realpath
138
+ candidate = base.join(relative_path)
139
+ raise DocNotFound unless candidate.extname == ".md"
140
+
141
+ real = candidate.realpath
142
+ raise DocNotFound unless real.to_s.start_with?(base.to_s + File::SEPARATOR)
143
+
144
+ real.to_s
145
+ rescue Errno::ENOENT, Errno::EACCES
146
+ raise DocNotFound
147
+ end
148
+
149
+ def docs_root_realpath
150
+ docs_root.realpath
151
+ rescue Errno::ENOENT
152
+ docs_root
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ class PortalsController < ApplicationController
5
+ def show
6
+ ensure_portals_loaded!
7
+ @portal_key = params[:portal].to_s.presence&.to_sym
8
+ @portal = navigation_items[@portal_key]
9
+ @portal_definition = AdminSuite::PortalRegistry.fetch(@portal_key)
10
+
11
+ raise ActionController::RoutingError, "Portal not found" if @portal.blank?
12
+
13
+ @sections =
14
+ (@portal[:sections] || {}).sort_by { |(_k, s)| s[:label].to_s }.map do |section_key, section|
15
+ items = Array(section[:items]).sort_by { |it| it[:label].to_s }
16
+ [ section_key, section.merge(items: items) ]
17
+ end
18
+
19
+ @dashboard_rows = @portal_definition&.dashboard_definition&.rows
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ class ResourcesController < ApplicationController
5
+ include Pagy::Backend
6
+ include Pagy::Frontend
7
+
8
+ before_action :set_resource, if: -> { params[:id].present? && !%w[index new create].include?(action_name) }
9
+
10
+ helper_method :resource_config, :resource_class, :resource, :collection, :current_portal, :resource_name
11
+
12
+ # GET /:portal/:resource_name
13
+ def index
14
+ @stats = calculate_stats if resource_config&.index_config&.stats_list&.any?
15
+ @pagy, @collection = paginate_collection(filtered_collection)
16
+ end
17
+
18
+ # GET /:portal/:resource_name/:id
19
+ def show
20
+ end
21
+
22
+ # GET /:portal/:resource_name/new
23
+ def new
24
+ @resource = resource_class.new
25
+ end
26
+
27
+ # GET /:portal/:resource_name/:id/edit
28
+ def edit
29
+ end
30
+
31
+ # POST /:portal/:resource_name
32
+ def create
33
+ @resource = resource_class.new(resource_params)
34
+ if @resource.save
35
+ redirect_to resource_url(@resource), notice: "#{resource_config.human_name} was successfully created."
36
+ else
37
+ render :new, status: :unprocessable_entity
38
+ end
39
+ end
40
+
41
+ # PATCH/PUT /:portal/:resource_name/:id
42
+ def update
43
+ if @resource.update(resource_params)
44
+ redirect_to resource_url(@resource), notice: "#{resource_config.human_name} was successfully updated."
45
+ else
46
+ render :edit, status: :unprocessable_entity
47
+ end
48
+ end
49
+
50
+ # DELETE /:portal/:resource_name/:id
51
+ def destroy
52
+ @resource.destroy!
53
+ redirect_to collection_url, notice: "#{resource_config.human_name} was successfully deleted.", status: :see_other
54
+ end
55
+
56
+ # POST /:portal/:resource_name/:id/execute_action/:action_name
57
+ def execute_action
58
+ action = params[:action_name].to_s.to_sym
59
+ action_def = find_action(action)
60
+ unless action_def
61
+ redirect_to resource_url(@resource), alert: "Action not found."
62
+ return
63
+ end
64
+
65
+ executor = Admin::Base::ActionExecutor.new(resource_config, action, admin_suite_actor)
66
+ result = executor.execute_member(@resource, params.to_unsafe_h)
67
+
68
+ if result.success?
69
+ redirect_to resource_url(@resource), notice: result.message
70
+ else
71
+ redirect_to resource_url(@resource), alert: result.message
72
+ end
73
+ end
74
+
75
+ # POST /:portal/:resource_name/bulk_action/:action_name
76
+ def bulk_action
77
+ action = params[:action_name].to_s.to_sym
78
+ ids = params[:ids] || []
79
+ if ids.empty?
80
+ redirect_to collection_url, alert: "No items selected."
81
+ return
82
+ end
83
+
84
+ model = resource_class
85
+ records = model.where(id: ids)
86
+ executor = Admin::Base::ActionExecutor.new(resource_config, action, admin_suite_actor)
87
+ result = executor.execute_bulk(records, params.to_unsafe_h)
88
+
89
+ if result.success?
90
+ redirect_to collection_url, notice: result.message
91
+ else
92
+ redirect_to collection_url, alert: result.message
93
+ end
94
+ end
95
+
96
+ # POST /:portal/:resource_name/:id/toggle
97
+ def toggle
98
+ field = params[:field].presence&.to_sym
99
+ unless field
100
+ head :unprocessable_entity
101
+ return
102
+ end
103
+
104
+ unless toggleable_fields.include?(field)
105
+ head :unprocessable_entity
106
+ return
107
+ end
108
+
109
+ current_value = !!@resource.public_send(field)
110
+ @resource.update!(field => !current_value)
111
+
112
+ respond_to do |format|
113
+ format.turbo_stream do
114
+ render turbo_stream: turbo_stream.replace(
115
+ dom_id(@resource, :toggle),
116
+ partial: "admin_suite/shared/toggle_cell",
117
+ locals: { record: @resource, field: field, toggle_url: resource_toggle_path(portal: current_portal, resource_name: resource_name, id: @resource.to_param, field: field) }
118
+ )
119
+ end
120
+ format.html { redirect_to resource_url(@resource), notice: "#{resource_config.human_name} updated." }
121
+ end
122
+ end
123
+
124
+ private
125
+
126
+ def current_portal
127
+ params[:portal].to_s.presence&.to_sym
128
+ end
129
+
130
+ def resource_name
131
+ params[:resource_name].to_s
132
+ end
133
+
134
+ def resource_config
135
+ ensure_resources_loaded!
136
+ klass_name = resource_name.singularize.camelize
137
+ "Admin::Resources::#{klass_name}Resource".constantize
138
+ rescue NameError
139
+ nil
140
+ end
141
+
142
+ def resource_class
143
+ resource_config&.model_class || resource_name.classify.constantize
144
+ end
145
+
146
+ def set_resource
147
+ @resource = resource_class.find(params[:id])
148
+ rescue ActiveRecord::RecordNotFound
149
+ # Support "friendly" params (e.g. slugged records) without requiring host apps
150
+ # to change their model primary keys.
151
+ id = params[:id].to_s
152
+ columns = resource_class.column_names
153
+
154
+ @resource =
155
+ if columns.include?("slug")
156
+ resource_class.find_by!(slug: id)
157
+ elsif columns.include?("uuid")
158
+ resource_class.find_by!(uuid: id)
159
+ elsif columns.include?("token")
160
+ resource_class.find_by!(token: id)
161
+ else
162
+ raise
163
+ end
164
+ end
165
+
166
+ def resource
167
+ @resource
168
+ end
169
+
170
+ def collection
171
+ @collection
172
+ end
173
+
174
+ def filtered_collection
175
+ return resource_class.all unless resource_config&.index_config
176
+
177
+ Admin::Base::FilterBuilder.new(resource_config, params).apply(resource_class.all)
178
+ end
179
+
180
+ def paginate_collection(scope)
181
+ per_page = resource_config&.index_config&.per_page || 25
182
+ pagy(scope, items: per_page)
183
+ end
184
+
185
+ def calculate_stats
186
+ resource_config.index_config.stats_list.map do |stat_def|
187
+ value =
188
+ begin
189
+ stat_def.calculator.call
190
+ rescue StandardError
191
+ "N/A"
192
+ end
193
+ { name: stat_def.name.to_s.humanize, value: value, color: stat_def.color }
194
+ end
195
+ end
196
+
197
+ def find_action(name)
198
+ resource_config&.actions_config&.member_actions&.find { |a| a.name == name }
199
+ end
200
+
201
+ def resource_params
202
+ permitted_fields = []
203
+ array_fields = []
204
+
205
+ resource_config&.form_config&.fields_list&.each do |field|
206
+ next unless field.is_a?(Admin::Base::Resource::FieldDefinition)
207
+
208
+ if field.type == :tags || field.type == :multi_select
209
+ array_fields << { field.name => [] }
210
+ array_fields << { tag_list: [] } if field.type == :tags && field.name != :tag_list
211
+ else
212
+ permitted_fields << field.name
213
+ end
214
+ end
215
+
216
+ key = resource_class.model_name.param_key
217
+ params.require(key).permit(permitted_fields + array_fields)
218
+ end
219
+
220
+ def toggleable_fields
221
+ return [] unless resource_config&.index_config&.columns_list
222
+
223
+ resource_config.index_config.columns_list.filter_map do |col|
224
+ next unless col.type == :toggle
225
+
226
+ (col.toggle_field || col.name).to_sym
227
+ end
228
+ end
229
+
230
+ def resource_url(record)
231
+ resource_path(portal: current_portal, resource_name: resource_name, id: record.to_param)
232
+ end
233
+
234
+ def collection_url
235
+ resources_path(portal: current_portal, resource_name: resource_name)
236
+ end
237
+ end
238
+ end