@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.
Files changed (41) hide show
  1. package/README.md +116 -54
  2. package/custom/api/dashboardApi.ts +9 -0
  3. package/custom/model/dashboard.types.ts +158 -1
  4. package/custom/queries/useWidgetData.ts +8 -4
  5. package/custom/runtime/WidgetShell.vue +8 -4
  6. package/custom/widgets/chart/chart.utils.ts +2 -2
  7. package/custom/widgets/gauge-card/GaugeCardWidget.vue +94 -12
  8. package/custom/widgets/pivot-table/PivotTableWidget.vue +27 -5
  9. package/custom/widgets/table/TableWidget.vue +155 -30
  10. package/dist/custom/api/dashboardApi.d.ts +7 -1
  11. package/dist/custom/api/dashboardApi.js +4 -6
  12. package/dist/custom/api/dashboardApi.ts +9 -0
  13. package/dist/custom/model/dashboard.types.d.ts +45 -0
  14. package/dist/custom/model/dashboard.types.js +82 -1
  15. package/dist/custom/model/dashboard.types.ts +158 -1
  16. package/dist/custom/queries/useDashboardConfig.d.ts +42 -0
  17. package/dist/custom/queries/useWidgetData.d.ts +44 -1
  18. package/dist/custom/queries/useWidgetData.js +3 -3
  19. package/dist/custom/queries/useWidgetData.ts +8 -4
  20. package/dist/custom/runtime/WidgetShell.vue +8 -4
  21. package/dist/custom/widgets/chart/chart.utils.d.ts +1 -1
  22. package/dist/custom/widgets/chart/chart.utils.js +2 -2
  23. package/dist/custom/widgets/chart/chart.utils.ts +2 -2
  24. package/dist/custom/widgets/gauge-card/GaugeCardWidget.vue +94 -12
  25. package/dist/custom/widgets/pivot-table/PivotTableWidget.vue +27 -5
  26. package/dist/custom/widgets/table/TableWidget.vue +155 -30
  27. package/dist/endpoint/widgets.d.ts +6 -1
  28. package/dist/endpoint/widgets.js +22 -4
  29. package/dist/schema/api.d.ts +882 -212
  30. package/dist/schema/api.js +11 -2
  31. package/dist/schema/widget.d.ts +542 -4
  32. package/dist/schema/widget.js +111 -1
  33. package/dist/services/widgetConfigValidator.js +32 -6
  34. package/dist/services/widgetDataService.d.ts +8 -6
  35. package/dist/services/widgetDataService.js +133 -11
  36. package/endpoint/widgets.ts +31 -4
  37. package/package.json +1 -1
  38. package/schema/api.ts +11 -1
  39. package/schema/widget.ts +114 -1
  40. package/services/widgetConfigValidator.ts +45 -6
  41. package/services/widgetDataService.ts +201 -19
