admin_suite 0.4.0 → 0.5.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +134 -7
  3. data/app/assets/vendor/chart.umd.min.js +14 -0
  4. data/app/controllers/admin_suite/resources_controller.rb +130 -5
  5. data/app/helpers/admin_suite/base_helper.rb +187 -10
  6. data/app/javascript/admin_suite_application.js +3 -0
  7. data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
  8. data/app/views/admin_suite/panels/_chart.html.erb +158 -18
  9. data/app/views/admin_suite/resources/index.html.erb +24 -5
  10. data/app/views/admin_suite/shared/_sidebar.html.erb +14 -8
  11. data/app/views/layouts/admin_suite/application.html.erb +6 -0
  12. data/config/routes.rb +3 -0
  13. data/lib/admin/base/filter_builder.rb +48 -5
  14. data/lib/admin/base/resource.rb +43 -7
  15. data/lib/admin_suite/configuration.rb +10 -6
  16. data/lib/admin_suite/engine.rb +1 -1
  17. data/lib/admin_suite/legacy_custom_renderer_procs.rb +1 -1
  18. data/lib/admin_suite/renderer_registry.rb +11 -0
  19. data/lib/admin_suite/renderers/legacy_gleania.rb +7 -5
  20. data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
  21. data/lib/admin_suite/version.rb +1 -1
  22. data/lib/admin_suite.rb +13 -2
  23. data/test/integration/association_linking_test.rb +292 -0
  24. data/test/integration/chart_panel_test.rb +491 -0
  25. data/test/integration/dashboard_test.rb +9 -2
  26. data/test/integration/index_table_test.rb +289 -0
  27. data/test/integration/navigation_sections_test.rb +21 -0
  28. data/test/integration/searchable_select_search_test.rb +368 -0
  29. data/test/integration/show_hide_blank_test.rb +195 -0
  30. data/test/integration/toggle_test.rb +106 -0
  31. data/test/lib/definition_loader_test.rb +12 -0
  32. data/test/lib/form_field_renderer_test.rb +83 -11
  33. data/test/lib/format_table_cell_test.rb +49 -0
  34. data/test/lib/index_includes_test.rb +223 -0
  35. data/test/lib/legacy_renderer_deprecation_test.rb +8 -1
  36. data/test/lib/renderer_test.rb +23 -7
  37. data/test/lib/resource_exportable_deprecation_test.rb +10 -2
  38. data/test/test_helper.rb +47 -8
  39. metadata +35 -5
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Exercises `hide_blank:` on `panel`/`section` `fields:` rows. Mirrors the
6
+ # `ReadOnlyResourceFixtures::Widget` shape (test/integration/read_only_resource_test.rb)
7
+ # so the show route resolves through the ordinary numeric-id `klass.find` path,
8
+ # not the slug/uuid/token fallback.
9
+ module HideBlankFixtures
10
+ class Widget
11
+ extend ActiveModel::Naming
12
+
13
+ attr_reader :id
14
+
15
+ def initialize(id: 1)
16
+ @id = id
17
+ end
18
+
19
+ def self.all
20
+ [ new ]
21
+ end
22
+
23
+ def self.column_names
24
+ %w[id]
25
+ end
26
+
27
+ def self.primary_key
28
+ "id"
29
+ end
30
+
31
+ def self.columns_hash
32
+ { "id" => Struct.new(:type).new(:integer) }
33
+ end
34
+
35
+ def self.find(id)
36
+ raise ActiveRecord::RecordNotFound unless id.to_s == "1"
37
+
38
+ new
39
+ end
40
+
41
+ def to_param
42
+ id.to_s
43
+ end
44
+
45
+ def attributes
46
+ { "id" => id }
47
+ end
48
+
49
+ # Non-blank control value -- always kept, hide_blank or not.
50
+ def title
51
+ "Widget One"
52
+ end
53
+
54
+ def blank_string
55
+ ""
56
+ end
57
+
58
+ def whitespace_string
59
+ " "
60
+ end
61
+
62
+ def nil_field
63
+ nil
64
+ end
65
+
66
+ def empty_array
67
+ []
68
+ end
69
+
70
+ def empty_hash
71
+ {}
72
+ end
73
+
74
+ def false_flag
75
+ false
76
+ end
77
+
78
+ def zero_count
79
+ 0
80
+ end
81
+
82
+ def zero_float
83
+ 0.0
84
+ end
85
+ end
86
+ end
87
+
88
+ module Admin
89
+ module Resources
90
+ class HideBlankWidgetResource < Admin::Base::Resource
91
+ model HideBlankFixtures::Widget
92
+ portal :ops
93
+ section :observability
94
+
95
+ FIELDS = %i[
96
+ title blank_string whitespace_string nil_field
97
+ empty_array empty_hash false_flag zero_count zero_float
98
+ ].freeze
99
+
100
+ show do
101
+ sidebar do
102
+ panel :sidebar_hidden, title: "Sidebar hidden", fields: FIELDS, hide_blank: true
103
+ panel :sidebar_default, title: "Sidebar default", fields: FIELDS
104
+ end
105
+
106
+ section :main_hidden, title: "Main hidden", fields: FIELDS, hide_blank: true
107
+ section :main_default, title: "Main default", fields: FIELDS
108
+ end
109
+ end
110
+ end
111
+ end
112
+
113
+ module AdminSuite
114
+ class ShowHideBlankTest < ActionDispatch::IntegrationTest
115
+ PATH = "/internal/admin_suite/ops/hide_blank_widgets/1"
116
+
117
+ test "hide_blank: true hides nil, empty string, empty array, and empty hash" do
118
+ get PATH
119
+ assert_response :success
120
+
121
+ hidden_section = response.body[/Main hidden.*?(?=Main default)/m]
122
+ refute_nil hidden_section, "expected to find the 'Main hidden' panel before 'Main default' in the body"
123
+
124
+ refute_includes hidden_section, "Nil field"
125
+ refute_includes hidden_section, "Blank string"
126
+ refute_includes hidden_section, "Empty array"
127
+ refute_includes hidden_section, "Empty hash"
128
+ end
129
+
130
+ test "hide_blank: true keeps false, 0, 0.0, and whitespace-only strings" do
131
+ get PATH
132
+ assert_response :success
133
+
134
+ hidden_section = response.body[/Main hidden.*?(?=Main default)/m]
135
+ refute_nil hidden_section
136
+
137
+ assert_includes hidden_section, "False flag"
138
+ assert_includes hidden_section, "Zero count"
139
+ assert_includes hidden_section, "Zero float"
140
+ assert_includes hidden_section, "Whitespace string"
141
+ # And the boolean must still render as the meaningful "No" state, not
142
+ # silently vanish.
143
+ assert_includes hidden_section, "No"
144
+ end
145
+
146
+ test "hide_blank: true keeps genuinely non-blank fields" do
147
+ get PATH
148
+ assert_response :success
149
+
150
+ hidden_section = response.body[/Main hidden.*?(?=Main default)/m]
151
+ assert_includes hidden_section, "Title"
152
+ assert_includes hidden_section, "Widget One"
153
+ end
154
+
155
+ test "default (no hide_blank option) hides nothing -- every label row still renders" do
156
+ get PATH
157
+ assert_response :success
158
+
159
+ default_section = response.body[/Main default.*?(?=<\/html>)/m]
160
+ refute_nil default_section
161
+
162
+ %w[Title Blank\ string Whitespace\ string Nil\ field Empty\ array Empty\ hash False\ flag Zero\ count Zero\ float].each do |label|
163
+ assert_includes default_section, label, "expected label '#{label}' to render in the default (no hide_blank) panel"
164
+ end
165
+ end
166
+
167
+ test "hide_blank: true on a sidebar panel hides the same blank fields as main" do
168
+ get PATH
169
+ assert_response :success
170
+
171
+ sidebar_hidden = response.body[/Sidebar hidden.*?(?=Sidebar default)/m]
172
+ refute_nil sidebar_hidden
173
+
174
+ refute_includes sidebar_hidden, "Nil field"
175
+ refute_includes sidebar_hidden, "Blank string"
176
+ refute_includes sidebar_hidden, "Empty array"
177
+ refute_includes sidebar_hidden, "Empty hash"
178
+ assert_includes sidebar_hidden, "False flag"
179
+ assert_includes sidebar_hidden, "Zero count"
180
+ assert_includes sidebar_hidden, "Zero float"
181
+ end
182
+
183
+ test "default sidebar panel (no hide_blank) hides nothing" do
184
+ get PATH
185
+ assert_response :success
186
+
187
+ sidebar_default = response.body[/Sidebar default.*?(?=<div class="bg-white)/m] || response.body[/Sidebar default.*\z/m]
188
+ refute_nil sidebar_default
189
+
190
+ %w[Nil\ field Blank\ string Empty\ array Empty\ hash].each do |label|
191
+ assert_includes sidebar_default, label
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Final review Finding 3: the gem hard-depends on turbo-rails (`turbo_stream`
6
+ # in `ResourcesController#toggle`, `turbo_frame_tag` throughout the resource
7
+ # views) but never declared it in the gemspec. Before that dependency is
8
+ # declared, this test exercises the exact path the review flagged as broken
9
+ # in a turbo-less host: `format.turbo_stream` inside `respond_to` raises the
10
+ # moment it's evaluated, because the `:turbo_stream` MIME type is unregistered
11
+ # without turbo-rails loaded. See `test/test_helper.rb`'s (now removed)
12
+ # `TurboFrameTestHelper`, which only ever patched `turbo_frame_tag` -- it
13
+ # never made `turbo_stream`/the MIME type real, so this branch was untestable
14
+ # until the real dependency was declared.
15
+ module ToggleFixtures
16
+ class Widget
17
+ extend ActiveModel::Naming
18
+
19
+ attr_reader :id
20
+ attr_accessor :active
21
+
22
+ def initialize(id: 1, active: false)
23
+ @id = id
24
+ @active = active
25
+ end
26
+
27
+ def self.all
28
+ ReadOnlyResourceFixtures::Relation.new([ new ])
29
+ end
30
+
31
+ def self.column_names
32
+ %w[id active]
33
+ end
34
+
35
+ def self.primary_key
36
+ "id"
37
+ end
38
+
39
+ def self.columns_hash
40
+ { "id" => Struct.new(:type).new(:integer) }
41
+ end
42
+
43
+ def self.find(id)
44
+ return new(id: 1, active: false) if id.to_s == "1"
45
+
46
+ raise ActiveRecord::RecordNotFound
47
+ end
48
+
49
+ def to_param
50
+ id.to_s
51
+ end
52
+
53
+ # `dom_id` (via `ActionView::RecordIdentifier#record_key_for_dom_id`)
54
+ # calls `to_key` on the record -- `extend ActiveModel::Naming` alone
55
+ # only supplies `model_name`, not `to_key`.
56
+ def to_key
57
+ [ id ]
58
+ end
59
+
60
+ def update!(attrs)
61
+ attrs.each { |key, value| public_send("#{key}=", value) }
62
+ true
63
+ end
64
+ end
65
+ end
66
+
67
+ module Admin
68
+ module Resources
69
+ class ToggleWidgetResource < Admin::Base::Resource
70
+ model ToggleFixtures::Widget
71
+ portal :ops
72
+ section :observability
73
+
74
+ index do
75
+ columns do
76
+ column :active, type: :toggle
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ module AdminSuite
84
+ class ToggleTest < ActionDispatch::IntegrationTest
85
+ include ActionView::RecordIdentifier
86
+
87
+ BASE_PATH = "/internal/admin_suite/ops/toggle_widgets"
88
+
89
+ test "toggle's turbo_stream branch replaces the toggle cell with the flipped state" do
90
+ post "#{BASE_PATH}/1/toggle",
91
+ params: { field: "active" },
92
+ headers: { "Accept" => "text/vnd.turbo-stream.html" }
93
+
94
+ assert_response :success
95
+ assert_equal "text/vnd.turbo-stream.html", response.media_type
96
+
97
+ target = dom_id(ToggleFixtures::Widget.new(id: 1), "active_toggle")
98
+ assert_includes response.body, "<turbo-stream"
99
+ assert_includes response.body, %(action="replace")
100
+ assert_includes response.body, %(target="#{target}")
101
+ # The record started with `active: false`; the toggle flips it to
102
+ # `true` before rendering, so the replaced cell must reflect "on".
103
+ assert_includes response.body, "is-on"
104
+ end
105
+ end
106
+ end
@@ -41,6 +41,18 @@ module AdminSuite
41
41
  before.each_value { |definition| AdminSuite::PortalRegistry.register(definition) }
42
42
  end
43
43
  else
44
+ # :dashboards/:actions have no accumulating registry to snapshot --
45
+ # unlike :resources/:portals (which pick up real fixtures registered
46
+ # by *other* test files at load time), reset! for these two kinds
47
+ # only clears a single flag/definition object
48
+ # (`root_dashboard_loaded`/`root_dashboard_definition`,
49
+ # `handlers_loaded`) that is entirely local to whichever test set it,
50
+ # via its own `with_globs`/`with_dashboard`-style ensure block. So
51
+ # there is nothing cross-test left to preserve, and "restore" here
52
+ # deliberately degrades to a second `reset!` call rather than a real
53
+ # snapshot+restore -- do not read this branch as returning a
54
+ # value-preserving restore the way the :resources/:portals branches
55
+ # above do.
44
56
  -> { AdminSuite::DefinitionLoader.reset!(kind) }
45
57
  end
46
58
  end
@@ -6,31 +6,52 @@ module AdminSuite
6
6
  class FormFieldRendererTest < ActionView::TestCase
7
7
  include AdminSuite::BaseHelper
8
8
 
9
- Record = Struct.new(:name, :body, :enabled) do
9
+ Record = Struct.new(:name, :body, :enabled, :error_messages) do
10
+ # Both a class-level and an instance-level `model_name` are needed:
11
+ # `render_toggle_field`/`render_searchable_select`/`render_multi_select`
12
+ # all call `resource.class.model_name.param_key` (class-level), while
13
+ # `form_with` itself wants the instance-level accessor. (`extend
14
+ # ActiveModel::Naming` would give both via its own delegation, but
15
+ # then redefining `model_name` below to force the short "Record" name
16
+ # -- rather than the fully-qualified nested constant name -- clashes
17
+ # with that delegation and triggers a "method redefined" warning.
18
+ # Defining both explicitly avoids it.) The pre-existing instance-level
19
+ # `model_name` never covered the class-level call site because no test
20
+ # here exercised `:toggle`/`:searchable_select`/`:multi_select` before.
21
+ def self.model_name
22
+ @model_name ||= ActiveModel::Name.new(self, nil, "Record")
23
+ end
24
+
10
25
  def to_model = self
11
- def model_name = ActiveModel::Name.new(self.class, nil, "Record")
26
+ def model_name = self.class.model_name
12
27
  def persisted? = false
13
28
  def to_key = nil
14
29
 
15
30
  # render_form_field unconditionally calls `resource.errors[field.name]`
16
31
  # for every field type (to add the error border class / message), so
17
32
  # the double needs an `errors` object supporting `[]` -> Array-like
18
- # (`.any?`, `.first`). No test here exercises an actual validation
19
- # error, so a Hash defaulting to `[]` is sufficient.
20
- def errors = Hash.new([])
33
+ # (`.any?`, `.first`). Most tests here don't exercise an actual
34
+ # validation error, so a Hash defaulting to `[]` is sufficient; tests
35
+ # that do pass `error_messages` (a plain Hash of field name -> Array of
36
+ # messages) to get real per-field errors without losing the default.
37
+ def errors
38
+ Hash.new([]).merge(error_messages || {})
39
+ end
21
40
  end
22
41
 
23
- def field(name, type)
42
+ def field(name, type, **overrides)
24
43
  Admin::Base::Resource::FieldDefinition.new(
25
- name: name, type: type, required: false, label: name.to_s.humanize,
26
- readonly: false, multiple: false, creatable: false, preview: true
44
+ {
45
+ name: name, type: type, required: false, label: name.to_s.humanize,
46
+ readonly: false, multiple: false, creatable: false, preview: true
47
+ }.merge(overrides)
27
48
  )
28
49
  end
29
50
 
30
- def render_field(type, name: :name)
31
- record = Record.new("x", "y", true)
51
+ def render_field(type, name: :name, error_messages: nil, **field_overrides)
52
+ record = Record.new("x", "y", true, error_messages)
32
53
  html = nil
33
- form_with(model: record, url: "/", scope: :record) { |f| html = render_form_field(f, field(name, type), record) }
54
+ form_with(model: record, url: "/", scope: :record) { |f| html = render_form_field(f, field(name, type, **field_overrides), record) }
34
55
  html.to_s
35
56
  end
36
57
 
@@ -60,5 +81,56 @@ module AdminSuite
60
81
  test "the label is rendered for every field" do
61
82
  assert_includes render_field(:text), "Name"
62
83
  end
84
+
85
+ test "a field with a validation error renders the red border and error message" do
86
+ html = render_field(:text, error_messages: { name: [ "is invalid" ] })
87
+ assert_includes html, "border-red-500"
88
+ assert_includes html, "is invalid"
89
+ end
90
+
91
+ # The full set of field types `FieldRendererRegistry` is expected to
92
+ # have registered (see `lib/admin_suite/ui/field_renderer_registry.rb`).
93
+ # Pinned explicitly here, NOT derived from the registry itself: an
94
+ # earlier version of this test iterated `handlers.keys` directly, which
95
+ # cannot detect a deletion -- a deleted key simply stops appearing in
96
+ # the iteration, so the loop body never runs for it and the test still
97
+ # passes with one fewer assertion (verified by deleting the `:toggle`
98
+ # registration: 42 assertions became 41, 0 failures). The
99
+ # size-and-membership assertion in the test below is what actually
100
+ # catches that; a self-referential "iterate the thing you're testing"
101
+ # loop cannot.
102
+ EXPECTED_FIELD_TYPES = %i[
103
+ textarea url email number toggle label select searchable_select
104
+ dependent_select multi_select tags image attachment trix rich_text
105
+ markdown file datetime date time json code text string
106
+ ].freeze
107
+
108
+ # :trix/:rich_text both call `f.rich_text_area`, which only exists once
109
+ # ActionText's FormBuilder extension is loaded -- and per the plan's
110
+ # Test-harness fact #2, this dummy app is deliberately database-free and
111
+ # never requires `action_text/engine`. Confirmed by running the
112
+ # rendering test with them included: `NoMethodError: undefined method
113
+ # 'rich_text_area'`, not a bug in FieldRendererRegistry. There is no way
114
+ # to pin these two types in this harness without pulling in ActionText
115
+ # (and, transitively, ActiveRecord) — out of scope here. Named
116
+ # explicitly (not an implicit skip) so `EXPECTED_FIELD_TYPES.size` and
117
+ # the number of types actually exercised below both stay visible and
118
+ # add up: 24 expected, 2 excluded, 22 rendered.
119
+ UNTESTABLE_WITHOUT_ACTION_TEXT = %i[trix rich_text].freeze
120
+
121
+ test "the registry has exactly the expected set of field types, no more, no fewer" do
122
+ assert_equal EXPECTED_FIELD_TYPES.sort, AdminSuite::UI::FieldRendererRegistry.handlers.keys.sort
123
+ end
124
+
125
+ test "every expected field type except the ActionText-only ones renders non-empty markup" do
126
+ sample_collection = [ [ "One", 1 ], [ "Two", 2 ] ]
127
+ needs_collection = %i[select searchable_select dependent_select multi_select tags]
128
+
129
+ (EXPECTED_FIELD_TYPES - UNTESTABLE_WITHOUT_ACTION_TEXT).each do |type|
130
+ overrides = needs_collection.include?(type) ? { collection: sample_collection } : {}
131
+ html = render_field(type, **overrides)
132
+ assert html.present?, "expected #{type.inspect} to render non-empty markup"
133
+ end
134
+ end
63
135
  end
64
136
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module AdminSuite
6
+ # `format_table_cell` had no direct unit test anywhere in the suite before
7
+ # this file -- it was only exercised indirectly through
8
+ # `association_linking_test.rb`'s AR-focused fixtures. Covers the branches
9
+ # not already pinned there, and specifically the Integer/Float/BigDecimal
10
+ # delimiting added to match `format_show_value` (see
11
+ # `ShowValueFormatterTest#"integers and floats render delimited"`).
12
+ class FormatTableCellTest < ActionView::TestCase
13
+ include AdminSuite::BaseHelper
14
+
15
+ test "nil renders the em dash placeholder" do
16
+ assert_equal "—", format_table_cell(nil)
17
+ end
18
+
19
+ test "booleans render Yes/No" do
20
+ assert_equal "Yes", format_table_cell(true)
21
+ assert_equal "No", format_table_cell(false)
22
+ end
23
+
24
+ test "Time and DateTime render a short date-time" do
25
+ assert_equal "Jan 02, 03:04", format_table_cell(Time.utc(2026, 1, 2, 3, 4))
26
+ assert_equal "Jan 02, 03:04", format_table_cell(DateTime.new(2026, 1, 2, 3, 4))
27
+ end
28
+
29
+ test "Date renders a short date" do
30
+ assert_equal "Jan 02, 2026", format_table_cell(Date.new(2026, 1, 2))
31
+ end
32
+
33
+ test "integers and floats render delimited, matching format_show_value" do
34
+ assert_equal "1,234,567", format_table_cell(1_234_567)
35
+ assert_equal "1,234.5", format_table_cell(1234.5)
36
+ end
37
+
38
+ test "BigDecimal renders delimited rather than in exponential notation" do
39
+ result = format_table_cell(BigDecimal("9876.5"))
40
+ assert_includes result, "9,876"
41
+ refute_includes result, "0.98765e4"
42
+ end
43
+
44
+ test "plain strings are truncated at 50 characters" do
45
+ assert_equal "hello", format_table_cell("hello")
46
+ assert_equal ("x" * 47) + "...", format_table_cell("x" * 60)
47
+ end
48
+ end
49
+ end