Skip to content

Commit 089fe93

Browse files
committed
feat: enhance dashboard widget configuration and query handling
- Updated README to clarify chart widget configurations and added examples for bucketed multi-resource queries. - Refactored dashboard API error handling to normalize validation errors more effectively. - Introduced QueryBucketConfig type for better bucket configuration management in dashboard queries. - Improved DashboardGroup.vue and WidgetShell.vue components by replacing button elements with reusable DashboardToolbarButton and DashboardToolbarIcon components. - Added new DashboardToolbarButton and DashboardToolbarIcon components for better UI consistency. - Enhanced StackedBarChart.vue to include tooltip functionality for better user interaction. - Updated endpoint definitions to mark certain actions as dangerous for superadmin only. - Upgraded adminforth dependency to version 2.71.0 for improved functionality. - Modified schema definitions to support histogram queries and ensure proper validation. - Implemented bucketed query handling in widgetDataService for more flexible data retrieval.
1 parent a4e0c01 commit 089fe93

17 files changed

Lines changed: 480 additions & 183 deletions

File tree

README.md

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Each widget has common fields:
3939
| Widget target | Config field | Main settings | Data usage |
4040
| --- | --- | --- | --- |
4141
| `table` | `table` | `pagination`, `page_size`, `columns` | Uses `query` to display raw or aggregate rows. |
42-
| `chart` | `chart` | `type`, `x`, `y`, `label`, `value`, `series`, `buckets`, `color`, `colors` | Uses the same `query` shape for every chart type. Multi-resource charts use `query.source: steps`. |
42+
| `chart` | `chart` | `type`, `x`, `y`, `label`, `value`, `series`, `buckets`, `color`, `colors` | Uses the same `query` shape for most chart types. Multi-resource charts use `query.source: steps`; add `query.bucket` for shared numeric buckets across resources. |
4343
| `kpi_card` | `card` | `value`, `subtitle`, `comparison`, `sparkline` | Reads the first returned query row. |
4444
| `gauge_card` | `card` | `value`, `target`, `progress`, `color` | Reads the first returned query row. |
4545
| `pivot_table` | `pivot` | `rows`, `columns`, `values` | Uses query rows to build a pivot table. |
@@ -53,7 +53,7 @@ Chart widget types:
5353
| `bar` | Uses `x` and `y`. |
5454
| `stacked_bar` | Uses `x`, `y`, and `series`. |
5555
| `funnel` | Uses `label`, `value`, and optional `colors`. Data comes from the same `query` shapes as every other chart. |
56-
| `histogram` | Uses `x`, `y`, and optional `buckets`. |
56+
| `histogram` | Uses `x`, `y`, and optional `buckets`. Current histogram runtime support is single-resource only: provide raw rows for the numeric field and let `chart.buckets` derive counts on the frontend. For multi-resource bucket distributions, use `stacked_bar` with `query.source: steps` and `query.bucket`. |
5757
5858
## Query Shape
5959
@@ -89,6 +89,8 @@ type QueryConfig = {
8989
formatting?: Record<string, JsonValue>
9090
}
9191

92+
`source: 'steps'` returns one aggregate row per step by default. Each step supports aggregate `select` items plus optional `filters`; it does not support per-step `field` selects, `calc` selects, or `group_by`. Add `query.bucket` when multiple resources need the same numeric buckets, for example a stacked bar distribution by price range.
93+
9294
type DashboardFilter =
9395
| { and: DashboardFilter[] }
9496
| { or: DashboardFilter[] }
@@ -155,6 +157,45 @@ query:
155157
as: value
156158
```
157159

160+
Bucketed multi-resource queries use `query.bucket`. The dashboard runs each step once per bucket and returns rows with `label`, `name`, `resource`, and the selected aggregate aliases:
161+
162+
```yaml
163+
target: chart
164+
label: Cars by price range and database
165+
chart:
166+
type: stacked_bar
167+
title: Cars by price range and database
168+
x:
169+
field: label
170+
y:
171+
field: count
172+
series:
173+
field: name
174+
query:
175+
source: steps
176+
bucket:
177+
field: price
178+
buckets:
179+
- label: Budget
180+
max: 3500
181+
- label: Mid-range
182+
min: 3500
183+
max: 7000
184+
- label: Premium
185+
min: 7000
186+
steps:
187+
- name: SQLite
188+
resource: cars_sl
189+
select:
190+
- agg: count
191+
as: count
192+
- name: MySQL
193+
resource: cars_mysql
194+
select:
195+
- agg: count
196+
as: count
197+
```
198+
158199
Cost calculation example:
159200
160201
```yaml

custom/api/dashboardApi.ts

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,20 +62,82 @@ export class DashboardApiError extends Error {
6262
}
6363