@@ -23,6 +23,27 @@ export declare function useDashboardConfig(slug: Ref<string>): {
23
23
  maxWidth?: number | null | undefined;
24
24
  order: number;
25
25
  target: import("../model/dashboard.types.js").DashboardWidgetTarget;
26
+ dataSource?: {
27
+ type: "resource";
28
+ resourceId: string;
29
+ columns?: string[] | undefined;
30
+ sort?: unknown;
31
+ filters?: unknown;
32
+ } | {
33
+ type: "aggregate";
34
+ resourceId: string;
35
+ aggregations: Record<string, import("../model/dashboard.types.js").AggregationRule>;
36
+ groupBy?: {
37
+ type: "field";
38
+ field: string;
39
+ } | {
40
+ type: "date_trunc";
41
+ field: string;
42
+ truncation: "day" | "week" | "month" | "year";
43
+ timezone?: string | undefined;
44
+ } | undefined;
45
+ filters?: unknown;
46
+ } | undefined;
26
47
  chart?: {
27
48
  type: import("../widgets/chart/chart.types.js").ChartWidgetType;
28
49
  title?: string | undefined;
@@ -75,6 +96,27 @@ export declare function useDashboardConfig(slug: Ref<string>): {
75
96
  maxWidth?: number | null | undefined;
76
97
  order: number;
77
98
  target: import("../model/dashboard.types.js").DashboardWidgetTarget;
99
+ dataSource?: {
100
+ type: "resource";
101
+ resourceId: string;
102
+ columns?: string[] | undefined;
103
+ sort?: unknown;
104
+ filters?: unknown;
105
+ } | {
106
+ type: "aggregate";
107
+ resourceId: string;
108
+ aggregations: Record<string, import("../model/dashboard.types.js").AggregationRule>;
109
+ groupBy?: {
110
+ type: "field";
111
+ field: string;
112
+ } | {
113
+ type: "date_trunc";
114
+ field: string;
115
+ truncation: "day" | "week" | "month" | "year";
116
+ timezone?: string | undefined;
117
+ } | undefined;
118
+ filters?: unknown;
119
+ } | undefined;
78
120
  chart?: {
79
121
  type: import("../widgets/chart/chart.types.js").ChartWidgetType;
80
122
  title?: string | undefined;
@@ -1,5 +1,6 @@
1
1
  import { type Ref } from 'vue';
2
- export declare function useWidgetData(slug: Ref<string>, widgetId: Ref<string>): {
2
+ import { type DashboardWidgetDataRequest } from '../api/dashboardApi.js';
3
+ export declare function useWidgetData(slug: Ref<string>, widgetId: Ref<string>, request?: Ref<DashboardWidgetDataRequest>): {
3
4
  data: Ref<{
4
5
  widget: {
5
6
  id: string;
@@ -12,6 +13,27 @@ export declare function useWidgetData(slug: Ref<string>, widgetId: Ref<string>):
12
13
  maxWidth?: number | null | undefined;
13
14
  order: number;
14
15
  target: import("../model/dashboard.types.js").DashboardWidgetTarget;
16
+ dataSource?: {
17
+ type: "resource";
18
+ resourceId: string;
19
+ columns?: string[] | undefined;
20
+ sort?: unknown;
21
+ filters?: unknown;
22
+ } | {
23
+ type: "aggregate";
24
+ resourceId: string;
25
+ aggregations: Record<string, import("../model/dashboard.types.js").AggregationRule>;
26
+ groupBy?: {
27
+ type: "field";
28
+ field: string;
29
+ } | {
30
+ type: "date_trunc";
31
+ field: string;
32
+ truncation: "day" | "week" | "month" | "year";
33
+ timezone?: string | undefined;
34
+ } | undefined;
35
+ filters?: unknown;
36
+ } | undefined;
15
37
  chart?: {
16
38
  type: import("../widgets/chart/chart.types.js").ChartWidgetType;
17
39
  title?: string | undefined;
@@ -53,6 +75,27 @@ export declare function useWidgetData(slug: Ref<string>, widgetId: Ref<string>):
53
75
  maxWidth?: number | null | undefined;
54
76
  order: number;
55
77
  target: import("../model/dashboard.types.js").DashboardWidgetTarget;
78
+ dataSource?: {
79
+ type: "resource";
80
+ resourceId: string;
81
+ columns?: string[] | undefined;
82
+ sort?: unknown;
83
+ filters?: unknown;
84
+ } | {
85
+ type: "aggregate";
86
+ resourceId: string;
87
+ aggregations: Record<string, import("../model/dashboard.types.js").AggregationRule>;
88
+ groupBy?: {
89
+ type: "field";
90
+ field: string;
91
+ } | {
92
+ type: "date_trunc";
93
+ field: string;
94
+ truncation: "day" | "week" | "month" | "year";
95
+ timezone?: string | undefined;
96
+ } | undefined;
97
+ filters?: unknown;
98
+ } | undefined;
56
99
  chart?: {
57
100
  type: import("../widgets/chart/chart.types.js").ChartWidgetType;
58
101
  title?: string | undefined;
@@ -12,7 +12,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.useWidgetData = useWidgetData;
13
13
  const vue_1 = require("vue");
14
14
  const dashboardApi_js_1 = require("../api/dashboardApi.js");
15
- function useWidgetData(slug, widgetId) {
15
+ function useWidgetData(slug, widgetId, request) {
16
16
  const data = (0, vue_1.ref)(null);
17
17
  const isLoading = (0, vue_1.ref)(false);
18
18
  const isFetching = (0, vue_1.ref)(false);
@@ -29,7 +29,7 @@ function useWidgetData(slug, widgetId) {
29
29
  isLoading.value = true;
30
30
  }
31
31
  try {
32
- const response = yield dashboardApi_js_1.dashboardApi.getDashboardWidgetData(slug.value, widgetId.value);
32
+ const response = yield dashboardApi_js_1.dashboardApi.getDashboardWidgetData(slug.value, widgetId.value, request === null || request === void 0 ? void 0 : request.value);
33
33
  data.value = response;
34
34
  error.value = null;
35
35
  return response;
@@ -44,7 +44,7 @@ function useWidgetData(slug, widgetId) {
44
44
  }
45
45
  });
46
46
  }
47
- (0, vue_1.watch)([slug, widgetId], () => {
47
+ (0, vue_1.watch)(request ? [slug, widgetId, request] : [slug, widgetId], () => {
48
48
  void refetch();
49
49
  }, { immediate: true });
50
50
  return {
@@ -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(slug: Ref<string>, widgetId: Ref<string>) {
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
- ? 'none'
142
- : fixedWidth ?? formatWidth(props.layout?.maxWidth) ?? 'none',
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>
@@ -1,5 +1,5 @@
1
1
  export declare const CHART_COLORS: string[];
2
2
  export declare function toFiniteNumber(value: unknown): number;
3
- export declare function formatChartValue(value: number): string;
3
+ export declare function formatChartValue(value: number, options?: Intl.NumberFormatOptions): string;
4
4
  export declare function formatChartLabel(value: unknown): string;
5
5
  export declare function formatChartAxisLabel(value: unknown, maxLength?: number): string;
@@ -19,8 +19,8 @@ function toFiniteNumber(value) {
19
19
  const numberValue = typeof value === 'number' ? value : Number(value);
20
20
  return Number.isFinite(numberValue) ? numberValue : 0;
21
21
  }
22
- function formatChartValue(value) {
23
- return new Intl.NumberFormat().format(value);
22
+ function formatChartValue(value, options = {}) {
23
+ return new Intl.NumberFormat(undefined, options).format(value);
24
24
  }
25
25
  function formatChartLabel(value) {
26
26
  if (typeof value !== 'string') {
@@ -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 as {
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 minValue = computed(() => gaugeConfig.value?.min ?? 0)
41
- const maxValue = computed(() => gaugeConfig.value?.max ?? 100)
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
- {{ formatChartValue(value) }}{{ gaugeConfig?.suffix ?? '' }}
182
+ {{ formattedValue }}{{ gaugeConfig?.suffix ?? '' }}
101
183
  </div>
102
184
  <div class="text-sm text-lightListTableText dark:text-darkListTableText">
103
- {{ formatChartValue(minValue) }} - {{ formatChartValue(maxValue) }}
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, DashboardWidgetTableData } from '../../model/dashboard.types.js'
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 DashboardWidgetTableData | null)
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 rowField = computed(() => pivotConfig.value?.row_field || columns.value[0])
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(() => Array.from(new Set(rows.value.map((row) => formatChartLabel(row[columnField.value])))))
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) {
@@ -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 ?? props.widget.query?.limit ?? 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>
@@ -9,7 +9,12 @@ type WidgetEndpointsContext = {
9
9
  persistDashboardConfig: (dashboard: DashboardRecord, config: DashboardConfig) => Promise<PersistedDashboardResponse>;
10
10
  buildDashboardResponse: (dashboard: DashboardRecord) => PersistedDashboardResponse;
11
11
  validateDashboardWidgetApiConfig: (widget: DashboardWidgetConfig) => DashboardWidgetConfigValidationError[];
12
- getWidgetData: (widget: DashboardWidgetConfig) => Promise<unknown>;
12
+ getWidgetData: (widget: DashboardWidgetConfig, options?: {
13
+ pagination?: {
14
+ page: number;
15
+ pageSize: number;
16
+ };
17
+ }) => Promise<unknown>;
13
18
  };
14
19
  export declare function registerWidgetEndpoints(server: IHttpServer, ctx: WidgetEndpointsContext): void;
15
20
  export {};