@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.
Files changed (55) hide show
  1. package/README.md +99 -54
  2. package/custom/api/dashboardApi.ts +9 -0
  3. package/custom/model/dashboard.types.ts +353 -2
  4. package/custom/queries/useWidgetData.ts +8 -4
  5. package/custom/runtime/DashboardRuntime.vue +2 -1
  6. package/custom/runtime/WidgetRenderer.vue +2 -1
  7. package/custom/runtime/WidgetShell.vue +8 -4
  8. package/custom/skills/adminforth-dashboard/SKILL.md +4 -4
  9. package/custom/widgets/chart/ChartWidget.vue +45 -12
  10. package/custom/widgets/chart/chart.types.ts +83 -0
  11. package/custom/widgets/chart/chart.utils.ts +2 -2
  12. package/custom/widgets/gauge-card/GaugeCardWidget.vue +63 -12
  13. package/custom/widgets/kpi-card/KpiCardWidget.vue +6 -8
  14. package/custom/widgets/pivot-table/PivotTableWidget.vue +32 -12
  15. package/custom/widgets/table/TableWidget.vue +155 -30
  16. package/dist/custom/api/dashboardApi.d.ts +7 -1
  17. package/dist/custom/api/dashboardApi.js +4 -6
  18. package/dist/custom/api/dashboardApi.ts +9 -0
  19. package/dist/custom/model/dashboard.types.d.ts +70 -1
  20. package/dist/custom/model/dashboard.types.js +173 -1
  21. package/dist/custom/model/dashboard.types.ts +353 -2
  22. package/dist/custom/queries/useDashboardConfig.d.ts +42 -2
  23. package/dist/custom/queries/useWidgetData.d.ts +44 -3
  24. package/dist/custom/queries/useWidgetData.js +3 -3
  25. package/dist/custom/queries/useWidgetData.ts +8 -4
  26. package/dist/custom/runtime/DashboardRuntime.vue +2 -1
  27. package/dist/custom/runtime/WidgetRenderer.vue +2 -1
  28. package/dist/custom/runtime/WidgetShell.vue +8 -4
  29. package/dist/custom/skills/adminforth-dashboard/SKILL.md +4 -4
  30. package/dist/custom/widgets/chart/ChartWidget.vue +45 -12
  31. package/dist/custom/widgets/chart/chart.types.d.ts +15 -0
  32. package/dist/custom/widgets/chart/chart.types.js +46 -0
  33. package/dist/custom/widgets/chart/chart.types.ts +83 -0
  34. package/dist/custom/widgets/chart/chart.utils.d.ts +1 -1
  35. package/dist/custom/widgets/chart/chart.utils.js +2 -2
  36. package/dist/custom/widgets/chart/chart.utils.ts +2 -2
  37. package/dist/custom/widgets/gauge-card/GaugeCardWidget.vue +63 -12
  38. package/dist/custom/widgets/kpi-card/KpiCardWidget.vue +6 -8
  39. package/dist/custom/widgets/pivot-table/PivotTableWidget.vue +32 -12
  40. package/dist/custom/widgets/table/TableWidget.vue +155 -30
  41. package/dist/endpoint/widgets.d.ts +6 -1
  42. package/dist/endpoint/widgets.js +41 -6
  43. package/dist/schema/api.d.ts +874 -444
  44. package/dist/schema/api.js +11 -2
  45. package/dist/schema/widget.d.ts +538 -132
  46. package/dist/schema/widget.js +138 -14
  47. package/dist/services/widgetConfigValidator.js +26 -40
  48. package/dist/services/widgetDataService.d.ts +7 -14
  49. package/dist/services/widgetDataService.js +115 -11
  50. package/endpoint/widgets.ts +56 -6
  51. package/package.json +1 -1
  52. package/schema/api.ts +11 -1
  53. package/schema/widget.ts +145 -15
  54. package/services/widgetConfigValidator.ts +36 -44
  55. 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.series_name"
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 xField = computed(() => chartConfig.value?.x_field || columns.value[0])
131
- const yField = computed(() => chartConfig.value?.y_field || columns.value[1])
132
- const labelField = computed(() => chartConfig.value?.label_field || columns.value[0])
133
- const valueField = computed(() => chartConfig.value?.value_field || columns.value[1])
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?.value_field) {
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?.value_field ? labelField.value : 'label')
151
- const pieValueField = computed(() => chartConfig.value?.value_field ? valueField.value : '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?.bucket_field
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?.bucket_field ? 'label' : labelField.value)
171
- const barValueField = computed(() => chartConfig.value?.bucket_field ? 'count' : valueField.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 as {
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?.value_field || columns.value[0])
40
- const minValue = computed(() => gaugeConfig.value?.min ?? 0)
41
- const maxValue = computed(() => gaugeConfig.value?.max ?? 100)
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
- {{ formatChartValue(value) }}{{ gaugeConfig?.suffix ?? '' }}
151
+ {{ formattedValue }}{{ gaugeConfig?.suffix ?? '' }}
101
152
  </div>
102
153
  <div class="text-sm text-lightListTableText dark:text-darkListTableText">
103
- {{ formatChartValue(minValue) }} - {{ formatChartValue(maxValue) }}
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 as {
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?.value_field || columns.value[0])
39
- const labelField = computed(() => kpiConfig.value?.label_field)
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 type { DashboardWidgetConfig, DashboardWidgetTableData } from '../../model/dashboard.types.js'
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 as {
30
- row_field?: string
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 rowField = computed(() => pivotConfig.value?.row_field || columns.value[0])
39
- const columnField = computed(() => pivotConfig.value?.column_field || columns.value[1])
40
- const valueField = computed(() => pivotConfig.value?.value_field || columns.value[2])
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(() => Array.from(new Set(rows.value.map((row) => formatChartLabel(row[columnField.value])))))
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 overflow-auto"
26
+ class="flex min-h-0 flex-1 flex-col"
27
27
  >
28
- <table class="min-w-max w-full border-collapse text-left text-sm">
29
- <thead class="bg-lightTableHeadingBackground text-xs uppercase text-lightTableHeadingText dark:bg-darkTableHeadingBackground dark:text-darkTableHeadingText">
30
- <tr>
31
- <th
32
- v-for="column in columns"
33
- :key="column"
34
- class="px-3 py-2 font-semibold"
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
- {{ column }}
37
- </th>
38
- </tr>
39
- </thead>
40
-
41
- <tbody>
42
- <tr
43
- v-for="(row, index) in tableData.rows"
44
- :key="index"
45
- class="border-t border-lightListBorder odd:bg-lightTableOddBackground even:bg-lightTableEvenBackground dark:border-darkListBorder odd:dark:bg-darkTableOddBackground even:dark:bg-darkTableEvenBackground"
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
- <td
48
- v-for="column in columns"
49
- :key="column"
50
- class="px-3 py-2 text-lightListTableText dark:text-darkListTableText"
76
+ &lt;
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
- {{ formatCell(row[column]) }}
53
- </td>
54
- </tr>
55
- </tbody>
56
- </table>
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
+ &gt;
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 = (props.widget.table as { columns?: string[] } | undefined)?.columns
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(slug, widgetId) {
172
- return __awaiter(this, void 0, void 0, function* () {
173
- return callDashboardWidgetDataApi('/adminapi/v1/dashboard/get_dashboard_widget_data', {
174
- slug,
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
  };