@open-mercato/ui 0.6.5-develop.5382.1.f542de69af → 0.6.6-develop.5412.1.e2a52b14f0
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/DataTable.js +4 -3
- 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/primitives/avatar.js +2 -2
- package/dist/primitives/avatar.js.map +2 -2
- package/package.json +4 -4
- package/src/backend/DataTable.tsx +6 -3
- 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/primitives/__tests__/alert.test.tsx +16 -0
- package/src/primitives/__tests__/avatar.test.tsx +36 -1
- package/src/primitives/avatar.tsx +8 -2
|
@@ -264,6 +264,8 @@ export type DataTableProps<T> = {
|
|
|
264
264
|
extensionTableId?: string
|
|
265
265
|
stickyFirstColumn?: boolean
|
|
266
266
|
stickyActionsColumn?: boolean
|
|
267
|
+
/** Horizontal alignment of the row-actions (kebab) column header + cell. Defaults to 'right'. */
|
|
268
|
+
actionsColumnAlign?: 'right' | 'center'
|
|
267
269
|
virtualized?: boolean
|
|
268
270
|
virtualizedMaxHeight?: number | string
|
|
269
271
|
virtualizedOverscan?: number
|
|
@@ -1000,6 +1002,7 @@ export function DataTable<T>({
|
|
|
1000
1002
|
extensionTableId: extensionTableIdProp,
|
|
1001
1003
|
stickyFirstColumn = false,
|
|
1002
1004
|
stickyActionsColumn = false,
|
|
1005
|
+
actionsColumnAlign = 'right',
|
|
1003
1006
|
virtualized = false,
|
|
1004
1007
|
virtualizedMaxHeight,
|
|
1005
1008
|
virtualizedOverscan = 10,
|
|
@@ -2710,7 +2713,7 @@ export function DataTable<T>({
|
|
|
2710
2713
|
<Button
|
|
2711
2714
|
variant="ghost"
|
|
2712
2715
|
type="button"
|
|
2713
|
-
className={`h-auto p-0 font-medium ${sortable && header.column.getCanSort?.() ? 'cursor-pointer select-none' : ''}`}
|
|
2716
|
+
className={`h-auto p-0 has-[>svg]:px-0 font-medium ${sortable && header.column.getCanSort?.() ? 'cursor-pointer select-none' : ''}`}
|
|
2714
2717
|
onClick={() => sortable && header.column.toggleSorting?.(header.column.getIsSorted() === 'asc')}
|
|
2715
2718
|
>
|
|
2716
2719
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
@@ -2737,7 +2740,7 @@ export function DataTable<T>({
|
|
|
2737
2740
|
{rowActions || injectedRowActions.length > 0 ? (
|
|
2738
2741
|
<TableHead
|
|
2739
2742
|
className={cn(
|
|
2740
|
-
'w-0 text-right',
|
|
2743
|
+
actionsColumnAlign === 'center' ? 'w-0 text-center' : 'w-0 text-right',
|
|
2741
2744
|
stickyActionsColumn && `sticky right-0 z-20 bg-background ${STICKY_RIGHT_SHADOW_CLASS}`,
|
|
2742
2745
|
)}
|
|
2743
2746
|
>
|
|
@@ -2868,7 +2871,7 @@ export function DataTable<T>({
|
|
|
2868
2871
|
{rowActions || injectedRowActions.length > 0 ? (
|
|
2869
2872
|
<TableCell
|
|
2870
2873
|
className={cn(
|
|
2871
|
-
'text-right whitespace-nowrap',
|
|
2874
|
+
actionsColumnAlign === 'center' ? 'text-center whitespace-nowrap' : 'text-right whitespace-nowrap',
|
|
2872
2875
|
stickyActionsColumn && `sticky right-0 z-10 bg-background ${STICKY_RIGHT_SHADOW_CLASS}`,
|
|
2873
2876
|
)}
|
|
2874
2877
|
data-actions-cell
|
|
@@ -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(
|
|
@@ -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
|
+
})
|
|
@@ -279,12 +279,18 @@ export type AvatarStackProps = {
|
|
|
279
279
|
max?: number
|
|
280
280
|
size?: VariantProps<typeof avatarVariants>['size']
|
|
281
281
|
className?: string
|
|
282
|
+
/**
|
|
283
|
+
* Additional hidden items not represented by `children` — e.g. a server-truncated list that
|
|
284
|
+
* already returned only the top N owners. Added to the children-derived overflow so the
|
|
285
|
+
* "+N" badge reflects the true number of omitted items, not just the ones dropped locally.
|
|
286
|
+
*/
|
|
287
|
+
overflowCount?: number
|
|
282
288
|
}
|
|
283
289
|
|
|
284
|
-
export function AvatarStack({ children, max = 4, size = 'md', className }: AvatarStackProps) {
|
|
290
|
+
export function AvatarStack({ children, max = 4, size = 'md', className, overflowCount = 0 }: AvatarStackProps) {
|
|
285
291
|
const items = React.Children.toArray(children)
|
|
286
292
|
const visible = items.slice(0, max)
|
|
287
|
-
const overflow = items.length - max
|
|
293
|
+
const overflow = Math.max(0, items.length - max) + Math.max(0, overflowCount)
|
|
288
294
|
|
|
289
295
|
return (
|
|
290
296
|
<div
|