6464
function normalizeValidationErrors(response: any): DashboardWidgetConfigValidationError[] {
65-
if (Array.isArray(response?.validationErrors)) {
66-
return response.validationErrors
65+
const errors = Array.isArray(response?.validationErrors)
66+
? response.validationErrors
67+
: Array.isArray(response?.details)
68+
? response.details.map((detail: any) => ({
69+
field: getValidationErrorField(detail),
70+
message: String(detail.message || 'Invalid value'),
71+
}))
72+
: []
73+
74+
return simplifyValidationErrors(errors)
75+
}
76+
77+
function getValidationErrorField(detail: any) {
78+
return Array.isArray(detail.instancePath)
79+
? detail.instancePath.join('.')
80+
: String(detail.instancePath || detail.path || 'config').replace(/^\//, '').replaceAll('/', '.')
81+
}
82+
83+
function simplifyValidationErrors(errors: DashboardWidgetConfigValidationError[]) {
84+
const collapsed = new Map<string, DashboardWidgetConfigValidationError>()
85+
86+
for (const error of errors) {
87+
const selectItemMatch = error.field.match(/^config\.query\.select\.(\d+)$/)
88+
89+
if (selectItemMatch) {
90+
const field = error.field
91+
collapsed.set(field, {
92+
field,
93+
message: 'must be a valid select item: field, aggregate, or calc',
94+
})
95+
continue
96+
}
97+
98+
if (isUnionBranchNoise(error)) {
99+
continue
100+
}
101+
102+
const key = `${error.field}:${error.message}`
103+
collapsed.set(key, error)
67104
}
68105

69-
if (Array.isArray(response?.details)) {
70-
return response.details.map((detail: any) => ({
71-
field: Array.isArray(detail.instancePath)
72-
? detail.instancePath.join('.')
73-
: String(detail.instancePath || detail.path || 'config').replace(/^\//, '').replaceAll('/', '.'),
74-
message: String(detail.message || 'Invalid value'),
75-
}))
106+
const simplifiedErrors = Array.from(collapsed.values())
107+
108+
if (simplifiedErrors.length) {
109+
return simplifiedErrors
110+
}
111+
112+
return dedupeValidationErrors(errors).filter((error) => error.message !== 'must match a schema in anyOf').slice(0, 5)
113+
}
114+
115+
function dedupeValidationErrors(errors: DashboardWidgetConfigValidationError[]) {
116+
const deduped = new Map<string, DashboardWidgetConfigValidationError>()
117+
118+
for (const error of errors) {
119+
deduped.set(`${error.field}:${error.message}`, error)
120+
}
121+
122+
return Array.from(deduped.values())
123+
}
124+
125+
function isUnionBranchNoise(error: DashboardWidgetConfigValidationError) {
126+
if (error.field !== 'config') {
127+
return false
76128
}
77129

78-
return []
130+
return error.message === 'must NOT have additional properties'
131+
|| error.message === 'must match a schema in anyOf'
132+
|| error.message === 'must match exactly one schema in oneOf'
133+
|| error.message === 'must have required property \'chart\''
134+
|| error.message === 'must have required property "chart"'
135+
|| error.message === 'must have required property \'card\''
136+
|| error.message === 'must have required property "card"'
137+
|| error.message === 'must have required property \'table\''
138+
|| error.message === 'must have required property "table"'
139+
|| error.message === 'must have required property \'pivot\''
140+
|| error.message === 'must have required property "pivot"'
79141
}
80142

81143
async function parseDashboardResponse(rawResponse: Response) {

custom/model/dashboard.types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ export type ResourceQueryConfig = {
140140
formatting?: Record<string, JsonValue>
141141
}
142142

143+
export type QueryBucketConfig = {
144+
field: string
145+
buckets: Array<{ label: string, min?: number, max?: number }>
146+
}
147+
143148
export type StepsQueryStepConfig = {
144149
name: string
145150
resource: string
@@ -150,6 +155,7 @@ export type StepsQueryStepConfig = {
150155
export type StepsQueryConfig = {
151156
source: 'steps'
152157
steps: StepsQueryStepConfig[]
158+
bucket?: QueryBucketConfig
153159
calcs?: QueryCalcSelectItem[]
154160
order_by?: QueryOrderByItem[]
155161
limit?: number

custom/runtime/DashboardGroup.vue

Lines changed: 15 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -14,91 +14,36 @@
1414
v-if="isAdmin"
1515
class="absolute right-3 top-3 flex gap-1 opacity-0 transition-opacity group-hover/dashboard:opacity-100"
1616
>
17-
<button
18-
type="button"
19-
class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightListViewButtonBorder bg-lightListViewButtonBackground text-lightListViewButtonText shadow-sm hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover dark:border-darkListViewButtonBorder dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:hover:bg-darkListViewButtonBackgroundHover dark:hover:text-darkListViewButtonTextHover"
17+
<DashboardToolbarButton
2018
title="Edit JSON"
2119
@click="emit('edit-group', group)"
2220
>
23-
<svg
24-
class="h-4 w-4"
25-
viewBox="0 0 24 24"
26-
fill="none"
27-
stroke="currentColor"
28-
stroke-width="1.8"
29-
stroke-linecap="round"
30-
stroke-linejoin="round"
31-
aria-hidden="true"
32-
>
33-
<path d="M15.5 7.5a3 3 0 1 1 1 2.2l-6.8 6.8H7.5v2.2H5.3v2.2H2.8v-2.5l7.5-7.5a5.5 5.5 0 1 1 5.2 1.6" />
34-
</svg>
35-
</button>
21+
<DashboardToolbarIcon name="edit" />
22+
</DashboardToolbarButton>
3623

37-
<button
38-
type="button"
39-
class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightListViewButtonBorder bg-lightListViewButtonBackground text-lightListViewButtonText shadow-sm hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover disabled:opacity-45 dark:border-darkListViewButtonBorder dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:hover:bg-darkListViewButtonBackgroundHover dark:hover:text-darkListViewButtonTextHover"
24+
<DashboardToolbarButton
4025
title="Move up"
4126
:disabled="!canMoveUp"
4227
@click="emit('move-up')"
4328
>
44-
<svg
45-
class="h-4 w-4"
46-
viewBox="0 0 24 24"
47-
fill="none"
48-
stroke="currentColor"
49-
stroke-width="2"
50-
stroke-linecap="round"
51-
stroke-linejoin="round"
52-
aria-hidden="true"
53-
>
54-
<path d="m18 15-6-6-6 6" />
55-
</svg>
56-
</button>
29+
<DashboardToolbarIcon name="move-up" />
30+
</DashboardToolbarButton>
5731

58-
<button
59-
type="button"
60-
class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightListViewButtonBorder bg-lightListViewButtonBackground text-lightListViewButtonText shadow-sm hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover disabled:opacity-45 dark:border-darkListViewButtonBorder dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:hover:bg-darkListViewButtonBackgroundHover dark:hover:text-darkListViewButtonTextHover"
32+
<DashboardToolbarButton
6133
title="Move down"
6234
:disabled="!canMoveDown"
6335
@click="emit('move-down')"
6436
>
65-
<svg
66-
class="h-4 w-4"
67-
viewBox="0 0 24 24"
68-
fill="none"
69-
stroke="currentColor"
70-
stroke-width="2"
71-
stroke-linecap="round"
72-
stroke-linejoin="round"
73-
aria-hidden="true"
74-
>
75-
<path d="m6 9 6 6 6-6" />
76-
</svg>
77-
</button>
37+
<DashboardToolbarIcon name="move-down" />
38+
</DashboardToolbarButton>
7839

79-
<button
80-
type="button"
81-
class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightInputErrorColor/30 bg-lightSecondary text-lightInputErrorColor shadow-sm hover:bg-lightListViewButtonBackgroundHover dark:bg-darkSecondary dark:hover:bg-darkListViewButtonBackgroundHover"
40+
<DashboardToolbarButton
8241
title="Remove"
42+
variant="danger"
8343
@click="emit('remove-group')"
8444
>
85-
<svg
86-
class="h-4 w-4"
87-
viewBox="0 0 24 24"
88-
fill="none"
89-
stroke="currentColor"
90-
stroke-width="2"
91-
stroke-linecap="round"
92-
stroke-linejoin="round"
93-
aria-hidden="true"
94-
>
95-
<path d="M3 6h18" />
96-
<path d="M8 6V4h8v2" />
97-
<path d="M19 6l-1 14H6L5 6" />
98-
<path d="M10 11v5" />
99-
<path d="M14 11v5" />
100-
</svg>
101-
</button>
45+
<DashboardToolbarIcon name="remove" />
46+
</DashboardToolbarButton>
10247
</div>
10348
</header>
10449

@@ -158,6 +103,8 @@
158103

159104
<script setup lang="ts">
160105
import { Button } from '@/afcl'
106+
import DashboardToolbarButton from './DashboardToolbarButton.vue'
107+
import DashboardToolbarIcon from './DashboardToolbarIcon.vue'
161108
import WidgetRenderer from './WidgetRenderer.vue'
162109
import WidgetShell from './WidgetShell.vue'
163110
import type { DashboardGroupConfig, DashboardWidgetConfig } from '../model/dashboard.types.js'
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<template>
2+
<button
3+
type="button"
4+
class="flex h-8 w-8 items-center justify-center rounded-lg border shadow-sm"
5+
:class="buttonClass"
6+
:title="title"
7+
:disabled="disabled"
8+
>
9+
<slot />
10+
</button>
11+
</template>
12+
13+
<script setup lang="ts">
14+
import { computed } from 'vue'
15+
16+
const props = withDefaults(defineProps<{
17+
title: string
18+
disabled?: boolean
19+
variant?: 'default' | 'danger'
20+
}>(), {
21+
disabled: false,
22+
variant: 'default',
23+
})
24+
25+
const buttonClass = computed(() => {
26+
if (props.variant === 'danger') {
27+
return 'border-lightInputErrorColor/30 bg-lightSecondary text-lightInputErrorColor hover:bg-lightListViewButtonBackgroundHover dark:bg-darkSecondary dark:hover:bg-darkListViewButtonBackgroundHover'
28+
}
29+
30+
return 'border-lightListViewButtonBorder bg-lightListViewButtonBackground text-lightListViewButtonText hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover disabled:opacity-45 dark:border-darkListViewButtonBorder dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:hover:bg-darkListViewButtonBackgroundHover dark:hover:text-darkListViewButtonTextHover'
31+
})
32+
</script>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<template>
2+
<svg
3+
v-if="name === 'edit'"
4+
xmlns="http://www.w3.org/2000/svg"
5+
width="16"
6+
height="16"
7+
viewBox="0 0 90 90"
8+
fill="none"
9+
aria-hidden="true"
10+
>
11+
<g transform="translate(90 0) scale(-1 1)">
12+
<path
13+
fill="currentColor"
14+
d="M69.243 90c-5.389 0-10.67-2.107-14.643-6.081-5.433-5.432-7.391-13.514-5.115-20.831L26.912 40.515c-7.313 2.276-15.397.32-20.832-5.114C.147 29.468-1.625 20.617 1.566 12.852l.846-2.059 12.493 12.493c2.311 2.31 6.07 2.311 8.381 0 2.31-2.311 2.31-6.071 0-8.381L10.794 2.413l2.059-.846c7.766-3.191 16.616-1.418 22.549 4.514 5.433 5.433 7.39 13.516 5.114 20.831l22.572 22.573c7.314-2.278 15.398-.32 20.832 5.114 5.934 5.933 7.704 14.784 4.514 22.549l-.847 2.059-12.492-12.493c-1.113-1.113-2.601-1.726-4.191-1.726s-3.077.613-4.191 1.726c-2.31 2.311-2.31 6.071 0 8.381l12.493 12.492-2.06.847C74.582 89.487 71.899 89.999 69.243 90zM27.692 37.1l25.206 25.207-.322.887c-2.345 6.469-.728 13.779 4.119 18.626 4.538 4.538 11.069 6.235 17.152 4.603l-9.232-9.232c-3.466-3.467-3.466-9.109 0-12.576 3.466-3.468 9.109-3.468 12.576 0l9.232 9.232c1.633-6.083-.064-12.615-4.602-17.153-4.847-4.847-12.161-6.464-18.627-4.118l-.887.322L37.101 27.692l.322-.887c2.345-6.469.729-13.78-4.118-18.627-4.539-4.538-11.07-6.235-17.152-4.603l9.232 9.232c3.467 3.467 3.467 9.109 0 12.576s-9.109 3.468-12.576 0l-9.233-9.232c-1.634 6.083.064 12.614 4.602 17.153 4.848 4.848 12.16 6.464 18.628 4.118l.886-.322z"
15+
/>
16+
</g>
17+
</svg>
18+
19+
<svg
20+
v-else
21+
class="h-4 w-4"
22+
viewBox="0 0 24 24"
23+
fill="none"
24+
stroke="currentColor"
25+
stroke-width="2"
26+
stroke-linecap="round"
27+
stroke-linejoin="round"
28+
aria-hidden="true"
29+
>
30+
<path
31+
v-if="name === 'move-up'"
32+
d="m18 15-6-6-6 6"
33+
/>
34+
<path
35+
v-else-if="name === 'move-down'"
36+
d="m6 9 6 6 6-6"
37+
/>
38+
<template v-else>
39+
<path d="M3 6h18" />
40+
<path d="M8 6V4h8v2" />
41+
<path d="M19 6l-1 14H6L5 6" />
42+
<path d="M10 11v5" />
43+
<path d="M14 11v5" />
44+
</template>
45+
</svg>
46+
</template>
47+
48+
<script setup lang="ts">
49+
defineProps<{
50+
name: 'edit' | 'move-up' | 'move-down' | 'remove'
51+
}>()
52+
</script>

0 commit comments

Comments
 (0)