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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +210 -1
- data/app/assets/vendor/chart.umd.min.js +14 -0
- data/app/assets/vendor/easymde.min.css +7 -0
- data/app/assets/vendor/easymde.min.js +7 -0
- data/app/controllers/admin_suite/application_controller.rb +26 -47
- data/app/controllers/admin_suite/portals_controller.rb +2 -2
- data/app/controllers/admin_suite/resources_controller.rb +138 -6
- data/app/helpers/admin_suite/base_helper.rb +232 -391
- data/app/javascript/admin_suite_application.js +3 -0
- data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
- data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +21 -2
- data/app/views/admin_suite/panels/_chart.html.erb +158 -18
- data/app/views/admin_suite/panels/_stat.html.erb +10 -0
- data/app/views/admin_suite/resources/index.html.erb +31 -82
- data/app/views/admin_suite/shared/_pagination.html.erb +74 -0
- data/app/views/admin_suite/shared/_sidebar.html.erb +15 -9
- data/app/views/layouts/admin_suite/application.html.erb +11 -3
- data/config/routes.rb +3 -0
- data/lib/admin/base/action_executor.rb +19 -51
- data/lib/admin/base/filter_builder.rb +48 -5
- data/lib/admin/base/resource.rb +92 -17
- data/lib/admin_suite/configuration.rb +32 -3
- data/lib/admin_suite/definition_loader.rb +194 -0
- data/lib/admin_suite/deprecation.rb +48 -0
- data/lib/admin_suite/engine.rb +55 -76
- data/lib/admin_suite/host_autoload_policy.rb +132 -0
- data/lib/admin_suite/legacy_custom_renderer_procs.rb +29 -0
- data/lib/admin_suite/portal_definition.rb +11 -0
- data/lib/admin_suite/renderer.rb +133 -0
- data/lib/admin_suite/renderer_registry.rb +81 -0
- data/lib/admin_suite/renderers/code_renderer.rb +15 -0
- data/lib/admin_suite/renderers/json_renderer.rb +15 -0
- data/lib/admin_suite/renderers/key_value_renderer.rb +39 -0
- data/lib/admin_suite/renderers/legacy_gleania.rb +232 -0
- data/lib/admin_suite/renderers/table_from_renderer.rb +22 -0
- data/lib/admin_suite/section_definition.rb +42 -0
- data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
- data/lib/admin_suite/ui/field_renderer_registry.rb +31 -4
- data/lib/admin_suite/ui/form_field_renderer.rb +1 -7
- data/lib/admin_suite/ui/show_formatter_registry.rb +9 -0
- data/lib/admin_suite/ui/show_value_formatter.rb +7 -3
- data/lib/admin_suite/version.rb +1 -1
- data/lib/admin_suite.rb +48 -0
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +0 -4
- data/test/controllers/resources_controller_test.rb +76 -1
- data/test/integration/association_linking_test.rb +292 -0
- data/test/integration/chart_panel_test.rb +491 -0
- data/test/integration/dashboard_test.rb +9 -2
- data/test/integration/index_table_test.rb +289 -0
- data/test/integration/layout_assets_test.rb +112 -0
- data/test/integration/navigation_sections_test.rb +62 -0
- data/test/integration/pagination_and_stats_test.rb +152 -0
- data/test/integration/searchable_select_search_test.rb +368 -0
- data/test/integration/show_hide_blank_test.rb +195 -0
- data/test/integration/toggle_test.rb +106 -0
- data/test/lib/action_executor_redirect_test.rb +41 -0
- data/test/lib/builtin_renderers_test.rb +202 -0
- data/test/lib/definition_loader_test.rb +276 -0
- data/test/lib/engine_defaults_test.rb +39 -0
- data/test/lib/form_field_renderer_test.rb +136 -0
- data/test/lib/format_table_cell_test.rb +49 -0
- data/test/lib/index_includes_test.rb +223 -0
- data/test/lib/legacy_renderer_deprecation_test.rb +42 -0
- data/test/lib/renderer_test.rb +237 -0
- data/test/lib/resource_exportable_deprecation_test.rb +47 -0
- data/test/lib/show_value_formatter_test.rb +88 -0
- data/test/lib/zeitwerk_integration_test.rb +28 -64
- data/test/test_helper.rb +121 -5
- 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
|
|
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
|
-
|
|
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
|
-
<%
|
|
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
|
-
<%
|
|
4
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
137
|
+
<% end %>
|
|
39
138
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
<%
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
+
<% end %>
|
|
214
234
|
<% end %>
|
|
215
235
|
</tbody>
|
|
216
236
|
</table>
|
|
217
237
|
</div>
|
|
218
238
|
|
|
219
239
|
<!-- Pagination -->
|
|
220
|
-
|
|
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 %>
|