admin_suite 0.3.1 → 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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +210 -1
  3. data/app/assets/vendor/chart.umd.min.js +14 -0
  4. data/app/assets/vendor/easymde.min.css +7 -0
  5. data/app/assets/vendor/easymde.min.js +7 -0
  6. data/app/controllers/admin_suite/application_controller.rb +26 -47
  7. data/app/controllers/admin_suite/portals_controller.rb +2 -2
  8. data/app/controllers/admin_suite/resources_controller.rb +138 -6
  9. data/app/helpers/admin_suite/base_helper.rb +232 -391
  10. data/app/javascript/admin_suite_application.js +3 -0
  11. data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
  12. data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +21 -2
  13. data/app/views/admin_suite/panels/_chart.html.erb +158 -18
  14. data/app/views/admin_suite/panels/_stat.html.erb +10 -0
  15. data/app/views/admin_suite/resources/index.html.erb +31 -82
  16. data/app/views/admin_suite/shared/_pagination.html.erb +74 -0
  17. data/app/views/admin_suite/shared/_sidebar.html.erb +15 -9
  18. data/app/views/layouts/admin_suite/application.html.erb +11 -3
  19. data/config/routes.rb +3 -0
  20. data/lib/admin/base/action_executor.rb +19 -51
  21. data/lib/admin/base/filter_builder.rb +48 -5
  22. data/lib/admin/base/resource.rb +92 -17
  23. data/lib/admin_suite/configuration.rb +32 -3
  24. data/lib/admin_suite/definition_loader.rb +194 -0
  25. data/lib/admin_suite/deprecation.rb +48 -0
  26. data/lib/admin_suite/engine.rb +55 -76
  27. data/lib/admin_suite/host_autoload_policy.rb +132 -0
  28. data/lib/admin_suite/legacy_custom_renderer_procs.rb +29 -0
  29. data/lib/admin_suite/portal_definition.rb +11 -0
  30. data/lib/admin_suite/renderer.rb +133 -0
  31. data/lib/admin_suite/renderer_registry.rb +81 -0
  32. data/lib/admin_suite/renderers/code_renderer.rb +15 -0
  33. data/lib/admin_suite/renderers/json_renderer.rb +15 -0
  34. data/lib/admin_suite/renderers/key_value_renderer.rb +39 -0
  35. data/lib/admin_suite/renderers/legacy_gleania.rb +232 -0
  36. data/lib/admin_suite/renderers/table_from_renderer.rb +22 -0
  37. data/lib/admin_suite/section_definition.rb +42 -0
  38. data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
  39. data/lib/admin_suite/ui/field_renderer_registry.rb +31 -4
  40. data/lib/admin_suite/ui/form_field_renderer.rb +1 -7
  41. data/lib/admin_suite/ui/show_formatter_registry.rb +9 -0
  42. data/lib/admin_suite/ui/show_value_formatter.rb +7 -3
  43. data/lib/admin_suite/version.rb +1 -1
  44. data/lib/admin_suite.rb +48 -0
  45. data/lib/generators/admin_suite/install/templates/admin_suite.rb +0 -4
  46. data/test/controllers/resources_controller_test.rb +76 -1
  47. data/test/integration/association_linking_test.rb +292 -0
  48. data/test/integration/chart_panel_test.rb +491 -0
  49. data/test/integration/dashboard_test.rb +9 -2
  50. data/test/integration/index_table_test.rb +289 -0
  51. data/test/integration/layout_assets_test.rb +112 -0
  52. data/test/integration/navigation_sections_test.rb +62 -0
  53. data/test/integration/pagination_and_stats_test.rb +152 -0
  54. data/test/integration/searchable_select_search_test.rb +368 -0
  55. data/test/integration/show_hide_blank_test.rb +195 -0
  56. data/test/integration/toggle_test.rb +106 -0
  57. data/test/lib/action_executor_redirect_test.rb +41 -0
  58. data/test/lib/builtin_renderers_test.rb +202 -0
  59. data/test/lib/definition_loader_test.rb +276 -0
  60. data/test/lib/engine_defaults_test.rb +39 -0
  61. data/test/lib/form_field_renderer_test.rb +136 -0
  62. data/test/lib/format_table_cell_test.rb +49 -0
  63. data/test/lib/index_includes_test.rb +223 -0
  64. data/test/lib/legacy_renderer_deprecation_test.rb +42 -0
  65. data/test/lib/renderer_test.rb +237 -0
  66. data/test/lib/resource_exportable_deprecation_test.rb +47 -0
  67. data/test/lib/show_value_formatter_test.rb +88 -0
  68. data/test/lib/zeitwerk_integration_test.rb +28 -64
  69. data/test/test_helper.rb +121 -5
  70. metadata +62 -5
