admin_suite 0.2.8 → 0.2.9

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 663de764696e7dc2696e54b4bb43381fe83ee33b74b9d212269ff6d25433466b
4
- data.tar.gz: 9e9c6f145e86d4c8a0d1087cdc2d45c752da1fbbe9f1d4219d7b8874ebcc5291
3
+ metadata.gz: f50f5a24977b7bc8284ee051fb3d9d1746b49a1ab8d9ade00d917bf0a8ff412b
4
+ data.tar.gz: 6e32362f8fe763980b83fa34910b21020d7bdca7918afb5d63084aae7e80d72e
5
5
  SHA512:
6
- metadata.gz: b23ab5d74fe3658980016cb8e7df4c1fe09a87601590b98b09dd43f3ece53c3d40f6cbc159aec035daec52525b8dbdbdf0da34ad8846061465ec9de05a270ae4
7
- data.tar.gz: b70e3d18ec3aba0acd78fea6ee74efbec8201546253483abd00f1443e3411e37c9f98b065df6cf04f51ec3df28d9fa5e4ad90738781119f616c3c71db44f6130
6
+ metadata.gz: b980579e952dc4ec9cd1cd56dab61219f9320e810df0697a97ad42031e08fa59793c893adfdcd00e7d0ab7449468ae7d02a886f2dd6a3406c65bf5c750a28c3f
7
+ data.tar.gz: 603fdf2522093c9aba2427286e6573515a75d3016367dde07d3a74661bc7e15ffe95b1b388258c3398cc4d6583b85ef7c9ace18cc55e118b116344835802dfdc
data/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.9] - 2026-07-14
11
+
12
+ ### Added
13
+
14
+ - Read-only resource support, server-side filter defaults, and filtered-scope statistics.
15
+
10
16
  ## [0.2.8] - 2026-03-23
11
17
 
12
18
  ### Added
@@ -38,4 +44,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
38
44
  - Initial extraction of the AdminSuite Rails engine.
39
45
  - Resource/portal DSL, docs viewer, and theming primitives.
40
46
  - Isolated gem test suite with a dummy Rails app.
41
-
data/CONTRIBUTING.md CHANGED
@@ -12,7 +12,7 @@ Thank you for your interest in contributing to AdminSuite!
12
12
 
13
13
  ## Development
14
14
 
15
- See `docs/development.md` for detailed information on:
15
+ See `../_vault/products/admin_suite/docs/development.md` for detailed information on:
16
16
  - Setting up your development environment
17
17
  - Running tests
18
18
  - Code style guidelines
@@ -36,7 +36,7 @@ The CI workflow runs automatically on every pull request.
36
36
 
37
37
  ## Releasing
38
38
 
39
- See `docs/releasing.md` for information on how releases are managed.
39
+ See `../_vault/products/admin_suite/docs/releasing.md` for information on how releases are managed.
40
40
 
41
41
  Releases are automated via GitHub Actions when changes are merged to `main` with a version bump.
42
42
 
data/README.md CHANGED
@@ -15,21 +15,9 @@ across other products.
15
15
 
16
16
  ## Documentation
17
17
 
18
- Start here:
19
-
20
- - `docs/README.md` (index)
21
-
22
- Read more:
23
-
24
- - `docs/installation.md`
25
- - `docs/configuration.md`
26
- - `docs/portals.md`
27
- - `docs/resources.md`
28
- - `docs/fields.md`
29
- - `docs/actions.md`
30
- - `docs/theming.md`
31
- - `docs/docs_viewer.md`
32
- - `docs/troubleshooting.md`
18
+ Canonical AdminSuite documentation lives in the TechWright vault at
19
+ `../_vault/products/admin_suite/docs/`. This repo intentionally has no root
20
+ `docs/` tree or docs symlink.
33
21
 
34
22
  ## Quickstart
35
23
 
@@ -67,7 +55,7 @@ AdminSuite.configure do |config|
67
55
  end
68
56
  ```
69
57
 
70
- Read more: `docs/configuration.md`
58
+ Read more: `../_vault/products/admin_suite/docs/configuration.md`
71
59
 
72
60
  ### Add portals (navigation metadata)
73
61
 
@@ -80,7 +68,7 @@ AdminSuite.configure do |config|
80
68
  end
81
69
  ```
82
70
 
83
- Read more: `docs/portals.md`
71
+ Read more: `../_vault/products/admin_suite/docs/portals.md`
84
72
 
85
73
  ### Add a resource
86
74
 
@@ -119,25 +107,23 @@ module Admin
119
107
  end
