@open-mercato/ui 0.6.5-develop.5382.1.f542de69af → 0.6.5
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/AppShell.js +15 -8
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/DataTable.js +10 -9
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/charts/KpiCard.js +22 -14
- package/dist/backend/charts/KpiCard.js.map +2 -2
- package/dist/backend/charts/Sparkline.js +75 -0
- package/dist/backend/charts/Sparkline.js.map +7 -0
- package/dist/backend/charts/index.js +4 -1
- package/dist/backend/charts/index.js.map +2 -2
- package/dist/backend/dashboard/DashboardScreen.js +1 -2
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/dist/backend/injection/InjectionSpot.js +23 -6
- package/dist/backend/injection/InjectionSpot.js.map +2 -2
- package/dist/primitives/avatar.js +2 -2
- package/dist/primitives/avatar.js.map +2 -2
- package/dist/primitives/pagination.js +3 -3
- package/dist/primitives/pagination.js.map +2 -2
- package/package.json +5 -6
- package/src/backend/AppShell.tsx +18 -8
- package/src/backend/DataTable.tsx +16 -9
- package/src/backend/__tests__/AppShell.test.tsx +61 -1
- package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +99 -0
- package/src/backend/charts/KpiCard.tsx +22 -12
- package/src/backend/charts/Sparkline.tsx +87 -0
- package/src/backend/charts/index.ts +2 -1
- package/src/backend/dashboard/DashboardScreen.tsx +1 -2
- package/src/backend/injection/InjectionSpot.tsx +27 -7
- package/src/backend/injection/__tests__/InjectionSpot.remount.test.tsx +140 -0
- package/src/primitives/__tests__/alert.test.tsx +16 -0
- package/src/primitives/__tests__/avatar.test.tsx +36 -1
- package/src/primitives/__tests__/pagination.test.tsx +31 -0
- package/src/primitives/avatar.tsx +8 -2
- package/src/primitives/pagination.tsx +3 -3
|
@@ -19,7 +19,13 @@ jest.mock('next/link', () => {
|
|
|
19
19
|
))
|
|
20
20
|
})
|
|
21
21
|
|
|
22
|
-
jest.mock('next/image', () =>
|
|
22
|
+
jest.mock('next/image', () => {
|
|
23
|
+
const React = require('react')
|
|
24
|
+
return (props: any) => {
|
|
25
|
+
const { unoptimized, ...rest } = props
|
|
26
|
+
return <img alt={rest.alt} data-unoptimized={unoptimized ? 'true' : 'false'} {...rest} />
|
|
27
|
+
}
|
|
28
|
+
})
|
|
23
29
|
|
|
24
30
|
jest.mock('next/navigation', () => ({
|
|
25
31
|
usePathname: () => mockPathname,
|
|
@@ -201,6 +207,60 @@ describe('AppShell', () => {
|
|
|
201
207
|
)
|
|
202
208
|
})
|
|
203
209
|
|
|
210
|
+
it('uses backend chrome brand logo when the selected organization has one', async () => {
|
|
211
|
+
const previousFetch = global.fetch
|
|
212
|
+
const previousWindowFetch = window.fetch
|
|
213
|
+
const previousOriginalFetch = (window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch
|
|
214
|
+
const fetchMock = jest.fn().mockResolvedValue(
|
|
215
|
+
new Response(JSON.stringify({
|
|
216
|
+
brand: {
|
|
217
|
+
name: 'Acme',
|
|
218
|
+
logo: {
|
|
219
|
+
src: '/api/attachments/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/acme.png?width=320',
|
|
220
|
+
alt: 'Acme logo',
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
groups,
|
|
224
|
+
settingsSections: [],
|
|
225
|
+
settingsPathPrefixes: [],
|
|
226
|
+
profileSections: [],
|
|
227
|
+
profilePathPrefixes: [],
|
|
228
|
+
grantedFeatures: [],
|
|
229
|
+
roles: [],
|
|
230
|
+
}), { status: 200, headers: { 'content-type': 'application/json' } }),
|
|
231
|
+
) as typeof fetch
|
|
232
|
+
global.fetch = fetchMock
|
|
233
|
+
window.fetch = fetchMock
|
|
234
|
+
;(window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch = fetchMock
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
renderWithProviders(
|
|
238
|
+
<AppShell
|
|
239
|
+
email="demo@example.com"
|
|
240
|
+
groups={[]}
|
|
241
|
+
adminNavApi="/api/auth/admin/nav-brand-logo"
|
|
242
|
+
>
|
|
243
|
+
<div>Child content</div>
|
|
244
|
+
</AppShell>,
|
|
245
|
+
{ dict },
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
await waitFor(() => {
|
|
249
|
+
const logo = screen.getByAltText('Acme logo')
|
|
250
|
+
expect(logo).toHaveAttribute(
|
|
251
|
+
'src',
|
|
252
|
+
'/api/attachments/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/acme.png?width=320',
|
|
253
|
+
)
|
|
254
|
+
expect(logo).toHaveAttribute('data-unoptimized', 'true')
|
|
255
|
+
})
|
|
256
|
+
expect(screen.getByText('Acme')).toBeInTheDocument()
|
|
257
|
+
} finally {
|
|
258
|
+
global.fetch = previousFetch
|
|
259
|
+
window.fetch = previousWindowFetch
|
|
260
|
+
;(window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch = previousOriginalFetch
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
|
|
204
264
|
it('renders nested settings links when settings parent route is active', async () => {
|
|
205
265
|
mockPathname = '/backend/entities/user'
|
|
206
266
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { DataTable } from '../DataTable'
|
|
4
|
+
import type { ColumnDef } from '@tanstack/react-table'
|
|
5
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
6
|
+
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import { render } from '@testing-library/react'
|
|
8
|
+
import { RowActions } from '../RowActions'
|
|
9
|
+
|
|
10
|
+
jest.mock('next/navigation', () => ({
|
|
11
|
+
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
15
|
+
useInjectionDataWidgets: () => ({ widgets: [], isLoading: false }),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
type Row = { id: string; title: string; status: string }
|
|
19
|
+
|
|
20
|
+
const tokensOf = (el: Element | null): string[] =>
|
|
21
|
+
(el?.getAttribute('class') ?? '').split(/\s+/).filter(Boolean)
|
|
22
|
+
|
|
23
|
+
// Pinned columns must never consume the viewport on phones: an unconditionally
|
|
24
|
+
// `sticky` first column (often ~300px wide) plus a sticky actions column can
|
|
25
|
+
// exceed a small screen entirely, leaving the middle columns scrolling
|
|
26
|
+
// invisibly underneath with no reachable window. Pinning (position, offsets,
|
|
27
|
+
// z-index, opaque background, edge shadows) must therefore apply only from the
|
|
28
|
+
// `md` breakpoint up, so narrow viewports fall back to the documented plain
|
|
29
|
+
// horizontal-scroll behavior where every column can be swiped into view.
|
|
30
|
+
describe('DataTable sticky columns are viewport-gated', () => {
|
|
31
|
+
function renderStickyTable() {
|
|
32
|
+
const columns: ColumnDef<Row>[] = [
|
|
33
|
+
{ accessorKey: 'title', header: 'Title' },
|
|
34
|
+
{ accessorKey: 'status', header: 'Status' },
|
|
35
|
+
]
|
|
36
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
|
|
37
|
+
const result = render(
|
|
38
|
+
<QueryClientProvider client={queryClient}>
|
|
39
|
+
<I18nProvider locale="en" dict={{}}>
|
|
40
|
+
<DataTable
|
|
41
|
+
columns={columns}
|
|
42
|
+
data={[{ id: '1', title: 'Solar rollout', status: 'open' }]}
|
|
43
|
+
stickyFirstColumn
|
|
44
|
+
stickyActionsColumn
|
|
45
|
+
rowActions={() => (
|
|
46
|
+
<RowActions items={[{ id: 'edit', label: 'Edit', onSelect: () => {} }]} />
|
|
47
|
+
)}
|
|
48
|
+
/>
|
|
49
|
+
</I18nProvider>
|
|
50
|
+
</QueryClientProvider>,
|
|
51
|
+
)
|
|
52
|
+
return { ...result, queryClient }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
it('pins the first data column only from md up', () => {
|
|
56
|
+
const { container, queryClient } = renderStickyTable()
|
|
57
|
+
try {
|
|
58
|
+
const headerCells = container.querySelectorAll('thead th')
|
|
59
|
+
const firstHeader = headerCells[0]
|
|
60
|
+
const firstBodyCell = container.querySelector('tbody tr td')
|
|
61
|
+
|
|
62
|
+
for (const cell of [firstHeader, firstBodyCell]) {
|
|
63
|
+
const tokens = tokensOf(cell)
|
|
64
|
+
expect(tokens).toEqual(
|
|
65
|
+
expect.arrayContaining(['md:sticky', 'md:left-0', 'md:bg-background', 'md:after:absolute']),
|
|
66
|
+
)
|
|
67
|
+
expect(tokens).not.toContain('sticky')
|
|
68
|
+
expect(tokens).not.toContain('left-0')
|
|
69
|
+
expect(tokens).not.toContain('bg-background')
|
|
70
|
+
expect(tokens).not.toContain('after:absolute')
|
|
71
|
+
}
|
|
72
|
+
} finally {
|
|
73
|
+
queryClient.clear()
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('pins the actions column only from md up', () => {
|
|
78
|
+
const { container, queryClient } = renderStickyTable()
|
|
79
|
+
try {
|
|
80
|
+
const headerCells = container.querySelectorAll('thead th')
|
|
81
|
+
const actionsHeader = headerCells[headerCells.length - 1]
|
|
82
|
+
const actionsBodyCell = container.querySelector('tbody tr td[data-actions-cell]')
|
|
83
|
+
expect(actionsBodyCell).not.toBeNull()
|
|
84
|
+
|
|
85
|
+
for (const cell of [actionsHeader, actionsBodyCell]) {
|
|
86
|
+
const tokens = tokensOf(cell)
|
|
87
|
+
expect(tokens).toEqual(
|
|
88
|
+
expect.arrayContaining(['md:sticky', 'md:right-0', 'md:bg-background', 'md:before:absolute']),
|
|
89
|
+
)
|
|
90
|
+
expect(tokens).not.toContain('sticky')
|
|
91
|
+
expect(tokens).not.toContain('right-0')
|
|
92
|
+
expect(tokens).not.toContain('bg-background')
|
|
93
|
+
expect(tokens).not.toContain('before:absolute')
|
|
94
|
+
}
|
|
95
|
+
} finally {
|
|
96
|
+
queryClient.clear()
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -20,6 +20,8 @@ export type KpiCardProps = {
|
|
|
20
20
|
suffix?: string
|
|
21
21
|
className?: string
|
|
22
22
|
headerAction?: React.ReactNode
|
|
23
|
+
footer?: React.ReactNode
|
|
24
|
+
titleClassName?: string
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
function defaultFormatValue(value: number): string {
|
|
@@ -32,17 +34,21 @@ function defaultFormatValue(value: number): string {
|
|
|
32
34
|
return value.toLocaleString(undefined, { maximumFractionDigits: 2 })
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
function formatPercentageChange(value: number): string {
|
|
36
|
-
const
|
|
37
|
-
|
|
37
|
+
function formatPercentageChange(value: number, unit: string = '%'): string {
|
|
38
|
+
const abs = Math.abs(value)
|
|
39
|
+
const formatted = Number.isInteger(abs) ? String(abs) : abs.toFixed(1)
|
|
40
|
+
return `${formatted}${unit}`
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
type BadgeDeltaProps = {
|
|
41
44
|
direction: 'up' | 'down' | 'unchanged'
|
|
42
45
|
value: number
|
|
46
|
+
unit?: string
|
|
47
|
+
className?: string
|
|
48
|
+
title?: string
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
function BadgeDelta({ direction, value }: BadgeDeltaProps) {
|
|
51
|
+
function BadgeDelta({ direction, value, unit = '%', className = '', title = 'Compared to previous period' }: BadgeDeltaProps) {
|
|
46
52
|
const baseClasses = 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium'
|
|
47
53
|
|
|
48
54
|
const directionClasses = {
|
|
@@ -71,15 +77,17 @@ function BadgeDelta({ direction, value }: BadgeDeltaProps) {
|
|
|
71
77
|
|
|
72
78
|
return (
|
|
73
79
|
<span
|
|
74
|
-
className={`${baseClasses} ${directionClasses[direction]}`}
|
|
75
|
-
title=
|
|
80
|
+
className={`${baseClasses} ${directionClasses[direction]}${className ? ` ${className}` : ''}`}
|
|
81
|
+
title={title}
|
|
76
82
|
>
|
|
77
83
|
{icons[direction]}
|
|
78
|
-
{formatPercentageChange(value)}
|
|
84
|
+
{formatPercentageChange(value, unit)}
|
|
79
85
|
</span>
|
|
80
86
|
)
|
|
81
87
|
}
|
|
82
88
|
|
|
89
|
+
export const DeltaBadge = BadgeDelta
|
|
90
|
+
|
|
83
91
|
export function KpiCard({
|
|
84
92
|
title,
|
|
85
93
|
value,
|
|
@@ -92,13 +100,15 @@ export function KpiCard({
|
|
|
92
100
|
suffix = '',
|
|
93
101
|
className = '',
|
|
94
102
|
headerAction,
|
|
103
|
+
footer,
|
|
104
|
+
titleClassName,
|
|
95
105
|
}: KpiCardProps) {
|
|
96
106
|
const hasWrapper = !!title
|
|
97
107
|
const wrapperClass = hasWrapper ? `rounded-lg border bg-card p-4 ${className}` : className
|
|
98
108
|
|
|
99
109
|
const headerRow = (title || headerAction) ? (
|
|
100
110
|
<div className="flex items-center justify-between gap-2 mb-2">
|
|
101
|
-
{title && <p className=
|
|
111
|
+
{title && <p className={titleClassName ?? 'text-sm font-medium text-muted-foreground'}>{title}</p>}
|
|
102
112
|
{headerAction}
|
|
103
113
|
</div>
|
|
104
114
|
) : null
|
|
@@ -136,10 +146,9 @@ export function KpiCard({
|
|
|
136
146
|
<div className={wrapperClass}>
|
|
137
147
|
{headerRow}
|
|
138
148
|
<div className="flex items-baseline gap-3">
|
|
139
|
-
<p className="text-2xl sm:text-3xl font-semibold tracking-tight text-card-foreground">
|
|
140
|
-
{prefix}
|
|
141
|
-
{
|
|
142
|
-
{suffix}
|
|
149
|
+
<p className="flex items-baseline gap-1.5 text-2xl sm:text-3xl font-semibold tracking-tight text-card-foreground">
|
|
150
|
+
<span>{prefix}{formatValue(value)}</span>
|
|
151
|
+
{suffix ? <span className="text-sm font-medium text-muted-foreground">{suffix}</span> : null}
|
|
143
152
|
</p>
|
|
144
153
|
{trend && (
|
|
145
154
|
<BadgeDelta direction={trend.direction} value={trend.value} />
|
|
@@ -148,6 +157,7 @@ export function KpiCard({
|
|
|
148
157
|
{trend && comparisonLabel && (
|
|
149
158
|
<p className="mt-1 text-xs text-muted-foreground">{comparisonLabel}</p>
|
|
150
159
|
)}
|
|
160
|
+
{footer != null && <div className="mt-3">{footer}</div>}
|
|
151
161
|
</div>
|
|
152
162
|
)
|
|
153
163
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
|
|
5
|
+
export type SparklineProps = {
|
|
6
|
+
values: number[]
|
|
7
|
+
ariaLabel: string
|
|
8
|
+
className?: string
|
|
9
|
+
width?: number
|
|
10
|
+
height?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function buildPoints(values: number[], width: number, height: number): Array<{ x: number; y: number }> {
|
|
14
|
+
const count = values.length
|
|
15
|
+
const padding = 2
|
|
16
|
+
const usableHeight = Math.max(height - padding * 2, 0)
|
|
17
|
+
|
|
18
|
+
if (count === 1) {
|
|
19
|
+
return [{ x: width / 2, y: height / 2 }]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let min = values[0]
|
|
23
|
+
let max = values[0]
|
|
24
|
+
for (const value of values) {
|
|
25
|
+
if (value < min) min = value
|
|
26
|
+
if (value > max) max = value
|
|
27
|
+
}
|
|
28
|
+
const range = max - min
|
|
29
|
+
|
|
30
|
+
const stepX = count > 1 ? width / (count - 1) : 0
|
|
31
|
+
|
|
32
|
+
return values.map((value, index) => {
|
|
33
|
+
const x = stepX * index
|
|
34
|
+
const ratio = range === 0 ? 0.5 : (value - min) / range
|
|
35
|
+
const y = padding + (1 - ratio) * usableHeight
|
|
36
|
+
return { x, y }
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toPath(points: Array<{ x: number; y: number }>): string {
|
|
41
|
+
return points
|
|
42
|
+
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`)
|
|
43
|
+
.join(' ')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function Sparkline({
|
|
47
|
+
values,
|
|
48
|
+
ariaLabel,
|
|
49
|
+
className = '',
|
|
50
|
+
width = 96,
|
|
51
|
+
height = 28,
|
|
52
|
+
}: SparklineProps) {
|
|
53
|
+
if (!values || values.length === 0) {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const points = buildPoints(values, width, height)
|
|
58
|
+
const linePath = toPath(points)
|
|
59
|
+
|
|
60
|
+
const firstPoint = points[0]
|
|
61
|
+
const lastPoint = points[points.length - 1]
|
|
62
|
+
const areaPath = `${linePath} L ${lastPoint.x.toFixed(2)} ${height} L ${firstPoint.x.toFixed(2)} ${height} Z`
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<svg
|
|
66
|
+
role="img"
|
|
67
|
+
aria-label={ariaLabel}
|
|
68
|
+
width={width}
|
|
69
|
+
height={height}
|
|
70
|
+
viewBox={`0 0 ${width} ${height}`}
|
|
71
|
+
preserveAspectRatio="none"
|
|
72
|
+
className={className}
|
|
73
|
+
>
|
|
74
|
+
<path d={areaPath} fill="currentColor" fillOpacity={0.12} stroke="none" />
|
|
75
|
+
<path
|
|
76
|
+
d={linePath}
|
|
77
|
+
fill="none"
|
|
78
|
+
stroke="currentColor"
|
|
79
|
+
strokeWidth={1.5}
|
|
80
|
+
strokeLinecap="round"
|
|
81
|
+
strokeLinejoin="round"
|
|
82
|
+
/>
|
|
83
|
+
</svg>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default Sparkline
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { KpiCard, type KpiCardProps, type KpiTrend } from './KpiCard'
|
|
1
|
+
export { KpiCard, DeltaBadge, type KpiCardProps, type KpiTrend } from './KpiCard'
|
|
2
|
+
export { Sparkline, type SparklineProps } from './Sparkline'
|
|
2
3
|
export { BarChart, type BarChartProps, type BarChartDataItem } from './BarChart'
|
|
3
4
|
export { LineChart, type LineChartProps, type LineChartDataItem } from './LineChart'
|
|
4
5
|
export { PieChart, type PieChartProps, type PieChartDataItem } from './PieChart'
|
|
@@ -10,7 +10,7 @@ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
|
10
10
|
import { getDashboardWidgets, loadDashboardWidgetModule } from './widgetRegistry'
|
|
11
11
|
import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
|
|
12
12
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
13
|
-
import { GripVertical,
|
|
13
|
+
import { GripVertical, Plus, RefreshCw, Settings2, Trash2, X, Loader2 } from 'lucide-react'
|
|
14
14
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
15
15
|
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
16
16
|
import { InjectionSpot } from '../injection/InjectionSpot'
|
|
@@ -366,7 +366,6 @@ export function DashboardScreen() {
|
|
|
366
366
|
if (!hasRegisteredWidgets && layout.length === 0) {
|
|
367
367
|
return (
|
|
368
368
|
<Alert variant="info">
|
|
369
|
-
<Info className="h-4 w-4" aria-hidden />
|
|
370
369
|
<AlertTitle>{t('dashboard.empty.noWidgets.title', 'No dashboard widgets yet')}</AlertTitle>
|
|
371
370
|
<AlertDescription>
|
|
372
371
|
{t(
|
|
@@ -76,6 +76,15 @@ export function useInjectionWidgets<TContext = unknown>(
|
|
|
76
76
|
const [error, setError] = React.useState<string | null>(null)
|
|
77
77
|
const [registryVersion, setRegistryVersion] = React.useState(() => getInjectionRegistryVersion())
|
|
78
78
|
const loadedRef = React.useRef(false)
|
|
79
|
+
const contextRef = React.useRef(options?.context)
|
|
80
|
+
const triggerOnLoadRef = React.useRef(options?.triggerOnLoad)
|
|
81
|
+
const onEventRef = React.useRef(options?.onEvent)
|
|
82
|
+
|
|
83
|
+
React.useEffect(() => {
|
|
84
|
+
contextRef.current = options?.context
|
|
85
|
+
triggerOnLoadRef.current = options?.triggerOnLoad
|
|
86
|
+
onEventRef.current = options?.onEvent
|
|
87
|
+
})
|
|
79
88
|
|
|
80
89
|
React.useEffect(() => {
|
|
81
90
|
return subscribeToInjectionRegistryChanges(() => {
|
|
@@ -105,16 +114,16 @@ export function useInjectionWidgets<TContext = unknown>(
|
|
|
105
114
|
placement: w.placement,
|
|
106
115
|
}))
|
|
107
116
|
setWidgets(widgetList)
|
|
108
|
-
|
|
117
|
+
|
|
109
118
|
// Trigger onLoad for all widgets
|
|
110
|
-
if (!loadedRef.current &&
|
|
119
|
+
if (!loadedRef.current && triggerOnLoadRef.current) {
|
|
111
120
|
loadedRef.current = true
|
|
112
121
|
for (const widget of widgetList) {
|
|
113
122
|
if (widget.module.eventHandlers?.onLoad) {
|
|
114
123
|
try {
|
|
115
|
-
const widgetContext = injectSharedStateIntoContext(
|
|
124
|
+
const widgetContext = injectSharedStateIntoContext(contextRef.current as TContext, widget.moduleId)
|
|
116
125
|
await widget.module.eventHandlers.onLoad(widgetContext)
|
|
117
|
-
|
|
126
|
+
onEventRef.current?.('onLoad', widget.widgetId)
|
|
118
127
|
} catch (err) {
|
|
119
128
|
console.error(`[InjectionSpot] Error in onLoad for widget ${widget.widgetId}:`, err)
|
|
120
129
|
}
|
|
@@ -133,7 +142,9 @@ export function useInjectionWidgets<TContext = unknown>(
|
|
|
133
142
|
return () => {
|
|
134
143
|
mounted = false
|
|
135
144
|
}
|
|
136
|
-
|
|
145
|
+
// context/triggerOnLoad/onEvent are read from refs so only a real registry-version bump reloads the spot
|
|
146
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
147
|
+
}, [spotId, registryVersion])
|
|
137
148
|
|
|
138
149
|
return { widgets, loading, error }
|
|
139
150
|
}
|
|
@@ -148,16 +159,25 @@ export function InjectionSpot<TContext = unknown, TData = unknown>({
|
|
|
148
159
|
widgetsOverride,
|
|
149
160
|
}: InjectionSpotProps<TContext, TData>) {
|
|
150
161
|
const useSpotId = widgetsOverride ? null : spotId
|
|
162
|
+
const onEventRef = React.useRef(onEvent)
|
|
163
|
+
React.useEffect(() => {
|
|
164
|
+
onEventRef.current = onEvent
|
|
165
|
+
})
|
|
166
|
+
const hasOnEvent = Boolean(onEvent)
|
|
167
|
+
const stableOnEvent = React.useMemo(
|
|
168
|
+
() => (hasOnEvent ? (event: 'onLoad', id: string) => onEventRef.current?.(event, id) : undefined),
|
|
169
|
+
[hasOnEvent],
|
|
170
|
+
)
|
|
151
171
|
const { widgets, loading, error } = useInjectionWidgets<TContext>(useSpotId, {
|
|
152
172
|
context,
|
|
153
173
|
triggerOnLoad: !widgetsOverride,
|
|
154
|
-
onEvent:
|
|
174
|
+
onEvent: stableOnEvent,
|
|
155
175
|
})
|
|
156
176
|
const effectiveWidgets = widgetsOverride ?? widgets
|
|
157
177
|
const effectiveLoading = widgetsOverride ? false : loading
|
|
158
178
|
const effectiveError = widgetsOverride ? null : error
|
|
159
179
|
|
|
160
|
-
if (effectiveLoading) {
|
|
180
|
+
if (effectiveLoading && effectiveWidgets.length === 0) {
|
|
161
181
|
return null
|
|
162
182
|
}
|
|
163
183
|
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { act, render, waitFor } from '@testing-library/react'
|
|
5
|
+
|
|
6
|
+
const mockState: { registryVersion: number; listener: null | (() => void) } = {
|
|
7
|
+
registryVersion: 0,
|
|
8
|
+
listener: null,
|
|
9
|
+
}
|
|
10
|
+
const mockLoadSpy = jest.fn()
|
|
11
|
+
|
|
12
|
+
jest.mock('@open-mercato/shared/modules/widgets/injection-loader', () => ({
|
|
13
|
+
getInjectionRegistryVersion: () => mockState.registryVersion,
|
|
14
|
+
subscribeToInjectionRegistryChanges: (listener: () => void) => {
|
|
15
|
+
mockState.listener = listener
|
|
16
|
+
return () => {
|
|
17
|
+
mockState.listener = null
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
loadInjectionWidgetsForSpot: (...args: unknown[]) => mockLoadSpy(...args),
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
jest.mock('../WidgetSharedState', () => ({
|
|
24
|
+
getWidgetSharedState: () => ({}),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
import { InjectionSpot } from '../InjectionSpot'
|
|
28
|
+
|
|
29
|
+
const SPOT_ID = 'data-table:test.products:toolbar'
|
|
30
|
+
const widgetMountSpy = jest.fn()
|
|
31
|
+
const onLoadSpy = jest.fn()
|
|
32
|
+
|
|
33
|
+
function makeWidget(id: string) {
|
|
34
|
+
return {
|
|
35
|
+
metadata: { id },
|
|
36
|
+
moduleId: 'test_module',
|
|
37
|
+
key: id,
|
|
38
|
+
placement: undefined,
|
|
39
|
+
eventHandlers: { onLoad: onLoadSpy },
|
|
40
|
+
Widget: ({ context }: { context: unknown }) => {
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
widgetMountSpy()
|
|
43
|
+
}, [])
|
|
44
|
+
const label = (context as { label?: string } | null)?.label ?? ''
|
|
45
|
+
return <div data-testid="injected-widget">{label}</div>
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function Harness({ tick, onEvent }: { tick: number; onEvent?: (event: 'onLoad', id: string) => void }) {
|
|
51
|
+
// Fresh context identity on every render — mirrors hosts that feed a useMemo/closure
|
|
52
|
+
// context whose identity changes on routine interactions (e.g. row-selection toggles).
|
|
53
|
+
const context = { label: 'ctx', tick }
|
|
54
|
+
return <InjectionSpot spotId={SPOT_ID} context={context} onEvent={onEvent} />
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('InjectionSpot — context identity changes do not remount injected widgets', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
mockState.registryVersion = 0
|
|
60
|
+
mockState.listener = null
|
|
61
|
+
mockLoadSpy.mockReset()
|
|
62
|
+
mockLoadSpy.mockResolvedValue([makeWidget('test_module:hello')])
|
|
63
|
+
widgetMountSpy.mockClear()
|
|
64
|
+
onLoadSpy.mockClear()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('renders the injected widget and loads the spot exactly once', async () => {
|
|
68
|
+
const { findByTestId } = render(<Harness tick={0} />)
|
|
69
|
+
await findByTestId('injected-widget')
|
|
70
|
+
expect(mockLoadSpy).toHaveBeenCalledTimes(1)
|
|
71
|
+
expect(widgetMountSpy).toHaveBeenCalledTimes(1)
|
|
72
|
+
expect(onLoadSpy).toHaveBeenCalledTimes(1)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('does NOT reload or remount when only the context identity changes', async () => {
|
|
76
|
+
const { rerender, findByTestId } = render(<Harness tick={0} />)
|
|
77
|
+
await findByTestId('injected-widget')
|
|
78
|
+
expect(mockLoadSpy).toHaveBeenCalledTimes(1)
|
|
79
|
+
expect(widgetMountSpy).toHaveBeenCalledTimes(1)
|
|
80
|
+
|
|
81
|
+
for (let next = 1; next <= 3; next += 1) {
|
|
82
|
+
rerender(<Harness tick={next} />)
|
|
83
|
+
// flush effects for this render
|
|
84
|
+
await act(async () => {})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
expect(mockLoadSpy).toHaveBeenCalledTimes(1)
|
|
88
|
+
expect(widgetMountSpy).toHaveBeenCalledTimes(1)
|
|
89
|
+
expect(onLoadSpy).toHaveBeenCalledTimes(1)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('does NOT reload when a fresh onEvent closure is passed on every render', async () => {
|
|
93
|
+
const { rerender, findByTestId } = render(<Harness tick={0} onEvent={() => {}} />)
|
|
94
|
+
await findByTestId('injected-widget')
|
|
95
|
+
expect(mockLoadSpy).toHaveBeenCalledTimes(1)
|
|
96
|
+
|
|
97
|
+
rerender(<Harness tick={1} onEvent={() => {}} />)
|
|
98
|
+
await act(async () => {})
|
|
99
|
+
|
|
100
|
+
expect(mockLoadSpy).toHaveBeenCalledTimes(1)
|
|
101
|
+
expect(widgetMountSpy).toHaveBeenCalledTimes(1)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('DOES reload when the injection registry version actually bumps', async () => {
|
|
105
|
+
const { findByTestId } = render(<Harness tick={0} />)
|
|
106
|
+
await findByTestId('injected-widget')
|
|
107
|
+
expect(mockLoadSpy).toHaveBeenCalledTimes(1)
|
|
108
|
+
|
|
109
|
+
await act(async () => {
|
|
110
|
+
mockState.registryVersion += 1
|
|
111
|
+
mockState.listener?.()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
await waitFor(() => expect(mockLoadSpy).toHaveBeenCalledTimes(2))
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('keeps the previously-rendered widget mounted during a registry reload', async () => {
|
|
118
|
+
const { findByTestId } = render(<Harness tick={0} />)
|
|
119
|
+
await findByTestId('injected-widget')
|
|
120
|
+
|
|
121
|
+
let resolveReload: (value: ReturnType<typeof makeWidget>[]) => void = () => {}
|
|
122
|
+
mockLoadSpy.mockImplementationOnce(
|
|
123
|
+
() => new Promise((resolve) => { resolveReload = resolve }),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
await act(async () => {
|
|
127
|
+
mockState.registryVersion += 1
|
|
128
|
+
mockState.listener?.()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// Reload is in-flight (loading === true) but the existing widget stays mounted.
|
|
132
|
+
expect(await findByTestId('injected-widget')).toBeTruthy()
|
|
133
|
+
|
|
134
|
+
await act(async () => {
|
|
135
|
+
resolveReload([makeWidget('test_module:hello')])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
await findByTestId('injected-widget')
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -133,6 +133,22 @@ describe('Alert primitive', () => {
|
|
|
133
133
|
render(<Alert status="information" style="light" icon={<Custom />}>Info</Alert>)
|
|
134
134
|
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
|
|
135
135
|
})
|
|
136
|
+
|
|
137
|
+
it('renders exactly one leading icon by default (#2759)', () => {
|
|
138
|
+
const { container } = render(<Alert status="information">Info</Alert>)
|
|
139
|
+
expect(container.querySelectorAll('[data-slot="alert-icon-badge"]')).toHaveLength(1)
|
|
140
|
+
expect(container.querySelectorAll('[data-slot="alert-icon-badge"] > svg')).toHaveLength(1)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('icon prop REPLACES the default status icon instead of adding a second one (#2759)', () => {
|
|
144
|
+
const Custom = () => <svg data-testid="custom-icon" aria-hidden="true" />
|
|
145
|
+
const { container } = render(
|
|
146
|
+
<Alert status="information" icon={<Custom />}>Info</Alert>,
|
|
147
|
+
)
|
|
148
|
+
expect(container.querySelectorAll('[data-slot="alert-icon-badge"]')).toHaveLength(1)
|
|
149
|
+
expect(container.querySelectorAll('[data-slot="alert-icon-badge"] > svg')).toHaveLength(1)
|
|
150
|
+
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
|
|
151
|
+
})
|
|
136
152
|
})
|
|
137
153
|
|
|
138
154
|
describe('Action slot + dismiss', () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
2
|
import { render, screen } from '@testing-library/react'
|
|
3
|
-
import { Avatar } from '../avatar'
|
|
3
|
+
import { Avatar, AvatarStack } from '../avatar'
|
|
4
4
|
|
|
5
5
|
describe('Avatar', () => {
|
|
6
6
|
it('renders two-character initials for multi-word labels', () => {
|
|
@@ -193,3 +193,38 @@ describe('Avatar', () => {
|
|
|
193
193
|
})
|
|
194
194
|
})
|
|
195
195
|
})
|
|
196
|
+
|
|
197
|
+
describe('AvatarStack', () => {
|
|
198
|
+
it('derives the +N overflow from children beyond max', () => {
|
|
199
|
+
render(
|
|
200
|
+
<AvatarStack max={2}>
|
|
201
|
+
<Avatar label="Alice Adams" />
|
|
202
|
+
<Avatar label="Bob Brown" />
|
|
203
|
+
<Avatar label="Carol Clark" />
|
|
204
|
+
</AvatarStack>,
|
|
205
|
+
)
|
|
206
|
+
expect(screen.getByText('+1')).toBeInTheDocument()
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('adds overflowCount (server-truncated items) to the children-derived overflow', () => {
|
|
210
|
+
// 5 children, max 4 → 1 hidden locally; overflowCount=7 server-hidden → "+8".
|
|
211
|
+
render(
|
|
212
|
+
<AvatarStack max={4} overflowCount={7}>
|
|
213
|
+
{Array.from({ length: 5 }, (_, index) => (
|
|
214
|
+
<Avatar key={index} label={`Owner ${index}`} />
|
|
215
|
+
))}
|
|
216
|
+
</AvatarStack>,
|
|
217
|
+
)
|
|
218
|
+
expect(screen.getByText('+8')).toBeInTheDocument()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('shows no overflow badge when children fit and overflowCount is 0', () => {
|
|
222
|
+
render(
|
|
223
|
+
<AvatarStack max={4}>
|
|
224
|
+
<Avatar label="Alice Adams" />
|
|
225
|
+
<Avatar label="Bob Brown" />
|
|
226
|
+
</AvatarStack>,
|
|
227
|
+
)
|
|
228
|
+
expect(screen.queryByText(/^\+\d/)).not.toBeInTheDocument()
|
|
229
|
+
})
|
|
230
|
+
})
|