@adminforth/dashboard 1.0.0 → 1.2.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 +99 -54
- package/custom/api/dashboardApi.ts +9 -0
- package/custom/model/dashboard.types.ts +353 -2
- package/custom/queries/useWidgetData.ts +8 -4
- package/custom/runtime/DashboardRuntime.vue +2 -1
- package/custom/runtime/WidgetRenderer.vue +2 -1
- package/custom/runtime/WidgetShell.vue +8 -4
- package/custom/skills/adminforth-dashboard/SKILL.md +4 -4
- package/custom/widgets/chart/ChartWidget.vue +45 -12
- package/custom/widgets/chart/chart.types.ts +83 -0
- package/custom/widgets/chart/chart.utils.ts +2 -2
- package/custom/widgets/gauge-card/GaugeCardWidget.vue +63 -12
- package/custom/widgets/kpi-card/KpiCardWidget.vue +6 -8
- package/custom/widgets/pivot-table/PivotTableWidget.vue +32 -12
- 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 +70 -1
- package/dist/custom/model/dashboard.types.js +173 -1
- package/dist/custom/model/dashboard.types.ts +353 -2
- package/dist/custom/queries/useDashboardConfig.d.ts +42 -2
- package/dist/custom/queries/useWidgetData.d.ts +44 -3
- package/dist/custom/queries/useWidgetData.js +3 -3
- package/dist/custom/queries/useWidgetData.ts +8 -4
- package/dist/custom/runtime/DashboardRuntime.vue +2 -1
- package/dist/custom/runtime/WidgetRenderer.vue +2 -1
- package/dist/custom/runtime/WidgetShell.vue +8 -4
- package/dist/custom/skills/adminforth-dashboard/SKILL.md +4 -4
- package/dist/custom/widgets/chart/ChartWidget.vue +45 -12
- package/dist/custom/widgets/chart/chart.types.d.ts +15 -0
- package/dist/custom/widgets/chart/chart.types.js +46 -0
- package/dist/custom/widgets/chart/chart.types.ts +83 -0
- 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 +63 -12
- package/dist/custom/widgets/kpi-card/KpiCardWidget.vue +6 -8
- package/dist/custom/widgets/pivot-table/PivotTableWidget.vue +32 -12
- package/dist/custom/widgets/table/TableWidget.vue +155 -30
- package/dist/endpoint/widgets.d.ts +6 -1
- package/dist/endpoint/widgets.js +41 -6
- package/dist/schema/api.d.ts +874 -444
- package/dist/schema/api.js +11 -2
- package/dist/schema/widget.d.ts +538 -132
- package/dist/schema/widget.js +138 -14
- package/dist/services/widgetConfigValidator.js +26 -40
- package/dist/services/widgetDataService.d.ts +7 -14
- package/dist/services/widgetDataService.js +115 -11
- package/endpoint/widgets.ts +56 -6
- package/package.json +1 -1
- package/schema/api.ts +11 -1
- package/schema/widget.ts +145 -15
- package/services/widgetConfigValidator.ts +36 -44
- package/services/widgetDataService.ts +175 -28
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
:rows="rows"
|
|
27
27
|
:x-field="xField"
|
|
28
28
|
:y-field="yField"
|
|
29
|
-
:series-name="chartConfig.
|
|
29
|
+
:series-name="chartConfig.seriesName"
|
|
30
30
|
:color="chartConfig.color"
|
|
31
31
|
:height="chartHeight"
|
|
32
32
|
/>
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
import { computed, watch } from 'vue'
|
|
92
92
|
import { useWidgetData } from '../../queries/useWidgetData.js'
|
|
93
93
|
import type { DashboardWidgetConfig, DashboardWidgetTableData } from '../../model/dashboard.types.js'
|
|
94
|
+
import { normalizeChartWidgetConfig } from './chart.types.js'
|
|
94
95
|
import BarChart from './bar/BarChart.vue'
|
|
95
96
|
import FunnelChart from './funnel/FunnelChart.vue'
|
|
96
97
|
import HistogramChart from './histogram/HistogramChart.vue'
|
|
@@ -126,13 +127,45 @@ watch(
|
|
|
126
127
|
const chartData = computed(() => data.value?.data as DashboardWidgetTableData | null)
|
|
127
128
|
const rows = computed(() => chartData.value?.rows ?? [])
|
|
128
129
|
const columns = computed(() => chartData.value?.columns ?? [])
|
|
129
|
-
const chartConfig = computed(() => props.widget.chart)
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
130
|
+
const chartConfig = computed(() => normalizeChartWidgetConfig(props.widget.chart))
|
|
131
|
+
const aggregateGroupField = computed(() => {
|
|
132
|
+
const dataSource = props.widget.dataSource
|
|
133
|
+
|
|
134
|
+
if (dataSource?.type !== 'aggregate' || !dataSource.groupBy) {
|
|
135
|
+
return undefined
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return dataSource.groupBy.field
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
function resolveChartDimensionField(field: string | undefined, fallbackField: string | undefined) {
|
|
142
|
+
const resolvedField = field ?? fallbackField
|
|
143
|
+
|
|
144
|
+
if (!resolvedField) {
|
|
145
|
+
return ''
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (columns.value.includes(resolvedField)) {
|
|
149
|
+
return resolvedField
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (
|
|
153
|
+
aggregateGroupField.value
|
|
154
|
+
&& resolvedField === aggregateGroupField.value
|
|
155
|
+
&& columns.value.includes('group')
|
|
156
|
+
) {
|
|
157
|
+
return 'group'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return resolvedField
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const xField = computed(() => resolveChartDimensionField(chartConfig.value?.xField, columns.value[0]))
|
|
164
|
+
const yField = computed(() => chartConfig.value?.yField || columns.value[1])
|
|
165
|
+
const labelField = computed(() => resolveChartDimensionField(chartConfig.value?.labelField, columns.value[0]))
|
|
166
|
+
const valueField = computed(() => chartConfig.value?.valueField || columns.value[1])
|
|
134
167
|
const pieRows = computed(() => {
|
|
135
|
-
if (chartConfig.value?.
|
|
168
|
+
if (chartConfig.value?.valueField) {
|
|
136
169
|
return rows.value
|
|
137
170
|
}
|
|
138
171
|
|
|
@@ -147,10 +180,10 @@ const pieRows = computed(() => {
|
|
|
147
180
|
|
|
148
181
|
return Array.from(groupedRows.values())
|
|
149
182
|
})
|
|
150
|
-
const pieLabelField = computed(() => chartConfig.value?.
|
|
151
|
-
const pieValueField = computed(() => chartConfig.value?.
|
|
183
|
+
const pieLabelField = computed(() => chartConfig.value?.valueField ? labelField.value : 'label')
|
|
184
|
+
const pieValueField = computed(() => chartConfig.value?.valueField ? valueField.value : 'value')
|
|
152
185
|
const barRows = computed(() => {
|
|
153
|
-
const bucketField = chartConfig.value?.
|
|
186
|
+
const bucketField = chartConfig.value?.bucketField
|
|
154
187
|
|
|
155
188
|
if (!bucketField) {
|
|
156
189
|
return rows.value
|
|
@@ -167,8 +200,8 @@ const barRows = computed(() => {
|
|
|
167
200
|
}).length,
|
|
168
201
|
}))
|
|
169
202
|
})
|
|
170
|
-
const barLabelField = computed(() => chartConfig.value?.
|
|
171
|
-
const barValueField = computed(() => chartConfig.value?.
|
|
203
|
+
const barLabelField = computed(() => chartConfig.value?.bucketField ? 'label' : labelField.value)
|
|
204
|
+
const barValueField = computed(() => chartConfig.value?.bucketField ? 'count' : valueField.value)
|
|
172
205
|
const stackedBarSeries = computed(() => {
|
|
173
206
|
if (chartConfig.value?.series?.length) {
|
|
174
207
|
return chartConfig.value.series
|
|
@@ -32,3 +32,86 @@ export type ChartWidgetConfig = {
|
|
|
32
32
|
color?: string
|
|
33
33
|
colors?: string[]
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
export type NormalizedChartWidgetConfig = {
|
|
37
|
+
type: ChartWidgetType
|
|
38
|
+
title?: string
|
|
39
|
+
xField?: string
|
|
40
|
+
yField?: string
|
|
41
|
+
labelField?: string
|
|
42
|
+
valueField?: string
|
|
43
|
+
bucketField?: string
|
|
44
|
+
buckets?: ChartWidgetBucketConfig[]
|
|
45
|
+
series?: ChartWidgetSeriesConfig[]
|
|
46
|
+
seriesName?: string
|
|
47
|
+
color?: string
|
|
48
|
+
colors?: string[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function normalizeChartWidgetConfig(value: unknown): NormalizedChartWidgetConfig | undefined {
|
|
52
|
+
const config = asChartWidgetConfigRecord(value)
|
|
53
|
+
|
|
54
|
+
if (!config) {
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const type = normalizeChartWidgetType(config.type)
|
|
59
|
+
|
|
60
|
+
if (!type) {
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const xField = getStringField(config, 'x_field')
|
|
65
|
+
const yField = getStringField(config, 'y_field')
|
|
66
|
+
const labelField = getStringField(config, 'label_field')
|
|
67
|
+
const valueField = getStringField(config, 'value_field')
|
|
68
|
+
const bucketField = getStringField(config, 'bucket_field')
|
|
69
|
+
const seriesName = getStringField(config, 'series_name')
|
|
70
|
+
const title = getStringField(config, 'title')
|
|
71
|
+
const color = getStringField(config, 'color')
|
|
72
|
+
const colors = Array.isArray(config.colors) ? config.colors as string[] : undefined
|
|
73
|
+
const buckets = Array.isArray(config.buckets) ? config.buckets as ChartWidgetBucketConfig[] : undefined
|
|
74
|
+
const series = Array.isArray(config.series) ? config.series as ChartWidgetSeriesConfig[] : undefined
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
type,
|
|
78
|
+
...(title !== undefined ? { title } : {}),
|
|
79
|
+
...(xField !== undefined ? { xField } : {}),
|
|
80
|
+
...(yField !== undefined ? { yField } : {}),
|
|
81
|
+
...(labelField !== undefined ? { labelField } : {}),
|
|
82
|
+
...(valueField !== undefined ? { valueField } : {}),
|
|
83
|
+
...(bucketField !== undefined ? { bucketField } : {}),
|
|
84
|
+
...(buckets !== undefined ? { buckets } : {}),
|
|
85
|
+
...(series !== undefined ? { series } : {}),
|
|
86
|
+
...(seriesName !== undefined ? { seriesName } : {}),
|
|
87
|
+
...(color !== undefined ? { color } : {}),
|
|
88
|
+
...(colors !== undefined ? { colors } : {}),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeChartWidgetType(value: unknown): ChartWidgetType | undefined {
|
|
93
|
+
switch (value) {
|
|
94
|
+
case 'line':
|
|
95
|
+
case 'pie':
|
|
96
|
+
case 'bar':
|
|
97
|
+
case 'stacked_bar':
|
|
98
|
+
case 'funnel':
|
|
99
|
+
case 'histogram':
|
|
100
|
+
return value
|
|
101
|
+
default:
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function asChartWidgetConfigRecord(value: unknown): Record<string, unknown> | undefined {
|
|
107
|
+
return isRecord(value) ? value : undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getStringField(record: Record<string, unknown>, key: string) {
|
|
111
|
+
const value = record[key]
|
|
112
|
+
return typeof value === 'string' ? value : undefined
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
116
|
+
return typeof value === 'object' && value !== null
|
|
117
|
+
}
|
|
@@ -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) {
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, watch } from 'vue'
|
|
3
3
|
import { useWidgetData } from '../../queries/useWidgetData.js'
|
|
4
|
+
import {
|
|
5
|
+
normalizeGaugeCardWidgetConfig,
|
|
6
|
+
} from '../../model/dashboard.types.js'
|
|
4
7
|
import type { DashboardWidgetConfig, DashboardWidgetTableData } from '../../model/dashboard.types.js'
|
|
5
8
|
import { CHART_COLORS, formatChartValue, toFiniteNumber } from '../chart/chart.utils.js'
|
|
6
9
|
|
|
@@ -18,6 +21,32 @@ const {
|
|
|
18
21
|
refetch,
|
|
19
22
|
} = useWidgetData(dashboardSlugRef, widgetIdRef)
|
|
20
23
|
|
|
24
|
+
function parseOptionalNumber(value: unknown): number | undefined {
|
|
25
|
+
if (value === null || value === undefined || value === '') {
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const parsed = typeof value === 'number' ? value : Number(value)
|
|
30
|
+
return Number.isFinite(parsed) ? parsed : undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function countFractionDigits(value: number) {
|
|
34
|
+
if (!Number.isFinite(value)) {
|
|
35
|
+
return 0
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const normalizedValue = value.toString().toLowerCase()
|
|
39
|
+
const [coefficient, exponentValue] = normalizedValue.split('e')
|
|
40
|
+
const exponent = exponentValue ? Number(exponentValue) : 0
|
|
41
|
+
const decimalDigits = coefficient.split('.')[1]?.length ?? 0
|
|
42
|
+
|
|
43
|
+
return Math.max(decimalDigits - exponent, 0)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeDisplayValue(value: number, useWholeNumbers: boolean) {
|
|
47
|
+
return useWholeNumbers ? Math.trunc(value) : value
|
|
48
|
+
}
|
|
49
|
+
|
|
21
50
|
watch(
|
|
22
51
|
() => props.widget,
|
|
23
52
|
() => {
|
|
@@ -26,20 +55,42 @@ watch(
|
|
|
26
55
|
{ deep: true },
|
|
27
56
|
)
|
|
28
57
|
|
|
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)
|
|
58
|
+
const gaugeConfig = computed(() => normalizeGaugeCardWidgetConfig(props.widget.gauge_card))
|
|
36
59
|
const widgetData = computed(() => data.value?.data as DashboardWidgetTableData | null)
|
|
37
60
|
const columns = computed(() => widgetData.value?.columns ?? [])
|
|
38
61
|
const firstRow = computed(() => widgetData.value?.rows[0] ?? {})
|
|
39
|
-
const valueField = computed(() => gaugeConfig.value?.
|
|
40
|
-
const
|
|
41
|
-
const
|
|
62
|
+
const valueField = computed(() => gaugeConfig.value?.valueField || columns.value[0])
|
|
63
|
+
const minField = computed(() => gaugeConfig.value?.minField)
|
|
64
|
+
const maxField = computed(() => gaugeConfig.value?.maxField)
|
|
65
|
+
const minValue = computed(() => {
|
|
66
|
+
const dynamicMin = minField.value ? parseOptionalNumber(firstRow.value[minField.value]) : undefined
|
|
67
|
+
return dynamicMin ?? parseOptionalNumber(gaugeConfig.value?.min) ?? 0
|
|
68
|
+
})
|
|
69
|
+
const maxValue = computed(() => {
|
|
70
|
+
const dynamicMax = maxField.value ? parseOptionalNumber(firstRow.value[maxField.value]) : undefined
|
|
71
|
+
return dynamicMax ?? parseOptionalNumber(gaugeConfig.value?.max) ?? 100
|
|
72
|
+
})
|
|
42
73
|
const value = computed(() => toFiniteNumber(firstRow.value[valueField.value]))
|
|
74
|
+
const fractionDigits = computed(() => Math.min([
|
|
75
|
+
value.value,
|
|
76
|
+
minValue.value,
|
|
77
|
+
maxValue.value,
|
|
78
|
+
].reduce((maxDigits, currentValue) => Math.max(maxDigits, countFractionDigits(currentValue)), 0), 3))
|
|
79
|
+
const shouldUseWholeNumbers = computed(() => Math.abs(maxValue.value) >= 1000)
|
|
80
|
+
const formattedValue = computed(() => formatChartValue(normalizeDisplayValue(value.value, shouldUseWholeNumbers.value), {
|
|
81
|
+
minimumFractionDigits: shouldUseWholeNumbers.value ? 0 : fractionDigits.value,
|
|
82
|
+
maximumFractionDigits: shouldUseWholeNumbers.value ? 0 : fractionDigits.value,
|
|
83
|
+
}))
|
|
84
|
+
const formattedMinValue = computed(() => formatChartValue(normalizeDisplayValue(minValue.value, shouldUseWholeNumbers.value), {
|
|
85
|
+
minimumFractionDigits: shouldUseWholeNumbers.value ? 0 : fractionDigits.value,
|
|
86
|
+
maximumFractionDigits: shouldUseWholeNumbers.value ? 0 : fractionDigits.value,
|
|
87
|
+
}))
|
|
88
|
+
const formattedMaxValue = computed(() => {
|
|
89
|
+
return formatChartValue(normalizeDisplayValue(maxValue.value, shouldUseWholeNumbers.value), {
|
|
90
|
+
minimumFractionDigits: shouldUseWholeNumbers.value ? 0 : fractionDigits.value,
|
|
91
|
+
maximumFractionDigits: shouldUseWholeNumbers.value ? 0 : fractionDigits.value,
|
|
92
|
+
})
|
|
93
|
+
})
|
|
43
94
|
const progress = computed(() => {
|
|
44
95
|
const range = maxValue.value - minValue.value
|
|
45
96
|
return range > 0 ? Math.min(Math.max((value.value - minValue.value) / range, 0), 1) : 0
|
|
@@ -97,10 +148,10 @@ const gaugeColor = computed(() => gaugeConfig.value?.color || CHART_COLORS[0])
|
|
|
97
148
|
</svg>
|
|
98
149
|
|
|
99
150
|
<div class="text-3xl font-bold text-lightNavbarText dark:text-darkNavbarText">
|
|
100
|
-
{{
|
|
151
|
+
{{ formattedValue }}{{ gaugeConfig?.suffix ?? '' }}
|
|
101
152
|
</div>
|
|
102
153
|
<div class="text-sm text-lightListTableText dark:text-darkListTableText">
|
|
103
|
-
{{
|
|
154
|
+
{{ formattedMinValue }} - {{ formattedMaxValue }}
|
|
104
155
|
</div>
|
|
105
156
|
</div>
|
|
106
157
|
</div>
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, watch } from 'vue'
|
|
3
3
|
import { useWidgetData } from '../../queries/useWidgetData.js'
|
|
4
|
+
import {
|
|
5
|
+
normalizeKpiCardWidgetConfig,
|
|
6
|
+
} from '../../model/dashboard.types.js'
|
|
4
7
|
import type { DashboardWidgetConfig, DashboardWidgetTableData } from '../../model/dashboard.types.js'
|
|
5
8
|
import { formatChartValue, toFiniteNumber } from '../chart/chart.utils.js'
|
|
6
9
|
|
|
@@ -26,17 +29,12 @@ watch(
|
|
|
26
29
|
{ deep: true },
|
|
27
30
|
)
|
|
28
31
|
|
|
29
|
-
const kpiConfig = computed(() => props.widget.kpi_card
|
|
30
|
-
value_field?: string
|
|
31
|
-
label_field?: string
|
|
32
|
-
prefix?: string
|
|
33
|
-
suffix?: string
|
|
34
|
-
} | undefined)
|
|
32
|
+
const kpiConfig = computed(() => normalizeKpiCardWidgetConfig(props.widget.kpi_card))
|
|
35
33
|
const widgetData = computed(() => data.value?.data as DashboardWidgetTableData | null)
|
|
36
34
|
const columns = computed(() => widgetData.value?.columns ?? [])
|
|
37
35
|
const firstRow = computed(() => widgetData.value?.rows[0] ?? {})
|
|
38
|
-
const valueField = computed(() => kpiConfig.value?.
|
|
39
|
-
const labelField = computed(() => kpiConfig.value?.
|
|
36
|
+
const valueField = computed(() => kpiConfig.value?.valueField || columns.value[0])
|
|
37
|
+
const labelField = computed(() => kpiConfig.value?.labelField)
|
|
40
38
|
const value = computed(() => toFiniteNumber(firstRow.value[valueField.value]))
|
|
41
39
|
const label = computed(() => labelField.value ? String(firstRow.value[labelField.value]) : props.widget.label)
|
|
42
40
|
const formattedValue = computed(() => `${kpiConfig.value?.prefix ?? ''}${formatChartValue(value.value)}${kpiConfig.value?.suffix ?? ''}`)
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, watch } from 'vue'
|
|
3
3
|
import { useWidgetData } from '../../queries/useWidgetData.js'
|
|
4
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
normalizePivotTableWidgetConfig,
|
|
6
|
+
} from '../../model/dashboard.types.js'
|
|
7
|
+
import type { DashboardWidgetConfig, DashboardWidgetData } from '../../model/dashboard.types.js'
|
|
5
8
|
import { formatChartLabel, formatChartValue, toFiniteNumber } from '../chart/chart.utils.js'
|
|
6
9
|
|
|
7
10
|
const props = defineProps<{
|
|
@@ -26,21 +29,38 @@ watch(
|
|
|
26
29
|
{ deep: true },
|
|
27
30
|
)
|
|
28
31
|
|
|
29
|
-
const pivotConfig = computed(() => props.widget.pivot_table
|
|
30
|
-
|
|
31
|
-
column_field?: string
|
|
32
|
-
value_field?: string
|
|
33
|
-
aggregation?: 'count' | 'sum'
|
|
34
|
-
} | undefined)
|
|
35
|
-
const widgetData = computed(() => data.value?.data as DashboardWidgetTableData | null)
|
|
32
|
+
const pivotConfig = computed(() => normalizePivotTableWidgetConfig(props.widget.pivot_table))
|
|
33
|
+
const widgetData = computed(() => data.value?.data as DashboardWidgetData | null)
|
|
36
34
|
const rows = computed(() => widgetData.value?.rows ?? [])
|
|
37
35
|
const columns = computed(() => widgetData.value?.columns ?? [])
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
const
|
|
36
|
+
const isAggregateData = computed(() => widgetData.value?.kind === 'aggregate')
|
|
37
|
+
const shouldRenderAggregateMatrix = computed(() => isAggregateData.value && !pivotConfig.value?.columnField)
|
|
38
|
+
const rowField = computed(() => pivotConfig.value?.rowField || (isAggregateData.value ? 'group' : columns.value[0]))
|
|
39
|
+
const columnField = computed(() => pivotConfig.value?.columnField || columns.value[1])
|
|
40
|
+
const valueField = computed(() => pivotConfig.value?.valueField || columns.value[2] || columns.value[1])
|
|
41
41
|
const aggregation = computed(() => pivotConfig.value?.aggregation || (valueField.value ? 'sum' : 'count'))
|
|
42
|
-
const pivotColumnLabels = computed(() =>
|
|
42
|
+
const pivotColumnLabels = computed(() => {
|
|
43
|
+
if (shouldRenderAggregateMatrix.value) {
|
|
44
|
+
return columns.value.filter((column) => column !== rowField.value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return Array.from(new Set(rows.value.map((row) => formatChartLabel(row[columnField.value]))))
|
|
48
|
+
})
|
|
43
49
|
const pivotRows = computed(() => {
|
|
50
|
+
if (shouldRenderAggregateMatrix.value) {
|
|
51
|
+
return rows.value.map((row) => {
|
|
52
|
+
const item: Record<string, number | string> = {
|
|
53
|
+
label: formatChartLabel(row[rowField.value]),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const column of pivotColumnLabels.value) {
|
|
57
|
+
item[column] = toFiniteNumber(row[column])
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return item
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
const rowMap = new Map<string, Record<string, number | string>>()
|
|
45
65
|
|
|
46
66
|
for (const row of rows.value) {
|
|
@@ -23,37 +23,87 @@
|
|
|
23
23
|
|
|
24
24
|
<div
|
|
25
25
|
v-else
|
|
26
|
-
class="min-h-0 flex-1
|
|
26
|
+
class="flex min-h-0 flex-1 flex-col"
|
|
27
27
|
>
|
|
28
|
-
<
|
|
29
|
-
<
|
|
30
|
-
<
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
<div class="min-h-0 flex-1 overflow-auto">
|
|
29
|
+
<table class="min-w-max w-full border-collapse text-left text-sm">
|
|
30
|
+
<thead class="bg-lightTableHeadingBackground text-xs uppercase text-lightTableHeadingText dark:bg-darkTableHeadingBackground dark:text-darkTableHeadingText">
|
|
31
|
+
<tr>
|
|
32
|
+
<th
|
|
33
|
+
v-for="column in columns"
|
|
34
|
+
:key="column"
|
|
35
|
+
class="px-3 py-2 font-semibold"
|
|
36
|
+
>
|
|
37
|
+
{{ column }}
|
|
38
|
+
</th>
|
|
39
|
+
</tr>
|
|
40
|
+
</thead>
|
|
41
|
+
|
|
42
|
+
<tbody>
|
|
43
|
+
<tr
|
|
44
|
+
v-for="(row, index) in tableData.rows"
|
|
45
|
+
:key="`${currentPage}-${index}`"
|
|
46
|
+
class="border-t border-lightListBorder odd:bg-lightTableOddBackground even:bg-lightTableEvenBackground dark:border-darkListBorder odd:dark:bg-darkTableOddBackground even:dark:bg-darkTableEvenBackground"
|
|
35
47
|
>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
<td
|
|
49
|
+
v-for="column in columns"
|
|
50
|
+
:key="column"
|
|
51
|
+
class="px-3 py-2 text-lightListTableText dark:text-darkListTableText"
|
|
52
|
+
>
|
|
53
|
+
{{ formatCell(row[column]) }}
|
|
54
|
+
</td>
|
|
55
|
+
</tr>
|
|
56
|
+
</tbody>
|
|
57
|
+
</table>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div
|
|
61
|
+
v-if="pagination"
|
|
62
|
+
class="flex flex-wrap items-center justify-between gap-2 border-t border-lightListBorder px-3 py-2 text-sm text-lightListTableText dark:border-darkListBorder dark:text-darkListTableText"
|
|
63
|
+
>
|
|
64
|
+
<div>
|
|
65
|
+
{{ pageStart }}-{{ pageEnd }} of {{ pagination.total }}
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div class="flex items-center gap-2">
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
class="flex h-8 w-8 items-center justify-center rounded border border-lightListBorder text-sm disabled:opacity-45 dark:border-darkListBorder"
|
|
72
|
+
:disabled="currentPage <= 1 || isFetching"
|
|
73
|
+
@click="currentPage -= 1"
|
|
74
|
+
aria-label="Previous page"
|
|
46
75
|
>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
76
|
+
<
|
|
77
|
+
</button>
|
|
78
|
+
|
|
79
|
+
<span class="flex items-center gap-1">
|
|
80
|
+
<span>Page</span>
|
|
81
|
+
<input
|
|
82
|
+
v-model.number="currentPageInput"
|
|
83
|
+
type="number"
|
|
84
|
+
min="1"
|
|
85
|
+
:max="pagination.totalPages"
|
|
86
|
+
class="dashboard-table-page-input h-8 min-w-8 rounded border border-lightListBorder bg-lightTableBackground px-2 text-center text-sm text-lightListTableText dark:border-darkListBorder dark:bg-darkTableBackground dark:text-darkListTableText"
|
|
87
|
+
:style="{ width: `${currentPageInputWidth}ch` }"
|
|
88
|
+
:disabled="isFetching"
|
|
89
|
+
aria-label="Current page"
|
|
90
|
+
@blur="applyCurrentPageInput"
|
|
91
|
+
@keydown.enter="applyCurrentPageInput"
|
|
51
92
|
>
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
93
|
+
<span>of {{ pagination.totalPages }}</span>
|
|
94
|
+
</span>
|
|
95
|
+
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
class="flex h-8 w-8 items-center justify-center rounded border border-lightListBorder text-sm disabled:opacity-45 dark:border-darkListBorder"
|
|
99
|
+
:disabled="currentPage >= pagination.totalPages || isFetching"
|
|
100
|
+
@click="currentPage += 1"
|
|
101
|
+
aria-label="Next page"
|
|
102
|
+
>
|
|
103
|
+
>
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
57
107
|
</div>
|
|
58
108
|
</div>
|
|
59
109
|
</template>
|
|
@@ -61,27 +111,52 @@
|
|
|
61
111
|
|
|
62
112
|
|
|
63
113
|
<script setup lang="ts">
|
|
64
|
-
import { computed, watch } from 'vue'
|
|
114
|
+
import { computed, ref, watch } from 'vue'
|
|
65
115
|
import { useWidgetData } from '../../queries/useWidgetData.js'
|
|
66
116
|
import type { DashboardWidgetConfig, DashboardWidgetTableData } from '../../model/dashboard.types.js'
|
|
67
117
|
|
|
118
|
+
type TableWidgetConfig = {
|
|
119
|
+
columns?: string[]
|
|
120
|
+
pagination?: boolean
|
|
121
|
+
pageSize?: number
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const DEFAULT_PAGE_SIZE = 10
|
|
125
|
+
|
|
68
126
|
const props = defineProps<{
|
|
69
127
|
dashboardSlug: string
|
|
70
128
|
widget: DashboardWidgetConfig
|
|
71
129
|
}>()
|
|
72
130
|
|
|
131
|
+
const currentPage = ref(1)
|
|
132
|
+
const currentPageInput = ref(1)
|
|
133
|
+
const tableConfig = computed(() => props.widget.table as TableWidgetConfig | undefined)
|
|
134
|
+
const isPaginationEnabled = computed(() => tableConfig.value?.pagination !== false)
|
|
135
|
+
const pageSize = computed(() => tableConfig.value?.pageSize ?? DEFAULT_PAGE_SIZE)
|
|
73
136
|
const dashboardSlugRef = computed(() => props.dashboardSlug)
|
|
74
137
|
const widgetIdRef = computed(() => props.widget.id)
|
|
138
|
+
const widgetDataRequest = computed(() => (
|
|
139
|
+
isPaginationEnabled.value
|
|
140
|
+
? {
|
|
141
|
+
pagination: {
|
|
142
|
+
page: currentPage.value,
|
|
143
|
+
pageSize: pageSize.value,
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
: {}
|
|
147
|
+
))
|
|
75
148
|
const {
|
|
76
149
|
data,
|
|
77
150
|
isLoading,
|
|
151
|
+
isFetching,
|
|
78
152
|
error,
|
|
79
153
|
refetch,
|
|
80
|
-
} = useWidgetData(dashboardSlugRef, widgetIdRef)
|
|
154
|
+
} = useWidgetData(dashboardSlugRef, widgetIdRef, widgetDataRequest)
|
|
81
155
|
|
|
82
156
|
watch(
|
|
83
157
|
() => props.widget,
|
|
84
158
|
() => {
|
|
159
|
+
currentPage.value = 1
|
|
85
160
|
void refetch()
|
|
86
161
|
},
|
|
87
162
|
{ deep: true },
|
|
@@ -92,10 +167,47 @@ const tableData = computed(() => {
|
|
|
92
167
|
})
|
|
93
168
|
|
|
94
169
|
const columns = computed(() => {
|
|
95
|
-
const configuredColumns =
|
|
170
|
+
const configuredColumns = tableConfig.value?.columns
|
|
96
171
|
return configuredColumns ?? tableData.value?.columns ?? []
|
|
97
172
|
})
|
|
98
173
|
|
|
174
|
+
const pagination = computed(() => tableData.value?.pagination)
|
|
175
|
+
const pageStart = computed(() => {
|
|
176
|
+
if (!pagination.value || pagination.value.total === 0) {
|
|
177
|
+
return 0
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return (pagination.value.page - 1) * pagination.value.pageSize + 1
|
|
181
|
+
})
|
|
182
|
+
const pageEnd = computed(() => {
|
|
183
|
+
if (!pagination.value) {
|
|
184
|
+
return 0
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return Math.min(pagination.value.page * pagination.value.pageSize, pagination.value.total)
|
|
188
|
+
})
|
|
189
|
+
const currentPageInputWidth = computed(() => {
|
|
190
|
+
const digits = String(currentPageInput.value || currentPage.value).length
|
|
191
|
+
return Math.max(digits + 3, 4)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
watch(pagination, (nextPagination) => {
|
|
195
|
+
if (nextPagination && currentPage.value > nextPagination.totalPages) {
|
|
196
|
+
currentPage.value = nextPagination.totalPages
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
watch(currentPage, (nextPage) => {
|
|
201
|
+
currentPageInput.value = nextPage
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
function applyCurrentPageInput() {
|
|
205
|
+
const totalPages = pagination.value?.totalPages ?? 1
|
|
206
|
+
const page = Number.isFinite(currentPageInput.value) ? currentPageInput.value : currentPage.value
|
|
207
|
+
currentPage.value = Math.min(Math.max(Math.trunc(page), 1), totalPages)
|
|
208
|
+
currentPageInput.value = currentPage.value
|
|
209
|
+
}
|
|
210
|
+
|
|
99
211
|
function formatCell(value: unknown) {
|
|
100
212
|
if (value === null || value === undefined) {
|
|
101
213
|
return ''
|
|
@@ -108,3 +220,16 @@ function formatCell(value: unknown) {
|
|
|
108
220
|
return String(value)
|
|
109
221
|
}
|
|
110
222
|
</script>
|
|
223
|
+
|
|
224
|
+
<style scoped>
|
|
225
|
+
.dashboard-table-page-input::-webkit-outer-spin-button,
|
|
226
|
+
.dashboard-table-page-input::-webkit-inner-spin-button {
|
|
227
|
+
margin: 0;
|
|
228
|
+
appearance: none;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.dashboard-table-page-input {
|
|
232
|
+
appearance: textfield;
|
|
233
|
+
-moz-appearance: textfield;
|
|
234
|
+
}
|
|
235
|
+
</style>
|
|
@@ -14,6 +14,12 @@ export type DashboardWidgetDataResponse = {
|
|
|
14
14
|
widget: DashboardWidgetConfig;
|
|
15
15
|
data: unknown;
|
|
16
16
|
};
|
|
17
|
+
export type DashboardWidgetDataRequest = {
|
|
18
|
+
pagination?: {
|
|
19
|
+
page: number;
|
|
20
|
+
pageSize: number;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
17
23
|
export declare class DashboardApiError extends Error {
|
|
18
24
|
validationErrors: DashboardWidgetConfigValidationError[];
|
|
19
25
|
constructor(message: string, validationErrors?: DashboardWidgetConfigValidationError[]);
|
|
@@ -28,5 +34,5 @@ export declare const dashboardApi: {
|
|
|
28
34
|
moveDashboardWidget(slug: string, widgetId: string, direction: DashboardWidgetMoveDirection): Promise<DashboardResponse>;
|
|
29
35
|
removeDashboardWidget(slug: string, widgetId: string): Promise<DashboardResponse>;
|
|
30
36
|
setWidgetConfig(slug: string, widgetId: string, config: DashboardWidgetConfig): Promise<DashboardResponse>;
|
|
31
|
-
getDashboardWidgetData(slug: string, widgetId: string): Promise<DashboardWidgetDataResponse>;
|
|
37
|
+
getDashboardWidgetData(slug: string, widgetId: string, request?: DashboardWidgetDataRequest): Promise<DashboardWidgetDataResponse>;
|
|
32
38
|
};
|
|
@@ -168,12 +168,10 @@ exports.dashboardApi = {
|
|
|
168
168
|
});
|
|
169
169
|
});
|
|
170
170
|
},
|
|
171
|
-
getDashboardWidgetData(
|
|
172
|
-
return __awaiter(this,
|
|
173
|
-
return callDashboardWidgetDataApi('/adminapi/v1/dashboard/get_dashboard_widget_data', {
|
|
174
|
-
|
|
175
|
-
widgetId,
|
|
176
|
-
});
|
|
171
|
+
getDashboardWidgetData(slug_1, widgetId_1) {
|
|
172
|
+
return __awaiter(this, arguments, void 0, function* (slug, widgetId, request = {}) {
|
|
173
|
+
return callDashboardWidgetDataApi('/adminapi/v1/dashboard/get_dashboard_widget_data', Object.assign({ slug,
|
|
174
|
+
widgetId }, request));
|
|
177
175
|
});
|
|
178
176
|
},
|
|
179
177
|
};
|