@adminforth/dashboard 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -54
- package/custom/api/dashboardApi.ts +9 -0
- package/custom/model/dashboard.types.ts +158 -1
- package/custom/queries/useWidgetData.ts +8 -4
- package/custom/runtime/WidgetShell.vue +8 -4
- package/custom/widgets/chart/chart.utils.ts +2 -2
- package/custom/widgets/gauge-card/GaugeCardWidget.vue +94 -12
- package/custom/widgets/pivot-table/PivotTableWidget.vue +27 -5
- package/custom/widgets/table/TableWidget.vue +155 -30
- package/dist/custom/api/dashboardApi.d.ts +7 -1
- package/dist/custom/api/dashboardApi.js +4 -6
- package/dist/custom/api/dashboardApi.ts +9 -0
- package/dist/custom/model/dashboard.types.d.ts +45 -0
- package/dist/custom/model/dashboard.types.js +82 -1
- package/dist/custom/model/dashboard.types.ts +158 -1
- package/dist/custom/queries/useDashboardConfig.d.ts +42 -0
- package/dist/custom/queries/useWidgetData.d.ts +44 -1
- package/dist/custom/queries/useWidgetData.js +3 -3
- package/dist/custom/queries/useWidgetData.ts +8 -4
- package/dist/custom/runtime/WidgetShell.vue +8 -4
- package/dist/custom/widgets/chart/chart.utils.d.ts +1 -1
- package/dist/custom/widgets/chart/chart.utils.js +2 -2
- package/dist/custom/widgets/chart/chart.utils.ts +2 -2
- package/dist/custom/widgets/gauge-card/GaugeCardWidget.vue +94 -12
- package/dist/custom/widgets/pivot-table/PivotTableWidget.vue +27 -5
- package/dist/custom/widgets/table/TableWidget.vue +155 -30
- package/dist/endpoint/widgets.d.ts +6 -1
- package/dist/endpoint/widgets.js +22 -4
- package/dist/schema/api.d.ts +882 -212
- package/dist/schema/api.js +11 -2
- package/dist/schema/widget.d.ts +542 -4
- package/dist/schema/widget.js +111 -1
- package/dist/services/widgetConfigValidator.js +32 -6
- package/dist/services/widgetDataService.d.ts +8 -6
- package/dist/services/widgetDataService.js +133 -11
- package/endpoint/widgets.ts +31 -4
- package/package.json +1 -1
- package/schema/api.ts +11 -1
- package/schema/widget.ts +114 -1
- package/services/widgetConfigValidator.ts +45 -6
- package/services/widgetDataService.ts +201 -19
package/README.md
CHANGED
|
@@ -2,58 +2,120 @@
|
|
|
2
2
|
|
|
3
3
|
Dashboard plugin for AdminForth.
|
|
4
4
|
|
|
5
|
+
It adds configurable dashboard pages backed by an AdminForth resource. Dashboard records define groups and widgets, the plugin renders them under `/dashboard/:slug`, contributes a **Dashboards** sidebar group, and exposes endpoints for editing groups and widgets from the AdminForth UI.
|
|
6
|
+
|
|
7
|
+
Full setup guide: https://adminforth.dev/docs/tutorial/Plugins/dashboard/
|
|
8
|
+
|
|
9
|
+
## Dashboard Config Shape
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
type DashboardConfig = {
|
|
13
|
+
version: number
|
|
14
|
+
groups: {
|
|
15
|
+
id: string
|
|
16
|
+
label: string
|
|
17
|
+
order: number
|
|
18
|
+
}[]
|
|
19
|
+
widgets: DashboardWidgetConfig[]
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Each widget has common fields:
|
|
24
|
+
|
|
25
|
+
| Field | Description |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `id` | Persisted widget id. |
|
|
28
|
+
| `group_id` | Group where the widget is rendered. |
|
|
29
|
+
| `label` | Optional widget title. |
|
|
30
|
+
| `target` | Widget type: `table`, `chart`, `kpi_card`, `pivot_table`, or `gauge_card`. |
|
|
31
|
+
| `order` | Widget order inside its group. |
|
|
32
|
+
| `size` | Preset width: `small`, `medium`, `large`, `wide`, or `full`. |
|
|
33
|
+
| `width`, `height`, `minWidth`, `maxWidth` | Optional explicit layout constraints. |
|
|
34
|
+
| `dataSource` | Optional resource or aggregate data source definition. |
|
|
35
|
+
| `query` | Optional AdminForth resource query used to load widget data. |
|
|
36
|
+
|
|
37
|
+
## Widget Support Matrix
|
|
38
|
+
|
|
39
|
+
| Widget target | Config field | Main settings | Data usage |
|
|
40
|
+
| --- | --- | --- | --- |
|
|
41
|
+
| `table` | `table` | `columns`, `pagination`, `pageSize` | Uses `dataSource.type = 'resource'` or legacy `query` to display resource rows with backend pagination unless `pagination` is `false`. |
|
|
42
|
+
| `chart` | `chart` | `type`, `x_field`, `y_field`, `label_field`, `value_field`, `bucket_field`, `buckets`, `series`, `series_name`, `color`, `colors` | Supports legacy `query` or `dataSource.type = 'aggregate'` with `groupBy`. |
|
|
43
|
+
| `kpi_card` | `kpi_card` | `value_field`, `label_field`, `prefix`, `suffix` | Reads the first row or aggregate values and formats one numeric value. |
|
|
44
|
+
| `gauge_card` | `gauge_card` | `value_field`, `min`, `max`, `min_field`, `max_field`, `suffix`, `color` | Reads the first row or aggregate values and renders progress between static or field-driven bounds. |
|
|
45
|
+
| `pivot_table` | `pivot_table` | `row_field`, `column_field`, `value_field`, `aggregation` | Supports legacy row-based queries and grouped aggregate rows. `aggregation` supports `count` and `sum`. |
|
|
46
|
+
|
|
47
|
+
Chart widget types:
|
|
48
|
+
|
|
49
|
+
| Chart type | Notes |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `line` | Uses `x_field` and `y_field`; optional `series_name` and `color`. |
|
|
52
|
+
| `pie` | Uses `label_field` and optional `value_field`; without `value_field`, rows are counted by label. |
|
|
53
|
+
| `bar` | Uses `label_field` and `value_field`, or `bucket_field` with `buckets`. |
|
|
54
|
+
| `stacked_bar` | Uses `x_field` and `series`; if `series` is omitted, non-x columns become series. |
|
|
55
|
+
| `funnel` | Uses `label_field`, `value_field`, and optional `colors`. |
|
|
56
|
+
| `histogram` | Uses the same bucket settings as `bar`. |
|
|
57
|
+
|
|
58
|
+
## Query Shape
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
type DashboardWidgetQuery = {
|
|
62
|
+
resource: string
|
|
63
|
+
select?: string[]
|
|
64
|
+
order?: {
|
|
65
|
+
field: string
|
|
66
|
+
direction: 'asc' | 'desc'
|
|
67
|
+
}
|
|
68
|
+
limit?: number
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`resource` is an AdminForth `resourceId`. The query is executed through AdminForth resources, so widgets use the same resource contracts as the rest of the application.
|
|
73
|
+
|
|
74
|
+
## Data Source Shape
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
type WidgetDataSource =
|
|
78
|
+
| {
|
|
79
|
+
type: 'resource'
|
|
80
|
+
resourceId: string
|
|
81
|
+
columns?: string[]
|
|
82
|
+
filters?: unknown
|
|
83
|
+
sort?: unknown
|
|
84
|
+
}
|
|
85
|
+
| {
|
|
86
|
+
type: 'aggregate'
|
|
87
|
+
resourceId: string
|
|
88
|
+
aggregations: Record<string, {
|
|
89
|
+
operation: 'sum' | 'count' | 'avg' | 'min' | 'max' | 'median'
|
|
90
|
+
field?: string
|
|
91
|
+
}>
|
|
92
|
+
groupBy?:
|
|
93
|
+
| { type: 'field'; field: string }
|
|
94
|
+
| {
|
|
95
|
+
type: 'date_trunc'
|
|
96
|
+
field: string
|
|
97
|
+
truncation: 'day' | 'week' | 'month' | 'year'
|
|
98
|
+
timezone?: string
|
|
99
|
+
}
|
|
100
|
+
filters?: unknown
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`query` remains supported for backwards compatibility. When both are present, widgets prefer `dataSource`.
|
|
105
|
+
|
|
106
|
+
## Runtime Structure
|
|
107
|
+
|
|
108
|
+
```text
|
|
5
109
|
DashboardPage.vue
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
runtime/
|
|
20
|
-
DashboardPage.vue
|
|
21
|
-
DashboardRuntime.vue
|
|
22
|
-
DashboardGroup.vue
|
|
23
|
-
WidgetShell.vue
|
|
24
|
-
WidgetRenderer.vue
|
|
25
|
-
|
|
26
|
-
widgets/
|
|
27
|
-
registry.ts
|
|
28
|
-
|
|
29
|
-
table/
|
|
30
|
-
TableWidget.vue
|
|
31
|
-
TableWidgetEditor.vue
|
|
32
|
-
table.adapter.ts
|
|
33
|
-
|
|
34
|
-
chart/
|
|
35
|
-
ChartWidget.vue
|
|
36
|
-
ChartWidgetEditor.vue
|
|
37
|
-
chart.adapter.ts
|
|
38
|
-
charts/
|
|
39
|
-
PieChart.vue
|
|
40
|
-
LineChart.vue
|
|
41
|
-
BarChart.vue
|
|
42
|
-
StackedBarChart.vue
|
|
43
|
-
FunnelChart.vue
|
|
44
|
-
HistogramChart.vue
|
|
45
|
-
|
|
46
|
-
kpi-card/
|
|
47
|
-
KpiCardWidget.vue
|
|
48
|
-
KpiCardWidgetEditor.vue
|
|
49
|
-
kpi.adapter.ts
|
|
50
|
-
|
|
51
|
-
pivot-table/
|
|
52
|
-
PivotTableWidget.vue
|
|
53
|
-
PivotTableWidgetEditor.vue
|
|
54
|
-
pivot.adapter.ts
|
|
55
|
-
|
|
56
|
-
gauge-card/
|
|
57
|
-
GaugeCardWidget.vue
|
|
58
|
-
GaugeCardWidgetEditor.vue
|
|
59
|
-
gauge.adapter.ts
|
|
110
|
+
└── DashboardRuntime.vue
|
|
111
|
+
└── DashboardGroup.vue
|
|
112
|
+
└── WidgetShell.vue
|
|
113
|
+
└── WidgetRenderer.vue
|
|
114
|
+
├── TableWidget.vue
|
|
115
|
+
├── ChartWidget.vue
|
|
116
|
+
├── KpiCardWidget.vue
|
|
117
|
+
├── PivotTableWidget.vue
|
|
118
|
+
└── GaugeCardWidget.vue
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`DashboardPage.vue` loads a dashboard by slug, `DashboardRuntime.vue` renders ordered groups, `WidgetShell.vue` provides the widget frame and editor actions, and `WidgetRenderer.vue` selects the widget component by `target`.
|
|
@@ -24,6 +24,13 @@ export type DashboardWidgetDataResponse = {
|
|
|
24
24
|
data: unknown
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
export type DashboardWidgetDataRequest = {
|
|
28
|
+
pagination?: {
|
|
29
|
+
page: number
|
|
30
|
+
pageSize: number
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
export class DashboardApiError extends Error {
|
|
28
35
|
validationErrors: DashboardWidgetConfigValidationError[]
|
|
29
36
|
|
|
@@ -204,10 +211,12 @@ export const dashboardApi = {
|
|
|
204
211
|
async getDashboardWidgetData(
|
|
205
212
|
slug: string,
|
|
206
213
|
widgetId: string,
|
|
214
|
+
request: DashboardWidgetDataRequest = {},
|
|
207
215
|
): Promise<DashboardWidgetDataResponse> {
|
|
208
216
|
return callDashboardWidgetDataApi('/adminapi/v1/dashboard/get_dashboard_widget_data', {
|
|
209
217
|
slug,
|
|
210
218
|
widgetId,
|
|
219
|
+
...request,
|
|
211
220
|
})
|
|
212
221
|
},
|
|
213
222
|
}
|
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
import type { ChartWidgetConfig } from '../widgets/chart/chart.types.js'
|
|
2
2
|
|
|
3
|
+
export type AggregationOperation = 'sum' | 'count' | 'avg' | 'min' | 'max' | 'median'
|
|
4
|
+
|
|
5
|
+
export type AggregationRule = {
|
|
6
|
+
operation: AggregationOperation
|
|
7
|
+
field?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type GroupByRule =
|
|
11
|
+
| {
|
|
12
|
+
type: 'field'
|
|
13
|
+
field: string
|
|
14
|
+
}
|
|
15
|
+
| {
|
|
16
|
+
type: 'date_trunc'
|
|
17
|
+
field: string
|
|
18
|
+
truncation: 'day' | 'week' | 'month' | 'year'
|
|
19
|
+
timezone?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ResourceWidgetDataSource = {
|
|
23
|
+
type: 'resource'
|
|
24
|
+
resourceId: string
|
|
25
|
+
columns?: string[]
|
|
26
|
+
sort?: unknown
|
|
27
|
+
filters?: unknown
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type AggregateWidgetDataSource = {
|
|
31
|
+
type: 'aggregate'
|
|
32
|
+
resourceId: string
|
|
33
|
+
aggregations: Record<string, AggregationRule>
|
|
34
|
+
groupBy?: GroupByRule
|
|
35
|
+
filters?: unknown
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type WidgetDataSource = ResourceWidgetDataSource | AggregateWidgetDataSource
|
|
39
|
+
|
|
3
40
|
export type DashboardConfig = {
|
|
4
41
|
version: number
|
|
5
42
|
groups: DashboardGroupConfig[]
|
|
@@ -45,6 +82,7 @@ export type DashboardWidgetConfig = {
|
|
|
45
82
|
maxWidth?: number | null
|
|
46
83
|
order: number
|
|
47
84
|
target: DashboardWidgetTarget
|
|
85
|
+
dataSource?: WidgetDataSource
|
|
48
86
|
chart?: ChartWidgetConfig
|
|
49
87
|
table?: unknown
|
|
50
88
|
kpi_card?: unknown
|
|
@@ -54,20 +92,139 @@ export type DashboardWidgetConfig = {
|
|
|
54
92
|
}
|
|
55
93
|
|
|
56
94
|
export type DashboardWidgetTableData = {
|
|
95
|
+
kind?: 'table'
|
|
57
96
|
columns: string[]
|
|
58
97
|
rows: Record<string, unknown>[]
|
|
98
|
+
pagination?: {
|
|
99
|
+
page: number
|
|
100
|
+
pageSize: number
|
|
101
|
+
total: number
|
|
102
|
+
totalPages: number
|
|
103
|
+
}
|
|
59
104
|
}
|
|
60
105
|
|
|
106
|
+
export type DashboardWidgetAggregateData = {
|
|
107
|
+
kind: 'aggregate'
|
|
108
|
+
columns: string[]
|
|
109
|
+
rows: Record<string, unknown>[]
|
|
110
|
+
values?: Record<string, unknown>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type DashboardWidgetData = DashboardWidgetTableData | DashboardWidgetAggregateData
|
|
114
|
+
|
|
61
115
|
export function normalizeDashboardConfig(config: unknown): DashboardConfig {
|
|
62
116
|
const value = isRecord(config) ? config : {}
|
|
63
117
|
|
|
64
118
|
return {
|
|
65
119
|
version: typeof value.version === 'number' ? value.version : 1,
|
|
66
120
|
groups: Array.isArray(value.groups) ? (value.groups as DashboardGroupConfig[]) : [],
|
|
67
|
-
widgets: Array.isArray(value.widgets)
|
|
121
|
+
widgets: Array.isArray(value.widgets)
|
|
122
|
+
? value.widgets.map((widget) => normalizeDashboardWidgetConfig(widget) as DashboardWidgetConfig)
|
|
123
|
+
: [],
|
|
68
124
|
}
|
|
69
125
|
}
|
|
70
126
|
|
|
127
|
+
export function normalizeDashboardWidgetConfig(config: unknown) {
|
|
128
|
+
if (!isRecord(config)) {
|
|
129
|
+
return config
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const normalized: Record<string, unknown> = { ...config }
|
|
133
|
+
const target = normalizeDashboardWidgetTarget(normalized.target ?? normalized.type)
|
|
134
|
+
|
|
135
|
+
if (target && normalized.target === undefined) {
|
|
136
|
+
normalized.target = target
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (target === 'kpi_card') {
|
|
140
|
+
const kpiCardConfig = normalizeKpiCardConfig(normalized)
|
|
141
|
+
|
|
142
|
+
if (kpiCardConfig !== undefined) {
|
|
143
|
+
normalized.kpi_card = kpiCardConfig
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (target === 'gauge_card') {
|
|
148
|
+
const gaugeCardConfig = normalizeGaugeCardConfig(normalized)
|
|
149
|
+
|
|
150
|
+
if (gaugeCardConfig !== undefined) {
|
|
151
|
+
normalized.gauge_card = gaugeCardConfig
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return normalized
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function normalizeDashboardWidgetTarget(value: unknown): DashboardWidgetTarget | undefined {
|
|
159
|
+
switch (value) {
|
|
160
|
+
case 'empty':
|
|
161
|
+
case 'table':
|
|
162
|
+
case 'chart':
|
|
163
|
+
case 'kpi_card':
|
|
164
|
+
case 'pivot_table':
|
|
165
|
+
case 'gauge_card':
|
|
166
|
+
return value
|
|
167
|
+
default:
|
|
168
|
+
return undefined
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function normalizeKpiCardConfig(value: Record<string, unknown>) {
|
|
173
|
+
const config = isRecord(value.kpi_card) ? { ...value.kpi_card } : {}
|
|
174
|
+
|
|
175
|
+
if (typeof value.valueField === 'string' && config.value_field === undefined) {
|
|
176
|
+
config.value_field = value.valueField
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (typeof value.labelField === 'string' && config.label_field === undefined) {
|
|
180
|
+
config.label_field = value.labelField
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (typeof value.prefix === 'string' && config.prefix === undefined) {
|
|
184
|
+
config.prefix = value.prefix
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (typeof value.suffix === 'string' && config.suffix === undefined) {
|
|
188
|
+
config.suffix = value.suffix
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return Object.keys(config).length ? config : value.kpi_card
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function normalizeGaugeCardConfig(value: Record<string, unknown>) {
|
|
195
|
+
const config = isRecord(value.gauge_card) ? { ...value.gauge_card } : {}
|
|
196
|
+
|
|
197
|
+
if (typeof value.valueField === 'string' && config.value_field === undefined) {
|
|
198
|
+
config.value_field = value.valueField
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (value.min !== undefined && config.min === undefined) {
|
|
202
|
+
config.min = value.min
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (value.max !== undefined && config.max === undefined) {
|
|
206
|
+
config.max = value.max
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (typeof value.minField === 'string' && config.min_field === undefined) {
|
|
210
|
+
config.min_field = value.minField
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (typeof value.maxField === 'string' && config.max_field === undefined) {
|
|
214
|
+
config.max_field = value.maxField
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (typeof value.suffix === 'string' && config.suffix === undefined) {
|
|
218
|
+
config.suffix = value.suffix
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (typeof value.color === 'string' && config.color === undefined) {
|
|
222
|
+
config.color = value.color
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return Object.keys(config).length ? config : value.gauge_card
|
|
226
|
+
}
|
|
227
|
+
|
|
71
228
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
72
229
|
return typeof value === 'object' && value !== null
|
|
73
230
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { ref, watch, type Ref } from 'vue'
|
|
2
|
-
import { dashboardApi } from '../api/dashboardApi.js'
|
|
2
|
+
import { dashboardApi, type DashboardWidgetDataRequest } from '../api/dashboardApi.js'
|
|
3
3
|
|
|
4
|
-
export function useWidgetData(
|
|
4
|
+
export function useWidgetData(
|
|
5
|
+
slug: Ref<string>,
|
|
6
|
+
widgetId: Ref<string>,
|
|
7
|
+
request?: Ref<DashboardWidgetDataRequest>,
|
|
8
|
+
) {
|
|
5
9
|
const data = ref<Awaited<ReturnType<typeof dashboardApi.getDashboardWidgetData>> | null>(null)
|
|
6
10
|
const isLoading = ref(false)
|
|
7
11
|
const isFetching = ref(false)
|
|
@@ -20,7 +24,7 @@ export function useWidgetData(slug: Ref<string>, widgetId: Ref<string>) {
|
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
try {
|
|
23
|
-
const response = await dashboardApi.getDashboardWidgetData(slug.value, widgetId.value)
|
|
27
|
+
const response = await dashboardApi.getDashboardWidgetData(slug.value, widgetId.value, request?.value)
|
|
24
28
|
data.value = response
|
|
25
29
|
error.value = null
|
|
26
30
|
return response
|
|
@@ -34,7 +38,7 @@ export function useWidgetData(slug: Ref<string>, widgetId: Ref<string>) {
|
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
watch(
|
|
37
|
-
[slug, widgetId],
|
|
41
|
+
request ? [slug, widgetId, request] : [slug, widgetId],
|
|
38
42
|
() => {
|
|
39
43
|
void refetch()
|
|
40
44
|
},
|
|
@@ -135,11 +135,11 @@ const widgetLayoutVars = computed<CSSProperties>(() => {
|
|
|
135
135
|
const fixedWidth = formatWidth(props.layout?.width)
|
|
136
136
|
|
|
137
137
|
return {
|
|
138
|
-
'--widget-basis': fixedWidth ?? basis,
|
|
139
|
-
'--widget-min-width': fixedWidth ?? formatWidth(props.layout?.minWidth) ?? basis,
|
|
138
|
+
'--widget-basis': clampToContainerWidth(fixedWidth ?? basis),
|
|
139
|
+
'--widget-min-width': clampToContainerWidth(fixedWidth ?? formatWidth(props.layout?.minWidth) ?? basis),
|
|
140
140
|
'--widget-max-width': props.layout?.maxWidth === null
|
|
141
|
-
? '
|
|
142
|
-
: fixedWidth ?? formatWidth(props.layout?.maxWidth) ?? '
|
|
141
|
+
? '100%'
|
|
142
|
+
: clampToContainerWidth(fixedWidth ?? formatWidth(props.layout?.maxWidth) ?? '100%'),
|
|
143
143
|
height: formatWidth(props.layout?.height ?? DEFAULT_WIDGET_HEIGHT),
|
|
144
144
|
}
|
|
145
145
|
})
|
|
@@ -149,4 +149,8 @@ function formatWidth(value: number | undefined) {
|
|
|
149
149
|
return `${value}px`
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
|
+
|
|
153
|
+
function clampToContainerWidth(value: string) {
|
|
154
|
+
return `min(${value}, 100%)`
|
|
155
|
+
}
|
|
152
156
|
</script>
|
|
@@ -14,8 +14,8 @@ export function toFiniteNumber(value: unknown) {
|
|
|
14
14
|
return Number.isFinite(numberValue) ? numberValue : 0
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export function formatChartValue(value: number) {
|
|
18
|
-
return new Intl.NumberFormat().format(value)
|
|
17
|
+
export function formatChartValue(value: number, options: Intl.NumberFormatOptions = {}) {
|
|
18
|
+
return new Intl.NumberFormat(undefined, options).format(value)
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function formatChartLabel(value: unknown) {
|
|
@@ -18,6 +18,66 @@ const {
|
|
|
18
18
|
refetch,
|
|
19
19
|
} = useWidgetData(dashboardSlugRef, widgetIdRef)
|
|
20
20
|
|
|
21
|
+
type GaugeCardConfig = {
|
|
22
|
+
value_field?: string
|
|
23
|
+
valueField?: string
|
|
24
|
+
min?: number | string
|
|
25
|
+
max?: number | string
|
|
26
|
+
min_field?: string
|
|
27
|
+
minField?: string
|
|
28
|
+
max_field?: string
|
|
29
|
+
maxField?: string
|
|
30
|
+
suffix?: string
|
|
31
|
+
color?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
35
|
+
return typeof value === 'object' && value !== null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseGaugeCardConfig(value: unknown): GaugeCardConfig | undefined {
|
|
39
|
+
if (isRecord(value)) {
|
|
40
|
+
return value as GaugeCardConfig
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (typeof value !== 'string') {
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(value) as unknown
|
|
49
|
+
return isRecord(parsed) ? parsed as GaugeCardConfig : undefined
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseOptionalNumber(value: unknown): number | undefined {
|
|
56
|
+
if (value === null || value === undefined || value === '') {
|
|
57
|
+
return undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const parsed = typeof value === 'number' ? value : Number(value)
|
|
61
|
+
return Number.isFinite(parsed) ? parsed : undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function countFractionDigits(value: number) {
|
|
65
|
+
if (!Number.isFinite(value)) {
|
|
66
|
+
return 0
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const normalizedValue = value.toString().toLowerCase()
|
|
70
|
+
const [coefficient, exponentValue] = normalizedValue.split('e')
|
|
71
|
+
const exponent = exponentValue ? Number(exponentValue) : 0
|
|
72
|
+
const decimalDigits = coefficient.split('.')[1]?.length ?? 0
|
|
73
|
+
|
|
74
|
+
return Math.max(decimalDigits - exponent, 0)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeDisplayValue(value: number, useWholeNumbers: boolean) {
|
|
78
|
+
return useWholeNumbers ? Math.trunc(value) : value
|
|
79
|
+
}
|
|
80
|
+
|
|
21
81
|
watch(
|
|
22
82
|
() => props.widget,
|
|
23
83
|
() => {
|
|
@@ -26,20 +86,42 @@ watch(
|
|
|
26
86
|
{ deep: true },
|
|
27
87
|
)
|
|
28
88
|
|
|
29
|
-
const gaugeConfig = computed(() => props.widget.gauge_card
|
|
30
|
-
value_field?: string
|
|
31
|
-
min?: number
|
|
32
|
-
max?: number
|
|
33
|
-
suffix?: string
|
|
34
|
-
color?: string
|
|
35
|
-
} | undefined)
|
|
89
|
+
const gaugeConfig = computed(() => parseGaugeCardConfig(props.widget.gauge_card))
|
|
36
90
|
const widgetData = computed(() => data.value?.data as DashboardWidgetTableData | null)
|
|
37
91
|
const columns = computed(() => widgetData.value?.columns ?? [])
|
|
38
92
|
const firstRow = computed(() => widgetData.value?.rows[0] ?? {})
|
|
39
|
-
const valueField = computed(() => gaugeConfig.value?.value_field || columns.value[0])
|
|
40
|
-
const
|
|
41
|
-
const
|
|
93
|
+
const valueField = computed(() => gaugeConfig.value?.value_field || gaugeConfig.value?.valueField || columns.value[0])
|
|
94
|
+
const minField = computed(() => gaugeConfig.value?.min_field || gaugeConfig.value?.minField)
|
|
95
|
+
const maxField = computed(() => gaugeConfig.value?.max_field || gaugeConfig.value?.maxField)
|
|
96
|
+
const minValue = computed(() => {
|
|
97
|
+
const dynamicMin = minField.value ? parseOptionalNumber(firstRow.value[minField.value]) : undefined
|
|
98
|
+
return dynamicMin ?? parseOptionalNumber(gaugeConfig.value?.min) ?? 0
|
|
99
|
+
})
|
|
100
|
+
const maxValue = computed(() => {
|
|
101
|
+
const dynamicMax = maxField.value ? parseOptionalNumber(firstRow.value[maxField.value]) : undefined
|
|
102
|
+
return dynamicMax ?? parseOptionalNumber(gaugeConfig.value?.max) ?? 100
|
|
103
|
+
})
|
|
42
104
|
const value = computed(() => toFiniteNumber(firstRow.value[valueField.value]))
|
|
105
|
+
const fractionDigits = computed(() => Math.min([
|
|
106
|
+
value.value,
|
|
107
|
+
minValue.value,
|
|
108
|
+
maxValue.value,
|
|
109
|
+
].reduce((maxDigits, currentValue) => Math.max(maxDigits, countFractionDigits(currentValue)), 0), 3))
|
|
110
|
+
const shouldUseWholeNumbers = computed(() => Math.abs(maxValue.value) >= 1000)
|
|
111
|
+
const formattedValue = computed(() => formatChartValue(normalizeDisplayValue(value.value, shouldUseWholeNumbers.value), {
|
|
112
|
+
minimumFractionDigits: shouldUseWholeNumbers.value ? 0 : fractionDigits.value,
|
|
113
|
+
maximumFractionDigits: shouldUseWholeNumbers.value ? 0 : fractionDigits.value,
|
|
114
|
+
}))
|
|
115
|
+
const formattedMinValue = computed(() => formatChartValue(normalizeDisplayValue(minValue.value, shouldUseWholeNumbers.value), {
|
|
116
|
+
minimumFractionDigits: shouldUseWholeNumbers.value ? 0 : fractionDigits.value,
|
|
117
|
+
maximumFractionDigits: shouldUseWholeNumbers.value ? 0 : fractionDigits.value,
|
|
118
|
+
}))
|
|
119
|
+
const formattedMaxValue = computed(() => {
|
|
120
|
+
return formatChartValue(normalizeDisplayValue(maxValue.value, shouldUseWholeNumbers.value), {
|
|
121
|
+
minimumFractionDigits: shouldUseWholeNumbers.value ? 0 : fractionDigits.value,
|
|
122
|
+
maximumFractionDigits: shouldUseWholeNumbers.value ? 0 : fractionDigits.value,
|
|
123
|
+
})
|
|
124
|
+
})
|
|
43
125
|
const progress = computed(() => {
|
|
44
126
|
const range = maxValue.value - minValue.value
|
|
45
127
|
return range > 0 ? Math.min(Math.max((value.value - minValue.value) / range, 0), 1) : 0
|
|
@@ -97,10 +179,10 @@ const gaugeColor = computed(() => gaugeConfig.value?.color || CHART_COLORS[0])
|
|
|
97
179
|
</svg>
|
|
98
180
|
|
|
99
181
|
<div class="text-3xl font-bold text-lightNavbarText dark:text-darkNavbarText">
|
|
100
|
-
{{
|
|
182
|
+
{{ formattedValue }}{{ gaugeConfig?.suffix ?? '' }}
|
|
101
183
|
</div>
|
|
102
184
|
<div class="text-sm text-lightListTableText dark:text-darkListTableText">
|
|
103
|
-
{{
|
|
185
|
+
{{ formattedMinValue }} - {{ formattedMaxValue }}
|
|
104
186
|
</div>
|
|
105
187
|
</div>
|
|
106
188
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, watch } from 'vue'
|
|
3
3
|
import { useWidgetData } from '../../queries/useWidgetData.js'
|
|
4
|
-
import type { DashboardWidgetConfig,
|
|
4
|
+
import type { DashboardWidgetConfig, DashboardWidgetData } from '../../model/dashboard.types.js'
|
|
5
5
|
import { formatChartLabel, formatChartValue, toFiniteNumber } from '../chart/chart.utils.js'
|
|
6
6
|
|
|
7
7
|
const props = defineProps<{
|
|
@@ -32,15 +32,37 @@ const pivotConfig = computed(() => props.widget.pivot_table as {
|
|
|
32
32
|
value_field?: string
|
|
33
33
|
aggregation?: 'count' | 'sum'
|
|
34
34
|
} | undefined)
|
|
35
|
-
const widgetData = computed(() => data.value?.data as
|
|
35
|
+
const widgetData = computed(() => data.value?.data as DashboardWidgetData | null)
|
|
36
36
|
const rows = computed(() => widgetData.value?.rows ?? [])
|
|
37
37
|
const columns = computed(() => widgetData.value?.columns ?? [])
|
|
38
|
-
const
|
|
38
|
+
const isAggregateData = computed(() => widgetData.value?.kind === 'aggregate')
|
|
39
|
+
const shouldRenderAggregateMatrix = computed(() => isAggregateData.value && !pivotConfig.value?.column_field)
|
|
40
|
+
const rowField = computed(() => pivotConfig.value?.row_field || (isAggregateData.value ? 'group' : columns.value[0]))
|
|
39
41
|
const columnField = computed(() => pivotConfig.value?.column_field || columns.value[1])
|
|
40
|
-
const valueField = computed(() => pivotConfig.value?.value_field || columns.value[2])
|
|
42
|
+
const valueField = computed(() => pivotConfig.value?.value_field || columns.value[2] || columns.value[1])
|
|
41
43
|
const aggregation = computed(() => pivotConfig.value?.aggregation || (valueField.value ? 'sum' : 'count'))
|
|
42
|
-
const pivotColumnLabels = computed(() =>
|
|
44
|
+
const pivotColumnLabels = computed(() => {
|
|
45
|
+
if (shouldRenderAggregateMatrix.value) {
|
|
46
|
+
return columns.value.filter((column) => column !== rowField.value)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return Array.from(new Set(rows.value.map((row) => formatChartLabel(row[columnField.value]))))
|
|
50
|
+
})
|
|
43
51
|
const pivotRows = computed(() => {
|
|
52
|
+
if (shouldRenderAggregateMatrix.value) {
|
|
53
|
+
return rows.value.map((row) => {
|
|
54
|
+
const item: Record<string, number | string> = {
|
|
55
|
+
label: formatChartLabel(row[rowField.value]),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const column of pivotColumnLabels.value) {
|
|
59
|
+
item[column] = toFiniteNumber(row[column])
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return item
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
44
66
|
const rowMap = new Map<string, Record<string, number | string>>()
|
|
45
67
|
|
|
46
68
|
for (const row of rows.value) {
|