@@ -42,3 +42,6 @@ application.register("admin-suite--flash", FlashController)
42
42
 
43
43
  import DependentSearchableSelectController from "controllers/admin_suite/dependent_searchable_select_controller"
44
44
  application.register("admin-suite--dependent-searchable-select", DependentSearchableSelectController)
45
+
46
+ import ChartController from "controllers/admin_suite/chart_controller"
47
+ application.register("admin-suite--chart", ChartController)
@@ -0,0 +1,173 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Chart Controller (Admin Suite)
5
+ *
6
+ * Upgrades a server-rendered CSS bar chart into a real Chart.js canvas.
7
+ * Chart.js is loaded from the engine's vendored asset (see app/assets/vendor)
8
+ * only on pages that render a chart panel with data.
9
+ *
10
+ * The server-rendered bar markup (this.element's existing children) is the
11
+ * no-JS degraded state. It stays on the page and visible until Chart.js is
12
+ * confirmed available and a canvas has actually been drawn — if Chart.js
13
+ * never loads, the bars are simply never hidden.
14
+ */
15
+
16
+ // Bounded retry: ~50 attempts at 100ms each (~5s) before giving up. Guards
17
+ // against an unbounded poll if the Chart.js asset fails to load (e.g. blocked
18
+ // by a CSP, 404, or a page that never actually included it).
19
+ const MAX_INIT_ATTEMPTS = 50
20
+ const INIT_RETRY_DELAY_MS = 100
21
+
22
+ const COLOR_HEX = {
23
+ amber: "#f59e0b",
24
+ green: "#22c55e",
25
+ red: "#ef4444",
26
+ cyan: "#06b6d4",
27
+ violet: "#8b5cf6",
28
+ indigo: "#6366f1",
29
+ slate: "#64748b",
30
+ }
31
+
32
+ const DOUGHNUT_PALETTE = [
33
+ "#6366f1", "#f59e0b", "#22c55e", "#ef4444",
34
+ "#06b6d4", "#8b5cf6", "#64748b", "#ec4899",
35
+ ]
36
+
37
+ // Mirrors the ERB partial's own `chart_types` allowlist (see
38
+ // _chart.html.erb). The ERB already normalizes an unknown `type:` down to
39
+ // "bar" before it reaches the DOM, so this is a defensive second check, not
40
+ // the primary one — it only matters if something other than the shipped
41
+ // partial writes the type-value attribute (e.g. a host's `panel_chart`
42
+ // override that skips the ERB's own validation).
43
+ const KNOWN_TYPES = [ "bar", "line", "area", "doughnut" ]
44
+
45
+ // Fallback height (px) if the server didn't send a height value for some
46
+ // reason. Kept in sync with the ERB partial's own default.
47
+ const DEFAULT_HEIGHT_PX = 192
48
+
49
+ export default class extends Controller {
50
+ static values = {
51
+ series: Array,
52
+ type: String,
53
+ color: String,
54
+ height: Number,
55
+ }
56
+
57
+ connect() {
58
+ this.initAttempts = 0
59
+ this.chart = null
60
+ this.canvasWrapper = null
61
+ this.initChart()
62
+ }
63
+
64
+ initChart() {
65
+ if (typeof window.Chart === "undefined") {
66
+ this.initAttempts += 1
67
+ if (this.initAttempts >= MAX_INIT_ATTEMPTS) {
68
+ console.warn(
69
+ "admin-suite--chart: Chart.js failed to load after " +
70
+ MAX_INIT_ATTEMPTS +
71
+ " attempts; leaving the server-rendered bar chart in place."
72
+ )
73
+ return
74
+ }
75
+
76
+ this.retryTimeout = setTimeout(() => this.initChart(), INIT_RETRY_DELAY_MS)
77
+ return
78
+ }
79
+
80
+ // Idempotence guard: never build a second chart on top of an existing one.
81
+ if (this.chart) return
82
+
83
+ const series = this.hasSeriesValue ? this.seriesValue : []
84
+ if (series.length === 0) return
85
+
86
+ let type = this.hasTypeValue && this.typeValue ? this.typeValue : "bar"
87
+ if (!KNOWN_TYPES.includes(type)) {
88
+ console.warn(
89
+ "admin-suite--chart: unknown chart type " + JSON.stringify(type) + "; falling back to \"bar\"."
90
+ )
91
+ type = "bar"
92
+ }
93
+ const color = COLOR_HEX[this.colorValue] || COLOR_HEX.indigo
94
+ const height = this.hasHeightValue && this.heightValue > 0 ? this.heightValue : DEFAULT_HEIGHT_PX
95
+
96
+ // Chart.js's `responsive: true` / `maintainAspectRatio: false` combo
97
+ // requires the canvas's parent to have an explicit, non-content-derived
98
+ // height — otherwise there is no reference box to size against, and the
99
+ // canvas can render at 0px or grow unboundedly. The wrapper's height
100
+ // matches the server-rendered bars' height exactly (same data value), so
101
+ // there's no layout shift when the canvas replaces them.
102
+ const wrapper = document.createElement("div")
103
+ wrapper.style.position = "relative"
104
+ wrapper.style.height = `${height}px`
105
+ wrapper.style.width = "100%"
106
+
107
+ const canvas = document.createElement("canvas")
108
+ canvas.setAttribute("role", "img")
109
+ canvas.setAttribute("aria-label", "Chart")
110
+ wrapper.appendChild(canvas)
111
+
112
+ // Attach to the document before constructing the Chart so Chart.js can
113
+ // actually measure the wrapper's box on first render.
114
+ this.element.appendChild(wrapper)
115
+
116
+ const labels = series.map((row) => row.label)
117
+ const values = series.map((row) => row.value)
118
+
119
+ const chartType = type === "area" ? "line" : type
120
+ const isDoughnut = chartType === "doughnut"
121
+
122
+ const dataset = {
123
+ data: values,
124
+ backgroundColor: isDoughnut ? DOUGHNUT_PALETTE : color,
125
+ borderColor: isDoughnut ? DOUGHNUT_PALETTE : color,
126
+ fill: type === "area",
127
+ tension: 0.3,
128
+ }
129
+
130
+ // Only hide the degraded bar markup once Chart.js has actually accepted
131
+ // the config and rendered — this is what keeps the CSS bars visible as
132
+ // the degraded state on any earlier failure path.
133
+ this.chart = new window.Chart(canvas.getContext("2d"), {
134
+ type: chartType,
135
+ data: { labels, datasets: [ dataset ] },
136
+ options: {
137
+ responsive: true,
138
+ maintainAspectRatio: false,
139
+ plugins: { legend: { display: isDoughnut } },
140
+ scales: isDoughnut ? {} : { y: { beginAtZero: true } },
141
+ },
142
+ })
143
+
144
+ this.canvasWrapper = wrapper
145
+
146
+ // Hide (not remove) the server-rendered bar markup so it can be restored
147
+ // by simply removing the canvas wrapper if the controller disconnects.
148
+ Array.from(this.element.children).forEach((child) => {
149
+ if (child !== wrapper) child.style.display = "none"
150
+ })
151
+ }
152
+
153
+ disconnect() {
154
+ if (this.retryTimeout) {
155
+ clearTimeout(this.retryTimeout)
156
+ this.retryTimeout = null
157
+ }
158
+
159
+ if (this.chart) {
160
+ this.chart.destroy()
161
+ this.chart = null
162
+ }
163
+
164
+ if (this.canvasWrapper) {
165
+ this.canvasWrapper.remove()
166
+ this.canvasWrapper = null
167
+ }
168
+
169
+ Array.from(this.element.children).forEach((child) => {
170
+ child.style.display = ""
171
+ })
172
+ }
173
+ }
@@ -4,18 +4,37 @@ import { Controller } from "@hotwired/stimulus"
4
4
  * Markdown Editor Controller (Admin Suite)
5
5
  *
6
6
  * Initializes EasyMDE on a textarea element for rich markdown editing.
7
- * EasyMDE is loaded globally via script tag in the admin layout.
7
+ * EasyMDE is loaded from the engine's vendored asset (see app/assets/vendor)
8
+ * only on pages that render a markdown field.
8
9
  */
10
+
11
+ // Bounded retry: ~50 attempts at 100ms each (~5s) before giving up. Guards
12
+ // against an unbounded poll if the EasyMDE asset fails to load (e.g. blocked
13
+ // by a CSP, 404, or a page that never actually included it).
14
+ const MAX_INIT_ATTEMPTS = 50
15
+ const INIT_RETRY_DELAY_MS = 100
16
+
9
17
  export default class extends Controller {
10
18
  static targets = ["textarea"]
11
19
 
12
20
  connect() {
21
+ this.initAttempts = 0
13
22
  this.initEditor()
14
23
  }
15
24
 
16
25
  initEditor() {
17
26
  if (typeof window.EasyMDE === "undefined") {
18
- setTimeout(() => this.initEditor(), 100)
27
+ this.initAttempts += 1
28
+ if (this.initAttempts >= MAX_INIT_ATTEMPTS) {
29
+ console.warn(
30
+ "admin-suite--markdown-editor: EasyMDE failed to load after " +
31
+ MAX_INIT_ATTEMPTS +
32
+ " attempts; falling back to a plain textarea."
33
+ )
34
+ return
35
+ }
36
+
37
+ setTimeout(() => this.initEditor(), INIT_RETRY_DELAY_MS)
19
38
  return
20
39
  }
21
40
 
@@ -1,7 +1,102 @@
1
- <% data = Array(panel_eval(panel.options[:data])) %>
1
+ <%
2
+ raw_data =
3
+ begin
4
+ Array(panel_eval(panel.options[:data]))
5
+ rescue StandardError => e
6
+ Rails.logger&.warn(
7
+ "AdminSuite: chart panel #{panel.title.inspect} data raised #{e.class}: #{e.message}\n" \
8
+ "#{e.backtrace&.first(5)&.join("\n")}"
9
+ )
10
+ []
11
+ end
12
+ # Accept String-keyed rows (JSONB, API payloads) the same way data_table does
13
+ # (see lib/admin_suite/renderer.rb#data_table), and skip any row that isn't
14
+ # Hash-like: a data proc can *succeed* and still hand back a junk row (e.g.
15
+ # a bare Integer or nil mixed into the array), and indexing that with
16
+ # `row[:value]` raises rather than returning nil, so it must be filtered
17
+ # before it reaches the height math below.
18
+ data = raw_data.filter_map do |row|
19
+ row = row.symbolize_keys if row.respond_to?(:symbolize_keys)
20
+ next unless row.is_a?(Hash)
21
+
22
+ raw_value = row[:value]
23
+ # Total coercion: `Float()` (unlike `#to_f`) raises on Boolean/Hash/Array/
24
+ # nil/non-numeric-String values instead of silently returning 0 or
25
+ # blowing up later, so a junk value degrades to 0 here rather than
26
+ # 500ing the dashboard when the height math below reaches it.
27
+ numeric = begin
28
+ Float(raw_value)
29
+ rescue ArgumentError, TypeError
30
+ 0.0
31
+ end
32
+
33
+ # `value` stays as originally provided (e.g. an Integer or a numeric
34
+ # String) so titles/labels still read "Mon: 3" rather than "Mon: 3.0" —
35
+ # `numeric` is the only thing the height math and the JSON series handed
36
+ # to Chart.js should ever touch.
37
+ { label: row[:label], value: raw_value, numeric: numeric }
38
+ end
39
+ %>
2
40
  <% total = panel.options[:total] %>
3
- <% color = (panel.options[:color] || theme_primary).to_sym %>
4
- <% max_value = (data.map { |d| d[:value].to_f }.max || 1).to_f %>
41
+ <%
42
+ # Total coercion, same idiom as `type:` below: `.to_sym` alone raises for
43
+ # any `color:` value that doesn't implement it (an Integer, Boolean, ...) --
44
+ # `NoMethodError` 500ing the whole dashboard. `.to_s` first makes it total;
45
+ # an unrecognized result (e.g. `color: 42` -> `:"42"`) already falls through
46
+ # to indigo in both the `case` below and the JS `COLOR_HEX` map, so no new
47
+ # fallback logic is needed here.
48
+ color = (panel.options[:color] || theme_primary).to_s.presence&.to_sym
49
+ %>
50
+ <%
51
+ # `type:` is validated here, not just passed through, because it flows to
52
+ # both the Chart.js config (an unrecognized string throws inside Chart.js)
53
+ # and the degraded ERB branch below — a host typo (e.g. `type: :pie`, which
54
+ # Chart.js itself doesn't even ship) must degrade to the bar chart, not
55
+ # break the dashboard or silently render nothing.
56
+ chart_types = %i[bar line area doughnut]
57
+ # `.to_s` first: `presence&.to_sym` alone raises on any non-String/Symbol
58
+ # scalar (an Integer, Float, or `true`/`false` don't define `#to_sym`),
59
+ # which is exactly the host-typo shape this fallback exists to catch.
60
+ # `.to_s` makes the coercion total -- `42 → "42" → :"42"` (fails the
61
+ # allowlist below, warns, falls back to :bar) and `nil → "" → nil` via
62
+ # `presence` (falls back to :bar silently, since an unspecified type is
63
+ # not a typo).
64
+ chart_type = panel.options[:type].to_s.presence&.to_sym
65
+ if chart_type && !chart_types.include?(chart_type)
66
+ Rails.logger&.warn(
67
+ "AdminSuite: chart panel #{panel.title.inspect} has unknown type #{chart_type.inspect}; " \
68
+ "falling back to :bar. Supported: #{chart_types.join(', ')}."
69
+ )
70
+ chart_type = nil
71
+ end
72
+ chart_type ||= :bar
73
+ %>
74
+ <%
75
+ # Chart height: 64px (the old `h-16` bar height) was fine for sparkline-ish
76
+ # CSS bars, but is not usable for a real Chart.js chart with axes/legend.
77
+ # Emitted as an inline style below, not an interpolated Tailwind height
78
+ # class (which the content scanner can't see at build time, since it's
79
+ # never a literal class name in source — see panels_helper.rb's span
80
+ # comment for the same lesson), so both the degraded bars and the upgraded
81
+ # canvas share an identical, explicit height and there's no layout shift
82
+ # when Chart.js takes over.
83
+ # Total coercion, same idiom as `data:`'s per-row `value` above: `presence`
84
+ # filters `nil`/`""`/`false`/`{}`/`[]`, but `true`, Symbols, non-empty
85
+ # Arrays and non-empty Hashes all survive it and none respond to `#to_i` --
86
+ # `NoMethodError` 500ing the whole dashboard. `Integer(Float(...))` raises
87
+ # on exactly those (plus non-numeric Strings), so anything that can't
88
+ # genuinely parse degrades to the default instead. `RangeError` covers its
89
+ # `FloatDomainError` subclass: `Float::INFINITY` and `NAN` parse fine but
90
+ # have no Integer form, and a host can reach them by computing a height
91
+ # from a ratio that divides by zero.
92
+ chart_height = begin
93
+ Integer(Float(panel.options[:height]))
94
+ rescue ArgumentError, TypeError, RangeError
95
+ 192
96
+ end
97
+ chart_height = 192 if chart_height <= 0
98
+ %>
99
+ <% max_value = (data.map { |d| d[:numeric] }.max || 1).to_f %>
5
100
  <% max_value = 1.0 if max_value.zero? %>
6
101
 
7
102
  <% bar_color = case color
@@ -23,23 +118,68 @@ end %>
23
118
  </div>
24
119
 
25
120
  <% if data.any? %>
26
- <div class="flex items-end gap-1 h-16">
27
- <% data.each do |d| %>
28
- <% value = d[:value].to_f %>
29
- <% height_float = (value / max_value) * 100.0 %>
30
- <% height_float = 0.0 if height_float.nan? || height_float.infinite? %>
31
- <% height = height_float.round %>
32
- <% height = [height, 2].max if value.positive? %>
33
- <div class="flex-1 h-full flex flex-col items-center justify-end">
34
- <div class="w-full rounded-t <%= bar_color %> transition-all" style="height: <%= height %>%"
35
- title="<%= "#{d[:label]}: #{d[:value]}" %>"></div>
36
- </div>
121
+ <%
122
+ # Load the vendored Chart.js assets only on pages that actually render a
123
+ # chart with data, via a dedicated content_for hook consumed by the
124
+ # layout (see app/views/layouts/admin_suite/application.html.erb).
125
+ # Mirrors the EasyMDE hook in
126
+ # lib/admin_suite/ui/field_renderer_registry.rb's :markdown handler.
127
+ # Guarded so multiple chart panels on one page don't emit the tags twice.
128
+ %>
129
+ <% unless content_for?(:chart_assets) %>
130
+ <% content_for(:chart_assets) do %>
131
+ <%# "vendor/chart.umd.min" (not bare "chart.umd.min"): Propshaft resolves
132
+ assets by path relative to a load-path root, and app/assets/vendor
133
+ lives *under* the already-registered app/assets root, so its files
134
+ are found at "vendor/chart.umd.min.js". %>
135
+ <%= javascript_include_tag "vendor/chart.umd.min", "data-turbo-track": "reload" %>
37
136
  <% end %>
38
- </div>
137
+ <% end %>
39
138
 
40
- <div class="flex gap-1 mt-2">
41
- <% data.each do |d| %>
42
- <div class="flex-1 text-center text-xs text-slate-400" title="<%= d[:label] %>"><%= d[:label] %></div>
139
+ <%
140
+ # Chart.js gets the coerced numeric series, never the raw display
141
+ # value a Boolean/Hash/Array/nil `value` would otherwise reach
142
+ # Chart.js as-is and break the chart, even though it's already been
143
+ # safely zeroed out for the CSS bars above.
144
+ series_for_chart_js = data.map { |d| { label: d[:label], value: d[:numeric] } }
145
+ %>
146
+ <div data-controller="admin-suite--chart"
147
+ data-admin-suite--chart-series-value="<%= series_for_chart_js.to_json %>"
148
+ data-admin-suite--chart-type-value="<%= chart_type %>"
149
+ data-admin-suite--chart-color-value="<%= color %>"
150
+ data-admin-suite--chart-height-value="<%= chart_height %>">
151
+ <% if chart_type == :doughnut %>
152
+ <%# A stacked bar makes no sense as a doughnut's degraded (no-JS)
153
+ state -- there's no "height" a doughnut slice maps to. A plain
154
+ labelled value list carries the same data legibly instead. %>
155
+ <div class="divide-y divide-slate-100" data-admin-suite--chart-target="list">
156
+ <% data.each do |d| %>
157
+ <div class="flex items-center justify-between py-1.5 text-sm">
158
+ <span class="text-slate-600 truncate" title="<%= d[:label] %>"><%= d[:label] %></span>
159
+ <span class="font-medium text-slate-900"><%= d[:value] %></span>
160
+ </div>
161
+ <% end %>
162
+ </div>
163
+ <% else %>
164
+ <div class="flex items-end gap-1" data-admin-suite--chart-target="bars" style="height: <%= chart_height %>px;">
165
+ <% data.each do |d| %>
166
+ <% value = d[:numeric] %>
167
+ <% height_float = (value / max_value) * 100.0 %>
168
+ <% height_float = 0.0 if height_float.nan? || height_float.infinite? %>
169
+ <% height = height_float.round %>
170
+ <% height = [height, 2].max if value.positive? %>
171
+ <div class="flex-1 h-full flex flex-col items-center justify-end">
172
+ <div class="w-full rounded-t <%= bar_color %> transition-all" style="height: <%= height %>%"
173
+ title="<%= "#{d[:label]}: #{d[:value]}" %>"></div>
174
+ </div>
175
+ <% end %>
176
+ </div>
177
+
178
+ <div class="flex gap-1 mt-2" data-admin-suite--chart-target="labels">
179
+ <% data.each do |d| %>
180
+ <div class="flex-1 text-center text-xs text-slate-400" title="<%= d[:label] %>"><%= d[:label] %></div>
181
+ <% end %>
182
+ </div>
43
183
  <% end %>
44
184
  </div>
45
185
  <% else %>
@@ -31,6 +31,16 @@
31
31
  border: "border border-violet-200",
32
32
  text: "text-violet-700",
33
33
  label: "text-violet-600" }
34
+ when :slate
35
+ { bg: "bg-white",
36
+ border: "border border-slate-200",
37
+ text: "text-slate-600",
38
+ label: "text-slate-500" }
39
+ when :indigo
40
+ { bg: "bg-gradient-to-br from-indigo-50 to-indigo-100",
41
+ border: "border border-indigo-200",
42
+ text: "text-indigo-700",
43
+ label: "text-indigo-600" }
34
44
  else
35
45
  { bg: "bg-white",
36
46
  border: "border border-slate-200",
@@ -48,11 +48,12 @@
48
48
  <% if @stats.present? %>
49
49
  <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
50
50
  <% @stats.each do |stat| %>
51
- <% color = stat[:color] || :slate %>
52
- <div class="bg-white rounded-xl p-4 border border-slate-200">
53
- <div class="text-2xl font-bold text-<%= color %>-600"><%= stat[:value] %></div>
54
- <div class="text-sm text-slate-500"><%= stat[:name] %></div>
55
- </div>
51
+ <% stat_panel = AdminSuite::UI::PanelDefinition.new(
52
+ type: :stat,
53
+ title: stat[:name],
54
+ options: { value: stat[:value], color: stat[:color] || :slate, variant: :mini }
55
+ ) %>
56
+ <%= render "admin_suite/panels/stat", panel: stat_panel %>
56
57
  <% end %>
57
58
  </div>
58
59
  <% end %>
@@ -124,6 +125,14 @@
124
125
  </div>
125
126
  <% end %>
126
127
 
128
+ <div>
129
+ <label class="block text-sm font-medium text-slate-700 mb-1">Per page</label>
130
+ <%= select_tag :per_page,
131
+ options_for_select([ 25, 50, 100 ], @pagy&.limit),
132
+ class: "form-input w-full",
133
+ data: { "admin-suite--live-filter-target": "input", action: "change->admin-suite--live-filter#submit" } %>
134
+ </div>
135
+
127
136
  <div class="flex gap-2 pt-2">
128
137
  <button type="submit" class="admin-suite-btn-primary flex-1 text-sm font-medium rounded-lg transition-colors">
129
138
  Apply
@@ -142,9 +151,9 @@
142
151
  <%= turbo_frame_tag "resource_results", data: { turbo_action: "advance" } do %>
143
152
  <div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
144
153
  <% if @collection.any? %>
145
- <div class="overflow-x-auto">
154
+ <div class="overflow-x-auto overflow-y-auto max-h-[70vh]">
146
155
  <table class="min-w-full divide-y divide-slate-200">
147
- <thead class="bg-slate-50">
156
+ <thead class="bg-slate-50 sticky top-0 z-10">
148
157
  <tr>
149
158
  <%
150
159
  current_sort = params[:sort]&.to_sym || resource_config.index_config.default_sort
@@ -188,9 +197,20 @@
188
197
  </thead>
189
198
  <tbody class="divide-y divide-slate-200">
190
199
  <% @collection.each do |record| %>
191
- <tr class="hover:bg-slate-50">
200
+ <%
201
+ show_path = url_for(action: :show, id: record.to_param)
202
+ %>
203
+ <%= tag.tr class: "hover:bg-slate-50 cursor-pointer",
204
+ data: {
205
+ controller: "admin-suite--click-actions",
206
+ action: "click->admin-suite--click-actions#navigate",
207
+ "admin-suite--click-actions-url-value": show_path
208
+ } do %>
192
209
  <% resource_config.index_config.columns_list.each do |column| %>
193
- <td class="px-4 py-3 text-sm text-slate-900">
210
+ <%
211
+ td_class = [ "px-4 py-3 text-sm text-slate-900", column_align_class(column.align), column.css_class ].reject(&:blank?).join(" ")
212
+ %>
213
+ <td class="<%= td_class %>">
194
214
  <%= render_column_value(record, column) %>
195
215
  </td>
196
216
  <% end %>
@@ -210,85 +230,14 @@
210
230
  <% end %>
211
231
  </div>
212
232
  </td>
213
- </tr>
233
+ <% end %>
214
234
  <% end %>
215
235
  </tbody>
216
236
  </table>
217
237
  </div>
218
238
 
219
239
  <!-- Pagination -->
220
- <% if @pagy.pages > 1 %>
221
- <div class="px-6 py-4 border-t border-slate-200">
222
- <div class="flex items-center justify-between">
223
- <div class="text-sm text-slate-500">
224
- Showing <span class="font-medium text-slate-700"><%= @pagy.from %></span> to
225
- <span class="font-medium text-slate-700"><%= @pagy.to %></span> of
226
- <span class="font-medium text-slate-700"><%= number_with_delimiter(@pagy.count) %></span> results
227
- </div>
228
-
229
- <nav class="flex items-center gap-1">
230
- <% if @pagy.prev %>
231
- <%= link_to url_for(params.permit!.merge(page: @pagy.prev)),
232
- class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors" do %>
233
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
234
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
235
- </svg>
236
- Prev
237
- <% end %>
238
- <% else %>
239
- <span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-400 bg-slate-100 border border-slate-200 rounded-lg cursor-not-allowed">
240
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
241
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
242
- </svg>
243
- Prev
244
- </span>
245
- <% end %>
246
-
247
- <div class="hidden sm:flex items-center gap-1 mx-2">
248
- <% @pagy.series.each do |item| %>
249
- <% if item == :gap %>
250
- <span class="px-2 py-1 text-sm text-slate-400">…</span>
251
- <% elsif item.is_a?(Integer) %>
252
- <%= link_to item, url_for(params.permit!.merge(page: item)),
253
- class: "px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors" %>
254
- <% elsif item.is_a?(String) %>
255
- <span class="px-3 py-1.5 text-sm font-medium text-white rounded-lg" style="background: var(--admin-suite-primary); border: 1px solid var(--admin-suite-primary);">
256
- <%= item %>
257
- </span>
258
- <% end %>
259
- <% end %>
260
- </div>
261
-
262
- <span class="sm:hidden px-3 py-1.5 text-sm text-slate-500">
263
- Page <%= @pagy.page %> of <%= @pagy.pages %>
264
- </span>
265
-
266
- <% if @pagy.next %>
267
- <%= link_to url_for(params.permit!.merge(page: @pagy.next)),
268
- class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors" do %>
269
- Next
270
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
271
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
272
- </svg>
273
- <% end %>
274
- <% else %>
275
- <span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-400 bg-slate-100 border border-slate-200 rounded-lg cursor-not-allowed">
276
- Next
277
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
278
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
279
- </svg>
280
- </span>
281
- <% end %>
282
- </nav>
283
- </div>
284
- </div>
285
- <% else %>
286
- <div class="px-6 py-3 border-t border-slate-200">
287
- <div class="text-sm text-slate-500">
288
- Showing <span class="font-medium text-slate-700"><%= @pagy.count %></span> results
289
- </div>
290
- </div>
291
- <% end %>
240
+ <%= render "admin_suite/shared/pagination", pagy: @pagy, page_param: :page %>
292
241
  <% else %>
293
242
  <div class="p-12 text-center">
294
243
  <div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-slate-100 mb-4">
@@ -0,0 +1,74 @@
1
+ <% page_param = local_assigns.fetch(:page_param, :page) %>
2
+
3
+ <% if pagy.pages > 1 %>
4
+ <div class="px-6 py-4 border-t border-slate-200">
5
+ <div class="flex items-center justify-between">
6
+ <div class="text-sm text-slate-500">
7
+ Showing <span class="font-medium text-slate-700"><%= pagy.from %></span> to
8
+ <span class="font-medium text-slate-700"><%= pagy.to %></span> of
9
+ <span class="font-medium text-slate-700"><%= number_with_delimiter(pagy.count) %></span> results
10
+ </div>
11
+
12
+ <nav class="flex items-center gap-1">
13
+ <% if pagy.prev %>
14
+ <%= link_to url_for(params.permit!.merge(page_param => pagy.prev)),
15
+ class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors" do %>
16
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
17
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
18
+ </svg>
19
+ Prev
20
+ <% end %>
21
+ <% else %>
22
+ <span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-400 bg-slate-100 border border-slate-200 rounded-lg cursor-not-allowed">
23
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
24
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
25
+ </svg>
26
+ Prev
27
+ </span>
28
+ <% end %>
29
+
30
+ <div class="hidden sm:flex items-center gap-1 mx-2">
31
+ <% pagy.series.each do |item| %>
32
+ <% if item == :gap %>
33
+ <span class="px-2 py-1 text-sm text-slate-400">…</span>
34
+ <% elsif item.is_a?(Integer) %>
35
+ <%= link_to item, url_for(params.permit!.merge(page_param => item)),
36
+ class: "px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors" %>
37
+ <% elsif item.is_a?(String) %>
38
+ <span class="px-3 py-1.5 text-sm font-medium text-white rounded-lg" style="background: var(--admin-suite-primary); border: 1px solid var(--admin-suite-primary);">
39
+ <%= item %>
40
+ </span>
41
+ <% end %>
42
+ <% end %>
43
+ </div>
44
+
45
+ <span class="sm:hidden px-3 py-1.5 text-sm text-slate-500">
46
+ Page <%= pagy.page %> of <%= pagy.pages %>
47
+ </span>
48
+
49
+ <% if pagy.next %>
50
+ <%= link_to url_for(params.permit!.merge(page_param => pagy.next)),
51
+ class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors" do %>
52
+ Next
53
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
54
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
55
+ </svg>
56
+ <% end %>
57
+ <% else %>
58
+ <span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-400 bg-slate-100 border border-slate-200 rounded-lg cursor-not-allowed">
59
+ Next
60
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
61
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
62
+ </svg>
63
+ </span>
64
+ <% end %>
65
+ </nav>
66
+ </div>
67
+ </div>
68
+ <% else %>
69
+ <div class="px-6 py-3 border-t border-slate-200">
70
+ <div class="text-sm text-slate-500">
71
+ Showing <span class="font-medium text-slate-700"><%= pagy.count %></span> results
72
+ </div>
73
+ </div>
74
+ <% end %>