120
108
  ```
121
109
 
122
- Read more: `docs/resources.md` and `docs/fields.md`
110
+ Read more: `../_vault/products/admin_suite/docs/resources.md` and `../_vault/products/admin_suite/docs/fields.md`
123
111
 
124
112
  ### Add docs (optional)
125
113
 
126
- Create markdown files in your host app:
127
-
128
- - `docs/*.md` (or set `config.docs_path`)
114
+ Set `config.docs_path` to an explicit documentation source. TechWright host apps point this at their canonical `_vault/products/<product>/docs/` directory; they do not create repo `docs/` trees.
129
115
 
130
116
  Then visit:
131
117
 
132
118
  - `/internal/admin/docs`
133
119
 
134
- Read more: `docs/docs_viewer.md`
120
+ Read more: `../_vault/products/admin_suite/docs/docs_viewer.md`
135
121
 
136
122
  ## Contributing
137
123
 
138
124
  See:
139
125
 
140
126
  - `CONTRIBUTING.md`
141
- - `docs/development.md`
142
- - `docs/releasing.md`
127
+ - `../_vault/products/admin_suite/docs/development.md`
128
+ - `../_vault/products/admin_suite/docs/releasing.md`
143
129
 
@@ -5,14 +5,16 @@ module AdminSuite
5
5
  include Pagy::Backend
6
6
  include Pagy::Frontend
7
7
 
8
+ before_action :enforce_read_only!, only: %i[new create edit update destroy]
8
9
  before_action :set_resource, if: -> { params[:id].present? && !%w[index new create].include?(action_name) }
9
10
 
10
11
  helper_method :resource_config, :resource_class, :resource, :collection, :current_portal, :resource_name
11
12
 
12
13
  # GET /:portal/:resource_name
13
14
  def index
14
- @stats = calculate_stats if resource_config&.index_config&.stats_list&.any?
15
- @pagy, @collection = paginate_collection(filtered_collection)
15
+ scope = filtered_collection
16
+ @stats = calculate_stats(scope) if resource_config&.index_config&.stats_list&.any?
17
+ @pagy, @collection = paginate_collection(scope)
16
18
  end
17
19
 
18
20
  # GET /:portal/:resource_name/:id
@@ -181,11 +183,15 @@ module AdminSuite
181
183
  pagy(scope, items: per_page)
182
184
  end
183
185
 
184
- def calculate_stats
186
+ def calculate_stats(scope)
185
187
  resource_config.index_config.stats_list.map do |stat_def|
186
188
  value =
187
189
  begin
188
- stat_def.calculator.call
190
+ if stat_def.calculator.arity.zero?
191
+ stat_def.calculator.call
192
+ else
193
+ stat_def.calculator.call(scope)
194
+ end
189
195
  rescue StandardError
190
196
  "N/A"
191
197
  end
@@ -193,6 +199,10 @@ module AdminSuite
193
199
  end
194
200
  end
195
201
 
202
+ def enforce_read_only!
203
+ head :not_found if resource_config&.read_only?
204
+ end
205
+
196
206
  def find_action(name)
197
207
  resource_config&.actions_config&.member_actions&.find { |a| a.name == name }
198
208
  end
@@ -1,7 +1,7 @@
1
1
  <% content_for :title, "#{resource_config.human_name_plural} - Admin Suite" %>
2
2
 
3
3
  <%
4
- has_new_route = begin
4
+ has_new_route = !resource_config.read_only? && begin
5
5
  url_for(action: :new)
6
6
  true
7
7
  rescue ActionController::UrlGenerationError
@@ -9,6 +9,8 @@
9
9
  end
10
10
 
11
11
  has_edit_route = ->(record) {
12
+ next false if resource_config.read_only?
13
+
12
14
  begin
13
15
  url_for(action: :edit, id: record.to_param)
14
16
  true
@@ -1,14 +1,14 @@
1
1
  <% content_for :title, "#{resource.respond_to?(:name) ? resource.name : "#{resource_config.human_name} ##{resource.id}"} - Admin Suite" %>
2
2
 
3
3
  <%
4
- has_edit_route = begin
4
+ has_edit_route = !resource_config.read_only? && begin
5
5
  url_for(action: :edit, id: resource.to_param)
6
6
  true
7
7
  rescue ActionController::UrlGenerationError
8
8
  false
9
9
  end
10
10
 
11
- has_destroy_route = begin
11
+ has_destroy_route = !resource_config.read_only? && begin
12
12
  url_for(action: :destroy, id: resource.to_param)
13
13
  true
14
14
  rescue ActionController::UrlGenerationError
@@ -57,7 +57,8 @@ module Admin
57
57
  # They are handled elsewhere (`apply_sort`) and must not be turned into SQL.
58
58
  return scope if %i[sort sort_direction direction page search].include?(filter.name.to_sym)
59
59
 
60
- value = params[filter.name]
60
+ value = params[filter.name].presence
61
+ value = filter.default if value.blank? && filter.respond_to?(:default)
61
62
  return scope if value.blank?
62
63
 
63
64
  if filter.respond_to?(:apply) && filter.apply.present?
@@ -165,6 +165,17 @@ module Admin
165
165
  @export_formats = formats
166
166
  end
167
167
 
168
+ # Declares whether the generic resource routes may mutate records.
169
+ # Read-only resources retain index/show access while the controller
170
+ # rejects direct requests to every built-in mutation endpoint.
171
+ def read_only(value = true)
172
+ @read_only = value
173
+ end
174
+
175
+ def read_only?
176
+ @read_only == true
177
+ end
178
+
168
179
  # Returns the resource name derived from class name
169
180
  #
170
181
  # @return [String]
@@ -328,12 +339,13 @@ module Admin
328
339
  placeholder: options[:placeholder],
329
340
  options: select_options,
330
341
  field: options[:field] || name,
331
- apply: options[:apply]
342
+ apply: options[:apply],
343
+ default: options[:default]
332
344
  )
333
345
  end
334
346
  end
335
347
 
336
- FilterDefinition = Struct.new(:name, :type, :label, :placeholder, :options, :field, :apply, keyword_init: true)
348
+ FilterDefinition = Struct.new(:name, :type, :label, :placeholder, :options, :field, :apply, :default, keyword_init: true)
337
349
 
338
350
  class StatsBuilder
339
351
  attr_reader :stats
@@ -2,7 +2,7 @@
2
2
 
3
3
  module AdminSuite
4
4
  module Version
5
- VERSION = "0.2.8"
5
+ VERSION = "0.2.9"
6
6
  end
7
7
 
8
8
  # Backward-compatible constant.
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module AdminSuite
6
+ class ResourcesControllerTest < ActiveSupport::TestCase
7
+ class TestController < ResourcesController
8
+ attr_writer :test_resource_config
9
+ attr_reader :filter_calls, :paginated_scope
10
+
11
+ def initialize
12
+ super
13
+ @filter_calls = 0
14
+ end
15
+
16
+ private
17
+
18
+ def resource_config
19
+ @test_resource_config
20
+ end
21
+
22
+ def filtered_collection
23
+ @filter_calls += 1
24
+ { total: 37 }
25
+ end
26
+
27
+ def paginate_collection(scope)
28
+ @paginated_scope = scope
29
+ [ Object.new, :paginated ]
30
+ end
31
+ end
32
+
33
+ class StatsResource < Admin::Base::Resource
34
+ index do
35
+ stats do
36
+ stat :legacy, -> { 11 }
37
+ stat :filtered, ->(scope) { scope.fetch(:total) }
38
+ end
39
+ end
40
+ end
41
+
42
+ class BrokenStatsResource < Admin::Base::Resource
43
+ index do
44
+ stats do
45
+ stat :broken, ->(_scope) { raise "boom" }
46
+ end
47
+ end
48
+ end
49
+
50
+ test "stats preserve zero arity calculators and pass the filtered scope to one arity calculators" do
51
+ controller = TestController.new
52
+ controller.test_resource_config = StatsResource
53
+ scope = { total: 37 }
54
+
55
+ stats = controller.send(:calculate_stats, scope)
56
+
57
+ assert_equal 11, stats.first[:value]
58
+ assert_equal 37, stats.second[:value]
59
+ end
60
+
61
+ test "stats preserve the existing calculator rescue behavior" do
62
+ controller = TestController.new
63
+ controller.test_resource_config = BrokenStatsResource
64
+
65
+ assert_equal "N/A", controller.send(:calculate_stats, Object.new).first[:value]
66
+ end
67
+
68
+ test "index reuses one filtered unpaginated scope for stats and pagination" do
69
+ controller = TestController.new
70
+ controller.test_resource_config = StatsResource
71
+
72
+ controller.index
73
+
74
+ assert_equal 1, controller.filter_calls
75
+ assert_equal({ total: 37 }, controller.paginated_scope)
76
+ assert_equal 37, controller.instance_variable_get(:@stats).second[:value]
77
+ assert_equal :paginated, controller.instance_variable_get(:@collection)
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # The dummy app is intentionally database-free, while the generic controller
6
+ # supports Active Record hosts. Supply only the exception type its lookup path
7
+ # rescues so show-page behavior can be exercised with an in-memory fixture.
8
+ unless defined?(ActiveRecord::RecordNotFound)
9
+ module ActiveRecord
10
+ class RecordNotFound < StandardError; end
11
+ end
12
+ end
13
+
14
+ module TurboFrameTestHelper
15
+ def turbo_frame_tag(name, **options, &block)
16
+ content_tag(:turbo_frame, capture(&block), id: name, **options)
17
+ end
18
+ end
19
+
20
+ ActionView::Base.include(TurboFrameTestHelper)
21
+
22
+ module ReadOnlyResourceFixtures
23
+ class Relation
24
+ include Enumerable
25
+
26
+ def initialize(records)
27
+ @records = records
28
+ end
29
+
30
+ def each(&block)
31
+ @records.each(&block)
32
+ end
33
+
34
+ def count(*)
35
+ @records.count
36
+ end
37
+
38
+ def offset(*)
39
+ self
40
+ end
41
+
42
+ def limit(*)
43
+ self
44
+ end
45
+ end
46
+
47
+ class Widget
48
+ extend ActiveModel::Naming
49
+
50
+ attr_reader :id, :name
51
+
52
+ def initialize(id: 1, name: "Observed widget")
53
+ @id = id
54
+ @name = name
55
+ end
56
+
57
+ def self.all
58
+ Relation.new([ new ])
59
+ end
60
+
61
+ def self.column_names
62
+ %w[id name]
63
+ end
64
+
65
+ def self.primary_key
66
+ "id"
67
+ end
68
+
69
+ def self.columns_hash
70
+ { "id" => Struct.new(:type).new(:integer) }
71
+ end
72
+
73
+ def self.find(id)
74
+ raise ActiveRecord::RecordNotFound unless id.to_s == "1"
75
+
76
+ new
77
+ end
78
+
79
+ def to_param
80
+ id.to_s
81
+ end
82
+
83
+ def attributes
84
+ { "id" => id, "name" => name }
85
+ end
86
+ end
87
+ end
88
+
89
+ module Admin
90
+ module Resources
91
+ class ReadOnlyWidgetResource < Admin::Base::Resource
92
+ model ReadOnlyResourceFixtures::Widget
93
+ portal :ops
94
+ section :observability
95
+ read_only
96
+
97
+ index do
98
+ columns do
99
+ column :name
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ module AdminSuite
107
+ class ReadOnlyResourceTest < ActionDispatch::IntegrationTest
108
+ BASE_PATH = "/internal/admin_suite/ops/read_only_widgets"
109
+
110
+ test "direct built in mutation endpoints are rejected" do
111
+ get "#{BASE_PATH}/new"
112
+ assert_response :not_found
113
+
114
+ post BASE_PATH, params: { read_only_resource_fixtures_widget: { name: "changed" } }
115
+ assert_response :not_found
116
+
117
+ get "#{BASE_PATH}/1/edit"
118
+ assert_response :not_found
119
+
120
+ patch "#{BASE_PATH}/1", params: { read_only_resource_fixtures_widget: { name: "changed" } }
121
+ assert_response :not_found
122
+
123
+ delete "#{BASE_PATH}/1"
124
+ assert_response :not_found
125
+ end
126
+
127
+ test "index hides create and edit controls" do
128
+ get BASE_PATH
129
+
130
+ assert_response :success
131
+ assert_includes response.body, "Observed widget"
132
+ refute_includes response.body, "New Widget"
133
+ refute_match(/>\s*Edit\s*</, response.body)
134
+ end
135
+
136
+ test "show mutation controls are conditional on write access" do
137
+ template = AdminSuite::Engine.root.join("app/views/admin_suite/resources/show.html.erb").read
138
+
139
+ assert_includes template, "has_edit_route = !resource_config.read_only?"
140
+ assert_includes template, "has_destroy_route = !resource_config.read_only?"
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module AdminSuite
6
+ class ResourceObservabilityExtensionsTest < ActiveSupport::TestCase
7
+ FakeScope = Struct.new(:filters) do
8
+ def where(*arguments)
9
+ self.class.new(filters + [ arguments ])
10
+ end
11
+ end
12
+
13
+ class FilteredResource < Admin::Base::Resource
14
+ index do
15
+ filters do
16
+ filter :window, type: :select, default: "24h",
17
+ apply: ->(scope, value) { scope.where(window: value) }
18
+ filter :status, type: :select
19
+ end
20
+ end
21
+ end
22
+
23
+ class ReadOnlyResource < Admin::Base::Resource
24
+ read_only
25
+ end
26
+
27
+ test "resources are writable by default and may be declared read only" do
28
+ refute Admin::Base::Resource.read_only?
29
+ assert ReadOnlyResource.read_only?
30
+ end
31
+
32
+ test "filter defaults apply when the parameter is absent" do
33
+ scope = Admin::Base::FilterBuilder.new(FilteredResource, ActionController::Parameters.new)
34
+ .apply(FakeScope.new([]))
35
+
36
+ assert_equal [ [ { window: "24h" } ] ], scope.filters
37
+ end
38
+
39
+ test "filter defaults apply when the parameter is blank and compose with explicit filters" do
40
+ params = ActionController::Parameters.new(window: "", status: "failed")
41
+ scope = Admin::Base::FilterBuilder.new(FilteredResource, params).apply(FakeScope.new([]))
42
+
43
+ assert_equal [ [ { window: "24h" } ], [ { status: "failed" } ] ], scope.filters
44
+ end
45
+
46
+ test "an explicit filter overrides its default" do
47
+ params = ActionController::Parameters.new(window: "7d")
48
+ scope = Admin::Base::FilterBuilder.new(FilteredResource, params).apply(FakeScope.new([]))
49
+
50
+ assert_equal [ [ { window: "7d" } ] ], scope.filters
51
+ end
52
+ end
53
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: admin_suite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.8
4
+ version: 0.2.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - TechWright Labs
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-23 00:00:00.000000000 Z
11
+ date: 2026-07-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -173,18 +173,6 @@ files:
173
173
  - app/views/layouts/admin_suite/application.html.erb
174
174
  - config/importmap.rb
175
175
  - config/routes.rb
176
- - docs/README.md
177
- - docs/actions.md
178
- - docs/configuration.md
179
- - docs/development.md
180
- - docs/docs_viewer.md
181
- - docs/fields.md
182
- - docs/installation.md
183
- - docs/portals.md
184
- - docs/releasing.md
185
- - docs/resources.md
186
- - docs/theming.md
187
- - docs/troubleshooting.md
188
176
  - lib/admin/base/action_executor.rb
189
177
  - lib/admin/base/action_handler.rb
190
178
  - lib/admin/base/filter_builder.rb
@@ -209,6 +197,7 @@ files:
209
197
  - lib/generators/admin_suite/scaffold/scaffold_generator.rb
210
198
  - lib/tasks/admin_suite_tailwind.rake
211
199
  - lib/tasks/admin_suite_test.rake
200
+ - test/controllers/resources_controller_test.rb
212
201
  - test/dummy/Gemfile
213
202
  - test/dummy/README.md
214
203
  - test/dummy/Rakefile
@@ -253,9 +242,11 @@ files:
253
242
  - test/fixtures/docs/progress/PROGRESS_REPORT.md
254
243
  - test/integration/dashboard_test.rb
255
244
  - test/integration/docs_test.rb
245
+ - test/integration/read_only_resource_test.rb
256
246
  - test/integration/theme_test.rb
257
247
  - test/lib/action_executor_test.rb
258
248
  - test/lib/markdown_renderer_test.rb
249
+ - test/lib/resource_observability_extensions_test.rb
259
250
  - test/lib/theme_palette_test.rb
260
251
  - test/lib/zeitwerk_integration_test.rb
261
252
  - test/test_helper.rb
data/docs/README.md DELETED
@@ -1,26 +0,0 @@
1
- # AdminSuite Documentation
2
-
3
- AdminSuite is a mountable Rails engine that provides:
4
-
5
- - A **resource DSL** for CRUD + search/sort/filter + show/form configuration
6
- - A **portal system** (navigation + optional portal dashboards)
7
- - A built-in **docs viewer** (renders Markdown from your host app filesystem)
8
- - A small baseline **UI layer** (Tailwind optional)
9
-
10
- ## Getting started
11
-
12
- - [Installation](installation.md)
13
- - [Configuration](configuration.md)
14
- - [Portals & dashboards](portals.md)
15
- - [Resources](resources.md)
16
- - [Fields](fields.md)
17
- - [Actions](actions.md)
18
- - [Theming & assets](theming.md)
19
- - [Docs viewer](docs_viewer.md)
20
- - [Troubleshooting](troubleshooting.md)
21
-
22
- ## Contributing / maintainers
23
-
24
- - [Development](development.md)
25
- - [Releasing](releasing.md)
26
-