@open-mercato/ui 0.6.4-develop.3944.1.4100aa7fbe → 0.6.4-develop.3949.1.adc3d0b3b1
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/.turbo/turbo-build.log +1 -1
- package/dist/backend/charts/BarChart.js +19 -76
- package/dist/backend/charts/BarChart.js.map +2 -2
- package/dist/backend/charts/BarChartImpl.js +91 -0
- package/dist/backend/charts/BarChartImpl.js.map +7 -0
- package/dist/backend/charts/LineChart.js +18 -91
- package/dist/backend/charts/LineChart.js.map +2 -2
- package/dist/backend/charts/LineChartImpl.js +112 -0
- package/dist/backend/charts/LineChartImpl.js.map +7 -0
- package/dist/backend/charts/PieChart.js +17 -62
- package/dist/backend/charts/PieChart.js.map +2 -2
- package/dist/backend/charts/PieChartImpl.js +76 -0
- package/dist/backend/charts/PieChartImpl.js.map +7 -0
- package/dist/backend/dashboard/DashboardScreen.js +6 -0
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/dist/backend/dashboard/widgetRegistry.js +18 -2
- package/dist/backend/dashboard/widgetRegistry.js.map +2 -2
- package/dist/backend/injection/useInjectionDataWidgets.js +12 -2
- package/dist/backend/injection/useInjectionDataWidgets.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/__tests__/lazy-heavy-libraries.test.ts +53 -0
- package/src/backend/charts/BarChart.tsx +21 -72
- package/src/backend/charts/BarChartImpl.tsx +99 -0
- package/src/backend/charts/LineChart.tsx +23 -85
- package/src/backend/charts/LineChartImpl.tsx +120 -0
- package/src/backend/charts/PieChart.tsx +19 -60
- package/src/backend/charts/PieChartImpl.tsx +82 -0
- package/src/backend/dashboard/DashboardScreen.tsx +6 -0
- package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +17 -0
- package/src/backend/dashboard/__tests__/widgetRegistry.test.ts +60 -0
- package/src/backend/dashboard/widgetRegistry.ts +23 -5
- package/src/backend/injection/useInjectionDataWidgets.ts +18 -2
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import {
|
|
5
|
+
PieChart as RechartsPieChart,
|
|
6
|
+
Pie,
|
|
7
|
+
Cell,
|
|
8
|
+
Tooltip,
|
|
9
|
+
ResponsiveContainer,
|
|
10
|
+
Legend,
|
|
11
|
+
Label,
|
|
12
|
+
} from 'recharts'
|
|
13
|
+
import { ChartTooltipContent, resolveChartColor } from './ChartUtils'
|
|
14
|
+
import type { PieChartProps } from './PieChart'
|
|
15
|
+
|
|
16
|
+
type ImplProps = Pick<
|
|
17
|
+
PieChartProps,
|
|
18
|
+
'data' | 'colors' | 'variant' | 'valueFormatter' | 'showLabel' | 'showTooltip'
|
|
19
|
+
> & {
|
|
20
|
+
valueFormatter: (value: number) => string
|
|
21
|
+
total: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function PieChartImpl({
|
|
25
|
+
data,
|
|
26
|
+
colors,
|
|
27
|
+
variant = 'donut',
|
|
28
|
+
valueFormatter,
|
|
29
|
+
showLabel = true,
|
|
30
|
+
showTooltip = true,
|
|
31
|
+
total,
|
|
32
|
+
}: ImplProps) {
|
|
33
|
+
const getSliceColor = (idx: number): string => resolveChartColor(colors?.[idx], idx)
|
|
34
|
+
const innerRadius = variant === 'donut' ? '60%' : 0
|
|
35
|
+
const outerRadius = '80%'
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
39
|
+
<RechartsPieChart>
|
|
40
|
+
<Pie
|
|
41
|
+
data={data}
|
|
42
|
+
dataKey="value"
|
|
43
|
+
nameKey="name"
|
|
44
|
+
cx="50%"
|
|
45
|
+
cy="40%"
|
|
46
|
+
innerRadius={innerRadius}
|
|
47
|
+
outerRadius={outerRadius}
|
|
48
|
+
paddingAngle={2}
|
|
49
|
+
strokeWidth={0}
|
|
50
|
+
>
|
|
51
|
+
{data.map((_, idx) => (
|
|
52
|
+
<Cell key={`cell-${idx}`} fill={getSliceColor(idx)} />
|
|
53
|
+
))}
|
|
54
|
+
{showLabel && variant === 'donut' && (
|
|
55
|
+
<Label
|
|
56
|
+
value={valueFormatter(total)}
|
|
57
|
+
position="center"
|
|
58
|
+
className="fill-foreground text-2xl font-bold"
|
|
59
|
+
/>
|
|
60
|
+
)}
|
|
61
|
+
</Pie>
|
|
62
|
+
{showTooltip && (
|
|
63
|
+
<Tooltip
|
|
64
|
+
content={
|
|
65
|
+
<ChartTooltipContent
|
|
66
|
+
valueFormatter={valueFormatter}
|
|
67
|
+
hideLabel
|
|
68
|
+
/>
|
|
69
|
+
}
|
|
70
|
+
/>
|
|
71
|
+
)}
|
|
72
|
+
<Legend
|
|
73
|
+
verticalAlign="bottom"
|
|
74
|
+
height={36}
|
|
75
|
+
formatter={(value) => (
|
|
76
|
+
<span style={{ color: 'hsl(var(--muted-foreground))', fontSize: '12px' }}>{value}</span>
|
|
77
|
+
)}
|
|
78
|
+
/>
|
|
79
|
+
</RechartsPieChart>
|
|
80
|
+
</ResponsiveContainer>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -560,6 +560,12 @@ function DashboardWidgetCard({
|
|
|
560
560
|
loadDashboardWidgetModule(meta.loaderKey)
|
|
561
561
|
.then((loaded) => {
|
|
562
562
|
if (cancelled) return
|
|
563
|
+
if (!loaded) {
|
|
564
|
+
setModule(null)
|
|
565
|
+
setLoadError(t('dashboard.widget.loadError'))
|
|
566
|
+
setLoading(false)
|
|
567
|
+
return
|
|
568
|
+
}
|
|
563
569
|
setModule(loaded)
|
|
564
570
|
setLoading(false)
|
|
565
571
|
})
|
|
@@ -32,6 +32,7 @@ const createMockResponse = (status: number): Response => ({ status } as Response
|
|
|
32
32
|
|
|
33
33
|
const dict = {
|
|
34
34
|
'dashboard.loadError': 'Failed to load dashboard',
|
|
35
|
+
'dashboard.widget.loadError': 'Unable to load widget.',
|
|
35
36
|
'dashboard.empty.noWidgets.title': 'No dashboard widgets yet',
|
|
36
37
|
'dashboard.empty.noWidgets.description': 'Dashboard widgets will appear here after you add a module.',
|
|
37
38
|
'dashboard.widgets.foo.title': 'Widget Foo',
|
|
@@ -134,4 +135,20 @@ describe('DashboardScreen', () => {
|
|
|
134
135
|
|
|
135
136
|
errorSpy.mockRestore()
|
|
136
137
|
})
|
|
138
|
+
|
|
139
|
+
it('shows a widget load error when the registered module cannot be found', async () => {
|
|
140
|
+
;(apiCall as jest.Mock).mockResolvedValue({
|
|
141
|
+
ok: true,
|
|
142
|
+
status: 200,
|
|
143
|
+
result: widgetResponse,
|
|
144
|
+
response: createMockResponse(200),
|
|
145
|
+
})
|
|
146
|
+
;(loadDashboardWidgetModule as jest.Mock).mockResolvedValue(null)
|
|
147
|
+
|
|
148
|
+
renderWithProviders(<DashboardScreen />, { dict })
|
|
149
|
+
|
|
150
|
+
expect(await screen.findByText('Widget Foo')).toBeInTheDocument()
|
|
151
|
+
expect(await screen.findByText('Unable to load widget.')).toBeInTheDocument()
|
|
152
|
+
expect(screen.queryByText('Widget body')).not.toBeInTheDocument()
|
|
153
|
+
})
|
|
137
154
|
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ModuleDashboardWidgetEntry } from '@open-mercato/shared/modules/registry'
|
|
6
|
+
|
|
7
|
+
function createEntry(loader = jest.fn().mockResolvedValue({ Widget: () => null })): ModuleDashboardWidgetEntry {
|
|
8
|
+
return {
|
|
9
|
+
moduleId: 'example',
|
|
10
|
+
key: 'example.dashboard.widget',
|
|
11
|
+
source: 'app',
|
|
12
|
+
loader,
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('dashboard widget registry', () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
jest.resetModules()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('waits for client bootstrap registration before resolving widget modules', async () => {
|
|
22
|
+
const { loadDashboardWidgetModule, registerDashboardWidgets } = await import('../widgetRegistry')
|
|
23
|
+
const loader = jest.fn().mockResolvedValue({ Widget: () => null })
|
|
24
|
+
const pending = loadDashboardWidgetModule('example.dashboard.widget')
|
|
25
|
+
let resolved = false
|
|
26
|
+
pending.then(() => {
|
|
27
|
+
resolved = true
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
await Promise.resolve()
|
|
31
|
+
expect(resolved).toBe(false)
|
|
32
|
+
expect(loader).not.toHaveBeenCalled()
|
|
33
|
+
|
|
34
|
+
registerDashboardWidgets([createEntry(loader)])
|
|
35
|
+
|
|
36
|
+
await expect(pending).resolves.toEqual({ Widget: expect.any(Function) })
|
|
37
|
+
expect(loader).toHaveBeenCalledTimes(1)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('uses the latest entries after dashboard widgets are re-registered', async () => {
|
|
41
|
+
const { loadDashboardWidgetModule, registerDashboardWidgets } = await import('../widgetRegistry')
|
|
42
|
+
const firstLoader = jest.fn().mockResolvedValue({ Widget: () => null })
|
|
43
|
+
const secondLoader = jest.fn().mockResolvedValue({ Widget: () => null })
|
|
44
|
+
|
|
45
|
+
registerDashboardWidgets([createEntry(firstLoader)])
|
|
46
|
+
await loadDashboardWidgetModule('example.dashboard.widget')
|
|
47
|
+
|
|
48
|
+
registerDashboardWidgets([
|
|
49
|
+
{
|
|
50
|
+
...createEntry(secondLoader),
|
|
51
|
+
key: 'example.dashboard.other',
|
|
52
|
+
},
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
await loadDashboardWidgetModule('example.dashboard.other')
|
|
56
|
+
|
|
57
|
+
expect(firstLoader).toHaveBeenCalledTimes(1)
|
|
58
|
+
expect(secondLoader).toHaveBeenCalledTimes(1)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -3,15 +3,30 @@ import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboa
|
|
|
3
3
|
import { applyDashboardWidgetOverridesToEntries } from '@open-mercato/shared/modules/overrides'
|
|
4
4
|
|
|
5
5
|
type Entry = ModuleDashboardWidgetEntry
|
|
6
|
+
type LoadedWidgetModule = DashboardWidgetModule<any>
|
|
7
|
+
|
|
8
|
+
const cache = new Map<string, Promise<LoadedWidgetModule>>()
|
|
6
9
|
|
|
7
10
|
// Registration pattern for publishable packages
|
|
8
11
|
let _dashboardWidgetEntries: Entry[] | null = null
|
|
12
|
+
let registrationWaiters: Array<(entries: Entry[]) => void> = []
|
|
13
|
+
|
|
14
|
+
function resolveRegistrationWaiters(entries: Entry[]) {
|
|
15
|
+
const waiters = registrationWaiters
|
|
16
|
+
registrationWaiters = []
|
|
17
|
+
for (const resolve of waiters) {
|
|
18
|
+
resolve(entries)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
9
21
|
|
|
10
22
|
export function registerDashboardWidgets(entries: Entry[]) {
|
|
11
23
|
if (_dashboardWidgetEntries !== null && process.env.NODE_ENV === 'development') {
|
|
12
24
|
console.debug('[Bootstrap] Dashboard widgets re-registered (this may occur during HMR)')
|
|
13
25
|
}
|
|
14
26
|
_dashboardWidgetEntries = applyDashboardWidgetOverridesToEntries(entries)
|
|
27
|
+
entriesPromise = Promise.resolve(_dashboardWidgetEntries)
|
|
28
|
+
cache.clear()
|
|
29
|
+
resolveRegistrationWaiters(_dashboardWidgetEntries)
|
|
15
30
|
}
|
|
16
31
|
|
|
17
32
|
export function getDashboardWidgets(): Entry[] {
|
|
@@ -28,8 +43,15 @@ export function getDashboardWidgets(): Entry[] {
|
|
|
28
43
|
let entriesPromise: Promise<Entry[]> | null = null
|
|
29
44
|
|
|
30
45
|
async function getEntries(): Promise<Entry[]> {
|
|
46
|
+
if (_dashboardWidgetEntries) {
|
|
47
|
+
return _dashboardWidgetEntries
|
|
48
|
+
}
|
|
31
49
|
if (!entriesPromise) {
|
|
32
|
-
const promise =
|
|
50
|
+
const promise = typeof window !== 'undefined'
|
|
51
|
+
? new Promise<Entry[]>((resolve) => {
|
|
52
|
+
registrationWaiters.push(resolve)
|
|
53
|
+
})
|
|
54
|
+
: Promise.resolve().then(() => getDashboardWidgets())
|
|
33
55
|
entriesPromise = promise.catch((err) => {
|
|
34
56
|
// Clear cache on error so next call can retry after registration
|
|
35
57
|
if (entriesPromise === promise) {
|
|
@@ -41,10 +63,6 @@ async function getEntries(): Promise<Entry[]> {
|
|
|
41
63
|
return entriesPromise
|
|
42
64
|
}
|
|
43
65
|
|
|
44
|
-
type LoadedWidgetModule = DashboardWidgetModule<any>
|
|
45
|
-
|
|
46
|
-
const cache = new Map<string, Promise<LoadedWidgetModule>>()
|
|
47
|
-
|
|
48
66
|
async function findEntry(loaderKey: string): Promise<Entry | undefined> {
|
|
49
67
|
const entries = await getEntries()
|
|
50
68
|
return entries.find((entry) => entry.key === loaderKey)
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import type { InjectionSpotId } from '@open-mercato/shared/modules/widgets/injection'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
loadInjectionDataWidgetsForSpot,
|
|
7
|
+
getInjectionRegistryVersion,
|
|
8
|
+
subscribeToInjectionRegistryChanges,
|
|
9
|
+
type LoadedInjectionDataWidget,
|
|
10
|
+
} from '@open-mercato/shared/modules/widgets/injection-loader'
|
|
6
11
|
|
|
7
12
|
export function useInjectionDataWidgets(spotId: InjectionSpotId): {
|
|
8
13
|
widgets: LoadedInjectionDataWidget[]
|
|
@@ -12,6 +17,17 @@ export function useInjectionDataWidgets(spotId: InjectionSpotId): {
|
|
|
12
17
|
const [widgets, setWidgets] = React.useState<LoadedInjectionDataWidget[]>([])
|
|
13
18
|
const [isLoading, setIsLoading] = React.useState(true)
|
|
14
19
|
const [error, setError] = React.useState<string | null>(null)
|
|
20
|
+
// Re-load when the injection registry is (re-)populated. With the async
|
|
21
|
+
// ClientBootstrap, registration can land after this hook first runs; the
|
|
22
|
+
// version bump triggers a reload so injected menus/columns/fields appear
|
|
23
|
+
// without requiring an unrelated re-render.
|
|
24
|
+
const [registryVersion, setRegistryVersion] = React.useState(() => getInjectionRegistryVersion())
|
|
25
|
+
|
|
26
|
+
React.useEffect(() => {
|
|
27
|
+
return subscribeToInjectionRegistryChanges(() => {
|
|
28
|
+
setRegistryVersion(getInjectionRegistryVersion())
|
|
29
|
+
})
|
|
30
|
+
}, [])
|
|
15
31
|
|
|
16
32
|
React.useEffect(() => {
|
|
17
33
|
let mounted = true
|
|
@@ -35,7 +51,7 @@ export function useInjectionDataWidgets(spotId: InjectionSpotId): {
|
|
|
35
51
|
return () => {
|
|
36
52
|
mounted = false
|
|
37
53
|
}
|
|
38
|
-
}, [spotId])
|
|
54
|
+
}, [spotId, registryVersion])
|
|
39
55
|
|
|
40
56
|
return { widgets, isLoading, error }
|
|
41
57
|
}
|