@object-ui/plugin-dashboard 3.3.0 → 3.3.1
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/CHANGELOG.md +10 -0
- package/README.md +21 -1
- package/dist/index.js +869 -787
- package/dist/index.umd.cjs +4 -4
- package/dist/packages/plugin-dashboard/src/DashboardRenderer.d.ts +5 -0
- package/dist/packages/plugin-dashboard/src/DashboardRenderer.d.ts.map +1 -1
- package/dist/packages/plugin-dashboard/src/MetricWidget.d.ts +4 -1
- package/dist/packages/plugin-dashboard/src/MetricWidget.d.ts.map +1 -1
- package/dist/packages/plugin-dashboard/src/ObjectMetricWidget.d.ts +2 -0
- package/dist/packages/plugin-dashboard/src/ObjectMetricWidget.d.ts.map +1 -1
- package/dist/packages/plugin-dashboard/src/index.d.ts +1 -1
- package/package.json +40 -7
- package/.turbo/turbo-build.log +0 -41
- package/src/DashboardConfigPanel.stories.tsx +0 -164
- package/src/DashboardConfigPanel.tsx +0 -158
- package/src/DashboardGridLayout.tsx +0 -367
- package/src/DashboardRenderer.stories.tsx +0 -173
- package/src/DashboardRenderer.tsx +0 -479
- package/src/DashboardWithConfig.tsx +0 -211
- package/src/MetricCard.tsx +0 -102
- package/src/MetricWidget.tsx +0 -96
- package/src/ObjectDataTable.tsx +0 -226
- package/src/ObjectMetricWidget.tsx +0 -159
- package/src/ObjectPivotTable.tsx +0 -160
- package/src/PivotTable.tsx +0 -262
- package/src/WidgetConfigPanel.tsx +0 -540
- package/src/__tests__/DashboardConfigPanel.test.tsx +0 -206
- package/src/__tests__/DashboardGridLayout.test.tsx +0 -199
- package/src/__tests__/DashboardRenderer.autoRefresh.test.tsx +0 -124
- package/src/__tests__/DashboardRenderer.designMode.test.tsx +0 -386
- package/src/__tests__/DashboardRenderer.header.test.tsx +0 -114
- package/src/__tests__/DashboardRenderer.mobile.test.tsx +0 -214
- package/src/__tests__/DashboardRenderer.widgetData.test.tsx +0 -1411
- package/src/__tests__/DashboardWithConfig.test.tsx +0 -276
- package/src/__tests__/MetricCard.test.tsx +0 -107
- package/src/__tests__/ObjectDataTable.test.tsx +0 -211
- package/src/__tests__/ObjectMetricWidget.test.tsx +0 -196
- package/src/__tests__/ObjectPivotTable.test.tsx +0 -192
- package/src/__tests__/PivotTable.test.tsx +0 -162
- package/src/__tests__/WidgetConfigPanel.test.tsx +0 -492
- package/src/__tests__/ensureWidgetIds.test.tsx +0 -103
- package/src/index.tsx +0 -236
- package/src/utils.ts +0 -17
- package/tsconfig.json +0 -19
- package/vite.config.ts +0 -64
- package/vitest.config.ts +0 -9
- package/vitest.setup.tsx +0 -18
package/src/MetricCard.tsx
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import React from 'react';
|
|
10
|
-
import { Card, CardContent, CardHeader, CardTitle } from '@object-ui/components';
|
|
11
|
-
import { cn } from '@object-ui/components';
|
|
12
|
-
import { ArrowDownIcon, ArrowUpIcon, MinusIcon, AlertCircle, Loader2 } from 'lucide-react';
|
|
13
|
-
import * as LucideIcons from 'lucide-react';
|
|
14
|
-
|
|
15
|
-
/** Resolve an I18nLabel (string or {key, defaultValue}) to a plain string. */
|
|
16
|
-
function resolveLabel(label: string | { key?: string; defaultValue?: string } | undefined): string | undefined {
|
|
17
|
-
if (label === undefined || label === null) return undefined;
|
|
18
|
-
if (typeof label === 'string') return label;
|
|
19
|
-
return label.defaultValue || label.key;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface MetricCardProps {
|
|
23
|
-
title?: string | { key?: string; defaultValue?: string };
|
|
24
|
-
value: string | number;
|
|
25
|
-
icon?: string;
|
|
26
|
-
trend?: 'up' | 'down' | 'neutral';
|
|
27
|
-
trendValue?: string;
|
|
28
|
-
description?: string | { key?: string; defaultValue?: string };
|
|
29
|
-
className?: string;
|
|
30
|
-
/** When true, the card is in a loading state (fetching data from server). */
|
|
31
|
-
loading?: boolean;
|
|
32
|
-
/** Error message from a failed data fetch. When set, the card shows an error state. */
|
|
33
|
-
error?: string | null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* MetricCard - Standalone metric card component for dashboard KPIs
|
|
38
|
-
* Displays a metric value with optional icon, trend indicator, and description
|
|
39
|
-
*/
|
|
40
|
-
export const MetricCard: React.FC<MetricCardProps> = ({
|
|
41
|
-
title,
|
|
42
|
-
value,
|
|
43
|
-
icon,
|
|
44
|
-
trend,
|
|
45
|
-
trendValue,
|
|
46
|
-
description,
|
|
47
|
-
className,
|
|
48
|
-
loading,
|
|
49
|
-
error,
|
|
50
|
-
...props
|
|
51
|
-
}) => {
|
|
52
|
-
// Resolve icon from lucide-react
|
|
53
|
-
const IconComponent = icon && (LucideIcons as any)[icon];
|
|
54
|
-
|
|
55
|
-
return (
|
|
56
|
-
<Card className={cn("h-full", className)} {...props}>
|
|
57
|
-
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
58
|
-
<CardTitle className="text-sm font-medium">
|
|
59
|
-
{resolveLabel(title)}
|
|
60
|
-
</CardTitle>
|
|
61
|
-
{IconComponent && (
|
|
62
|
-
<IconComponent className="h-4 w-4 text-muted-foreground" />
|
|
63
|
-
)}
|
|
64
|
-
</CardHeader>
|
|
65
|
-
<CardContent>
|
|
66
|
-
{loading ? (
|
|
67
|
-
<div className="flex items-center gap-2 text-muted-foreground" data-testid="metric-card-loading">
|
|
68
|
-
<Loader2 className="h-4 w-4 animate-spin" />
|
|
69
|
-
<span className="text-sm">Loading…</span>
|
|
70
|
-
</div>
|
|
71
|
-
) : error ? (
|
|
72
|
-
<div className="flex items-center gap-2" data-testid="metric-card-error" role="alert">
|
|
73
|
-
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
|
|
74
|
-
<span className="text-xs text-destructive truncate">{error}</span>
|
|
75
|
-
</div>
|
|
76
|
-
) : (
|
|
77
|
-
<>
|
|
78
|
-
<div className="text-2xl font-bold">{value}</div>
|
|
79
|
-
{(trend || trendValue || description) && (
|
|
80
|
-
<p className="text-xs text-muted-foreground flex items-center mt-1">
|
|
81
|
-
{trend && trendValue && (
|
|
82
|
-
<span className={cn(
|
|
83
|
-
"flex items-center mr-2",
|
|
84
|
-
trend === 'up' && "text-green-500",
|
|
85
|
-
trend === 'down' && "text-red-500",
|
|
86
|
-
trend === 'neutral' && "text-yellow-500"
|
|
87
|
-
)}>
|
|
88
|
-
{trend === 'up' && <ArrowUpIcon className="h-3 w-3 mr-1" />}
|
|
89
|
-
{trend === 'down' && <ArrowDownIcon className="h-3 w-3 mr-1" />}
|
|
90
|
-
{trend === 'neutral' && <MinusIcon className="h-3 w-3 mr-1" />}
|
|
91
|
-
{trendValue}
|
|
92
|
-
</span>
|
|
93
|
-
)}
|
|
94
|
-
{resolveLabel(description)}
|
|
95
|
-
</p>
|
|
96
|
-
)}
|
|
97
|
-
</>
|
|
98
|
-
)}
|
|
99
|
-
</CardContent>
|
|
100
|
-
</Card>
|
|
101
|
-
);
|
|
102
|
-
};
|
package/src/MetricWidget.tsx
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import React, { useMemo } from 'react';
|
|
2
|
-
import { Card, CardContent, CardHeader, CardTitle } from '@object-ui/components';
|
|
3
|
-
import { cn } from '@object-ui/components';
|
|
4
|
-
import { ArrowDownIcon, ArrowUpIcon, MinusIcon, AlertCircle, Loader2 } from 'lucide-react';
|
|
5
|
-
import * as LucideIcons from 'lucide-react';
|
|
6
|
-
|
|
7
|
-
/** Resolve an I18nLabel (string or {key, defaultValue}) to a plain string. */
|
|
8
|
-
function resolveLabel(label: string | { key?: string; defaultValue?: string } | undefined): string | undefined {
|
|
9
|
-
if (label === undefined || label === null) return undefined;
|
|
10
|
-
if (typeof label === 'string') return label;
|
|
11
|
-
return label.defaultValue || label.key;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface MetricWidgetProps {
|
|
15
|
-
label: string | { key?: string; defaultValue?: string };
|
|
16
|
-
value: string | number;
|
|
17
|
-
trend?: {
|
|
18
|
-
value: number;
|
|
19
|
-
label?: string | { key?: string; defaultValue?: string };
|
|
20
|
-
direction?: 'up' | 'down' | 'neutral';
|
|
21
|
-
};
|
|
22
|
-
icon?: React.ReactNode | string;
|
|
23
|
-
className?: string;
|
|
24
|
-
description?: string | { key?: string; defaultValue?: string };
|
|
25
|
-
/** When true, the widget is in a loading state (fetching data from server). */
|
|
26
|
-
loading?: boolean;
|
|
27
|
-
/** Error message from a failed data fetch. When set, the widget shows an error state. */
|
|
28
|
-
error?: string | null;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export const MetricWidget = ({
|
|
32
|
-
label,
|
|
33
|
-
value,
|
|
34
|
-
trend,
|
|
35
|
-
icon,
|
|
36
|
-
className,
|
|
37
|
-
description,
|
|
38
|
-
loading,
|
|
39
|
-
error,
|
|
40
|
-
...props
|
|
41
|
-
}: MetricWidgetProps) => {
|
|
42
|
-
// Resolve icon if it's a string
|
|
43
|
-
const resolvedIcon = useMemo(() => {
|
|
44
|
-
if (typeof icon === 'string') {
|
|
45
|
-
const IconComponent = (LucideIcons as any)[icon];
|
|
46
|
-
return IconComponent ? <IconComponent className="h-4 w-4 text-muted-foreground" /> : null;
|
|
47
|
-
}
|
|
48
|
-
return icon;
|
|
49
|
-
}, [icon]);
|
|
50
|
-
|
|
51
|
-
return (
|
|
52
|
-
<Card className={cn("h-full overflow-hidden", className)} {...props}>
|
|
53
|
-
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
54
|
-
<CardTitle className="text-sm font-medium truncate">
|
|
55
|
-
{resolveLabel(label)}
|
|
56
|
-
</CardTitle>
|
|
57
|
-
{resolvedIcon && <div className="h-4 w-4 text-muted-foreground shrink-0">{resolvedIcon}</div>}
|
|
58
|
-
</CardHeader>
|
|
59
|
-
<CardContent>
|
|
60
|
-
{loading ? (
|
|
61
|
-
<div className="flex items-center gap-2 text-muted-foreground" data-testid="metric-loading">
|
|
62
|
-
<Loader2 className="h-4 w-4 animate-spin" />
|
|
63
|
-
<span className="text-sm">Loading…</span>
|
|
64
|
-
</div>
|
|
65
|
-
) : error ? (
|
|
66
|
-
<div className="flex items-center gap-2" data-testid="metric-error" role="alert">
|
|
67
|
-
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
|
|
68
|
-
<span className="text-xs text-destructive truncate">{error}</span>
|
|
69
|
-
</div>
|
|
70
|
-
) : (
|
|
71
|
-
<>
|
|
72
|
-
<div className="text-2xl font-bold truncate">{value}</div>
|
|
73
|
-
{(trend || description) && (
|
|
74
|
-
<p className="text-xs text-muted-foreground flex items-center mt-1 truncate">
|
|
75
|
-
{trend && (
|
|
76
|
-
<span className={cn(
|
|
77
|
-
"flex items-center mr-2 shrink-0",
|
|
78
|
-
trend.direction === 'up' && "text-green-500",
|
|
79
|
-
trend.direction === 'down' && "text-red-500",
|
|
80
|
-
trend.direction === 'neutral' && "text-yellow-500"
|
|
81
|
-
)}>
|
|
82
|
-
{trend.direction === 'up' && <ArrowUpIcon className="h-3 w-3 mr-1" />}
|
|
83
|
-
{trend.direction === 'down' && <ArrowDownIcon className="h-3 w-3 mr-1" />}
|
|
84
|
-
{trend.direction === 'neutral' && <MinusIcon className="h-3 w-3 mr-1" />}
|
|
85
|
-
{trend.value}%
|
|
86
|
-
</span>
|
|
87
|
-
)}
|
|
88
|
-
<span className="truncate">{resolveLabel(description) || resolveLabel(trend?.label)}</span>
|
|
89
|
-
</p>
|
|
90
|
-
)}
|
|
91
|
-
</>
|
|
92
|
-
)}
|
|
93
|
-
</CardContent>
|
|
94
|
-
</Card>
|
|
95
|
-
);
|
|
96
|
-
};
|
package/src/ObjectDataTable.tsx
DELETED
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import React, { useState, useEffect, useContext, useMemo } from 'react';
|
|
10
|
-
import { useDataScope, SchemaRendererContext, SchemaRenderer } from '@object-ui/react';
|
|
11
|
-
import { extractRecords } from '@object-ui/core';
|
|
12
|
-
import { Skeleton, cn } from '@object-ui/components';
|
|
13
|
-
|
|
14
|
-
export interface ObjectDataTableProps {
|
|
15
|
-
schema: {
|
|
16
|
-
type: string;
|
|
17
|
-
objectName?: string;
|
|
18
|
-
dataProvider?: { provider: string; object?: string };
|
|
19
|
-
bind?: string;
|
|
20
|
-
filter?: any;
|
|
21
|
-
data?: any[];
|
|
22
|
-
columns?: any[];
|
|
23
|
-
searchable?: boolean;
|
|
24
|
-
pagination?: boolean;
|
|
25
|
-
className?: string;
|
|
26
|
-
[key: string]: any;
|
|
27
|
-
};
|
|
28
|
-
dataSource?: any;
|
|
29
|
-
className?: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** A column definition after normalization, with header and accessor key. */
|
|
33
|
-
interface NormalizedColumn {
|
|
34
|
-
header: string;
|
|
35
|
-
accessorKey: string;
|
|
36
|
-
[key: string]: any;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Normalize columns to support both string[] shorthand and object[] formats.
|
|
41
|
-
*
|
|
42
|
-
* - `string[]` entries are converted to `{ header, accessorKey }` objects,
|
|
43
|
-
* handling both snake_case and camelCase for header generation.
|
|
44
|
-
* - Object entries are returned as-is.
|
|
45
|
-
*/
|
|
46
|
-
export function normalizeColumns(columns: (string | Record<string, any>)[]): NormalizedColumn[] {
|
|
47
|
-
return columns.map((col) => {
|
|
48
|
-
if (typeof col === 'string') {
|
|
49
|
-
return {
|
|
50
|
-
header: col
|
|
51
|
-
// snake_case → spaces
|
|
52
|
-
.replace(/_/g, ' ')
|
|
53
|
-
// camelCase → spaces before uppercase letters
|
|
54
|
-
.replace(/([A-Z])/g, ' $1')
|
|
55
|
-
.trim()
|
|
56
|
-
// Title Case each word
|
|
57
|
-
.replace(/\b\w/g, (c: string) => c.toUpperCase()),
|
|
58
|
-
accessorKey: col,
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
return col;
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* ObjectDataTable — Async-aware wrapper for data-table.
|
|
67
|
-
*
|
|
68
|
-
* When `objectName` is provided and a `dataSource` is available via context
|
|
69
|
-
* or props, fetches records automatically and passes them to the registered
|
|
70
|
-
* `data-table` component via SchemaRenderer.
|
|
71
|
-
*
|
|
72
|
-
* Also auto-derives columns from fetched data keys when no explicit columns
|
|
73
|
-
* are configured.
|
|
74
|
-
*
|
|
75
|
-
* Lifecycle states:
|
|
76
|
-
* - **Loading** → skeleton placeholder
|
|
77
|
-
* - **Error** → error message
|
|
78
|
-
* - **Empty** → friendly "No data available" message
|
|
79
|
-
* - **Data** → data-table with fetched rows
|
|
80
|
-
*/
|
|
81
|
-
export const ObjectDataTable: React.FC<ObjectDataTableProps> = ({ schema, dataSource: propDataSource, className }) => {
|
|
82
|
-
const context = useContext(SchemaRendererContext);
|
|
83
|
-
const dataSource = propDataSource || context?.dataSource;
|
|
84
|
-
const boundData = useDataScope(schema.bind);
|
|
85
|
-
|
|
86
|
-
const [fetchedData, setFetchedData] = useState<any[]>([]);
|
|
87
|
-
const [loading, setLoading] = useState(false);
|
|
88
|
-
const [error, setError] = useState<string | null>(null);
|
|
89
|
-
|
|
90
|
-
useEffect(() => {
|
|
91
|
-
let isMounted = true;
|
|
92
|
-
|
|
93
|
-
const fetchData = async () => {
|
|
94
|
-
if (!dataSource || !schema.objectName) return;
|
|
95
|
-
if (isMounted) {
|
|
96
|
-
setLoading(true);
|
|
97
|
-
setError(null);
|
|
98
|
-
}
|
|
99
|
-
try {
|
|
100
|
-
let data: any[];
|
|
101
|
-
|
|
102
|
-
if (typeof dataSource.find === 'function') {
|
|
103
|
-
const results = await dataSource.find(schema.objectName, {
|
|
104
|
-
$filter: schema.filter,
|
|
105
|
-
});
|
|
106
|
-
data = extractRecords(results);
|
|
107
|
-
} else {
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (isMounted) {
|
|
112
|
-
setFetchedData(data);
|
|
113
|
-
}
|
|
114
|
-
} catch (e) {
|
|
115
|
-
console.error('[ObjectDataTable] Fetch error:', e);
|
|
116
|
-
if (isMounted) {
|
|
117
|
-
setError(e instanceof Error ? e.message : 'Failed to load data');
|
|
118
|
-
}
|
|
119
|
-
} finally {
|
|
120
|
-
if (isMounted) setLoading(false);
|
|
121
|
-
}
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
if (schema.objectName && !boundData && (!schema.data || schema.data.length === 0)) {
|
|
125
|
-
fetchData();
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return () => { isMounted = false; };
|
|
129
|
-
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter]);
|
|
130
|
-
|
|
131
|
-
// Resolve data: bound data > static schema data > fetched data
|
|
132
|
-
const rawData = boundData || schema.data || fetchedData;
|
|
133
|
-
const finalData = Array.isArray(rawData) ? rawData : [];
|
|
134
|
-
|
|
135
|
-
// Auto-derive columns from data keys when none are provided
|
|
136
|
-
const derivedColumns = useMemo(() => {
|
|
137
|
-
if (schema.columns && schema.columns.length > 0) {
|
|
138
|
-
return normalizeColumns(schema.columns);
|
|
139
|
-
}
|
|
140
|
-
if (finalData.length === 0) return [];
|
|
141
|
-
// Exclude internal/private fields (prefixed with '_') from auto-derived columns
|
|
142
|
-
const keys = Object.keys(finalData[0]).filter(k => !k.startsWith('_'));
|
|
143
|
-
// Convert camelCase keys to human-readable headers (e.g. firstName → First Name)
|
|
144
|
-
return keys.map(k => ({
|
|
145
|
-
header: k.charAt(0).toUpperCase() + k.slice(1).replace(/([A-Z])/g, ' $1'),
|
|
146
|
-
accessorKey: k,
|
|
147
|
-
}));
|
|
148
|
-
}, [schema.columns, finalData]);
|
|
149
|
-
|
|
150
|
-
// Loading skeleton
|
|
151
|
-
if (loading && finalData.length === 0) {
|
|
152
|
-
return (
|
|
153
|
-
<div className={cn('overflow-auto', className)} data-testid="table-loading">
|
|
154
|
-
<div className="space-y-2 p-2">
|
|
155
|
-
<div className="flex gap-2">
|
|
156
|
-
<Skeleton className="h-6 w-1/4" />
|
|
157
|
-
<Skeleton className="h-6 w-1/4" />
|
|
158
|
-
<Skeleton className="h-6 w-1/4" />
|
|
159
|
-
<Skeleton className="h-6 w-1/4" />
|
|
160
|
-
</div>
|
|
161
|
-
{[1, 2, 3, 4].map((i) => (
|
|
162
|
-
<div key={i} className="flex gap-2">
|
|
163
|
-
<Skeleton className="h-5 w-1/4" />
|
|
164
|
-
<Skeleton className="h-5 w-1/4" />
|
|
165
|
-
<Skeleton className="h-5 w-1/4" />
|
|
166
|
-
<Skeleton className="h-5 w-1/4" />
|
|
167
|
-
</div>
|
|
168
|
-
))}
|
|
169
|
-
</div>
|
|
170
|
-
</div>
|
|
171
|
-
);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Error state
|
|
175
|
-
if (error) {
|
|
176
|
-
return (
|
|
177
|
-
<div className={cn('overflow-auto', className)} data-testid="table-error">
|
|
178
|
-
<div className="flex flex-col items-center justify-center py-8 text-destructive" data-testid="table-error-message">
|
|
179
|
-
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 mb-2 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
180
|
-
<circle cx="12" cy="12" r="10" />
|
|
181
|
-
<line x1="12" y1="8" x2="12" y2="12" />
|
|
182
|
-
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
183
|
-
</svg>
|
|
184
|
-
<p className="text-xs">{error}</p>
|
|
185
|
-
</div>
|
|
186
|
-
</div>
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// No data source available but objectName configured
|
|
191
|
-
if (!dataSource && schema.objectName && finalData.length === 0) {
|
|
192
|
-
return (
|
|
193
|
-
<div className={cn('overflow-auto', className)}>
|
|
194
|
-
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
|
195
|
-
<p className="text-xs">No data source available for “{schema.objectName}”</p>
|
|
196
|
-
</div>
|
|
197
|
-
</div>
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Empty state
|
|
202
|
-
if (finalData.length === 0) {
|
|
203
|
-
return (
|
|
204
|
-
<div className={cn('overflow-auto', className)} data-testid="table-empty-state">
|
|
205
|
-
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
|
206
|
-
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 mb-2 opacity-40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
207
|
-
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
208
|
-
<line x1="3" y1="9" x2="21" y2="9" />
|
|
209
|
-
<line x1="9" y1="21" x2="9" y2="9" />
|
|
210
|
-
</svg>
|
|
211
|
-
<p className="text-xs">No data available</p>
|
|
212
|
-
</div>
|
|
213
|
-
</div>
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Delegate to data-table via SchemaRenderer
|
|
218
|
-
const tableSchema = {
|
|
219
|
-
...schema,
|
|
220
|
-
type: 'data-table',
|
|
221
|
-
data: finalData,
|
|
222
|
-
columns: derivedColumns,
|
|
223
|
-
};
|
|
224
|
-
|
|
225
|
-
return <SchemaRenderer schema={tableSchema} className={className} />;
|
|
226
|
-
};
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import React, { useState, useEffect, useContext, useCallback } from 'react';
|
|
10
|
-
import { SchemaRendererContext } from '@object-ui/react';
|
|
11
|
-
import { MetricWidget } from './MetricWidget';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* ObjectMetricWidget — Data-bound metric widget.
|
|
15
|
-
*
|
|
16
|
-
* When a metric widget has an `object` binding and a `dataSource` is available,
|
|
17
|
-
* this component attempts to fetch the metric value from the server using
|
|
18
|
-
* aggregation. If the fetch fails, it shows an error state instead of
|
|
19
|
-
* silently displaying stale/hardcoded data.
|
|
20
|
-
*
|
|
21
|
-
* Lifecycle states:
|
|
22
|
-
* - **Loading** → spinner placeholder
|
|
23
|
-
* - **Error** → error message (API failure is surfaced, not hidden)
|
|
24
|
-
* - **Data** → actual metric value from server
|
|
25
|
-
* - **Fallback** → when no dataSource is available, renders the static
|
|
26
|
-
* `options.value` as provided in the widget config (demo/fallback mode)
|
|
27
|
-
*/
|
|
28
|
-
export interface ObjectMetricWidgetProps {
|
|
29
|
-
/** The object/resource name to query */
|
|
30
|
-
objectName: string;
|
|
31
|
-
/** Aggregation config (field, function, groupBy) */
|
|
32
|
-
aggregate?: { field: string; function: string; groupBy?: string };
|
|
33
|
-
/** Filter conditions */
|
|
34
|
-
filter?: any;
|
|
35
|
-
/** Static label for the metric */
|
|
36
|
-
label: string | { key?: string; defaultValue?: string };
|
|
37
|
-
/** Fallback static value (used when no dataSource or in demo mode) */
|
|
38
|
-
fallbackValue?: string | number;
|
|
39
|
-
/** Trend info */
|
|
40
|
-
trend?: {
|
|
41
|
-
value: number;
|
|
42
|
-
label?: string | { key?: string; defaultValue?: string };
|
|
43
|
-
direction?: 'up' | 'down' | 'neutral';
|
|
44
|
-
};
|
|
45
|
-
/** Icon name or ReactNode */
|
|
46
|
-
icon?: React.ReactNode | string;
|
|
47
|
-
/** Additional CSS class */
|
|
48
|
-
className?: string;
|
|
49
|
-
/** Description */
|
|
50
|
-
description?: string | { key?: string; defaultValue?: string };
|
|
51
|
-
/** External data source (overrides context) */
|
|
52
|
-
dataSource?: any;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export const ObjectMetricWidget: React.FC<ObjectMetricWidgetProps> = ({
|
|
56
|
-
objectName,
|
|
57
|
-
aggregate,
|
|
58
|
-
filter,
|
|
59
|
-
label,
|
|
60
|
-
fallbackValue,
|
|
61
|
-
trend,
|
|
62
|
-
icon,
|
|
63
|
-
className,
|
|
64
|
-
description,
|
|
65
|
-
dataSource: propDataSource,
|
|
66
|
-
}) => {
|
|
67
|
-
const context = useContext(SchemaRendererContext);
|
|
68
|
-
const dataSource = propDataSource || context?.dataSource;
|
|
69
|
-
|
|
70
|
-
const [fetchedValue, setFetchedValue] = useState<string | number | null>(null);
|
|
71
|
-
const [loading, setLoading] = useState(false);
|
|
72
|
-
const [error, setError] = useState<string | null>(null);
|
|
73
|
-
|
|
74
|
-
const fetchMetric = useCallback(async (ds: any, mounted: { current: boolean }) => {
|
|
75
|
-
if (!ds || !objectName) return;
|
|
76
|
-
if (mounted.current) {
|
|
77
|
-
setLoading(true);
|
|
78
|
-
setError(null);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
try {
|
|
82
|
-
let value: string | number;
|
|
83
|
-
|
|
84
|
-
if (aggregate && typeof ds.aggregate === 'function') {
|
|
85
|
-
// Server-side aggregation
|
|
86
|
-
const results = await ds.aggregate(objectName, {
|
|
87
|
-
field: aggregate.field,
|
|
88
|
-
function: aggregate.function,
|
|
89
|
-
groupBy: aggregate.groupBy || '_all',
|
|
90
|
-
filter,
|
|
91
|
-
});
|
|
92
|
-
const data = Array.isArray(results) ? results : [];
|
|
93
|
-
|
|
94
|
-
if (data.length === 0) {
|
|
95
|
-
value = 0;
|
|
96
|
-
} else if (aggregate.function === 'count') {
|
|
97
|
-
// Sum all count results
|
|
98
|
-
value = data.reduce((sum: number, r: any) => sum + (Number(r[aggregate.field]) || Number(r.count) || 0), 0);
|
|
99
|
-
} else {
|
|
100
|
-
// Take the first result's value
|
|
101
|
-
value = data[0][aggregate.field] ?? 0;
|
|
102
|
-
}
|
|
103
|
-
} else if (typeof ds.find === 'function') {
|
|
104
|
-
// Fallback: count records
|
|
105
|
-
const results = await ds.find(objectName, { $filter: filter });
|
|
106
|
-
const records = Array.isArray(results) ? results : results?.data || results?.records || [];
|
|
107
|
-
value = records.length;
|
|
108
|
-
} else {
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (mounted.current) {
|
|
113
|
-
setFetchedValue(value);
|
|
114
|
-
}
|
|
115
|
-
} catch (e) {
|
|
116
|
-
console.error('[ObjectMetricWidget] Fetch error:', e);
|
|
117
|
-
if (mounted.current) {
|
|
118
|
-
setError(e instanceof Error ? e.message : 'Failed to load metric');
|
|
119
|
-
}
|
|
120
|
-
} finally {
|
|
121
|
-
if (mounted.current) setLoading(false);
|
|
122
|
-
}
|
|
123
|
-
}, [objectName, aggregate, filter]);
|
|
124
|
-
|
|
125
|
-
useEffect(() => {
|
|
126
|
-
const mounted = { current: true };
|
|
127
|
-
|
|
128
|
-
if (dataSource && objectName) {
|
|
129
|
-
fetchMetric(dataSource, mounted);
|
|
130
|
-
} else {
|
|
131
|
-
// Reset state when dataSource becomes unavailable so we fall back
|
|
132
|
-
// to the static fallbackValue instead of showing stale server data.
|
|
133
|
-
setFetchedValue(null);
|
|
134
|
-
setError(null);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return () => { mounted.current = false; };
|
|
138
|
-
}, [dataSource, objectName, fetchMetric]);
|
|
139
|
-
|
|
140
|
-
// Determine the display value:
|
|
141
|
-
// - If we fetched a value from the server, use it
|
|
142
|
-
// - If there's no data source, use the fallback (demo/static value)
|
|
143
|
-
const displayValue = fetchedValue !== null
|
|
144
|
-
? fetchedValue
|
|
145
|
-
: (!dataSource ? (fallbackValue ?? '—') : '—');
|
|
146
|
-
|
|
147
|
-
return (
|
|
148
|
-
<MetricWidget
|
|
149
|
-
label={label}
|
|
150
|
-
value={displayValue}
|
|
151
|
-
trend={trend}
|
|
152
|
-
icon={icon}
|
|
153
|
-
className={className}
|
|
154
|
-
description={description}
|
|
155
|
-
loading={loading}
|
|
156
|
-
error={error}
|
|
157
|
-
/>
|
|
158
|
-
);
|
|
159
|
-
};
|