@open-mercato/ui 0.6.5-develop.5337.1.534b781eac → 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.
Files changed (65) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/ai/AiChat.js.map +1 -1
  3. package/dist/ai/records/ActivityCard.js +1 -6
  4. package/dist/ai/records/ActivityCard.js.map +2 -2
  5. package/dist/ai/records/DealCard.js +2 -23
  6. package/dist/ai/records/DealCard.js.map +2 -2
  7. package/dist/ai/records/ProductCard.js +2 -17
  8. package/dist/ai/records/ProductCard.js.map +2 -2
  9. package/dist/ai/useAiChat.js.map +1 -1
  10. package/dist/backend/AppShell.js +15 -8
  11. package/dist/backend/AppShell.js.map +2 -2
  12. package/dist/backend/CrudForm.js.map +1 -1
  13. package/dist/backend/DataTable.js +10 -9
  14. package/dist/backend/DataTable.js.map +2 -2
  15. package/dist/backend/ProfileDropdown.js +62 -33
  16. package/dist/backend/ProfileDropdown.js.map +2 -2
  17. package/dist/backend/charts/KpiCard.js +22 -14
  18. package/dist/backend/charts/KpiCard.js.map +2 -2
  19. package/dist/backend/charts/Sparkline.js +75 -0
  20. package/dist/backend/charts/Sparkline.js.map +7 -0
  21. package/dist/backend/charts/index.js +4 -1
  22. package/dist/backend/charts/index.js.map +2 -2
  23. package/dist/backend/dashboard/DashboardScreen.js +1 -2
  24. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  25. package/dist/backend/injection/InjectionSpot.js +23 -6
  26. package/dist/backend/injection/InjectionSpot.js.map +2 -2
  27. package/dist/backend/injection/useGuardedMutation.js.map +1 -1
  28. package/dist/backend/utils/optimisticLock.js.map +1 -1
  29. package/dist/primitives/avatar.js +2 -2
  30. package/dist/primitives/avatar.js.map +2 -2
  31. package/dist/primitives/pagination.js +3 -3
  32. package/dist/primitives/pagination.js.map +2 -2
  33. package/dist/utils/format.js +27 -0
  34. package/dist/utils/format.js.map +7 -0
  35. package/package.json +9 -6
  36. package/src/ai/AiChat.tsx +1 -1
  37. package/src/ai/__tests__/AiChat.task-plan.test.tsx +1 -1
  38. package/src/ai/records/ActivityCard.tsx +1 -7
  39. package/src/ai/records/DealCard.tsx +2 -26
  40. package/src/ai/records/ProductCard.tsx +2 -19
  41. package/src/ai/useAiChat.ts +1 -1
  42. package/src/backend/AppShell.tsx +18 -8
  43. package/src/backend/CrudForm.tsx +1 -1
  44. package/src/backend/DataTable.tsx +18 -11
  45. package/src/backend/ProfileDropdown.tsx +35 -3
  46. package/src/backend/__tests__/AppShell.test.tsx +61 -1
  47. package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +52 -0
  48. package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +99 -0
  49. package/src/backend/__tests__/ProfileDropdown.test.tsx +59 -0
  50. package/src/backend/__tests__/lazy-heavy-libraries.test.ts +8 -0
  51. package/src/backend/charts/KpiCard.tsx +22 -12
  52. package/src/backend/charts/Sparkline.tsx +87 -0
  53. package/src/backend/charts/index.ts +2 -1
  54. package/src/backend/dashboard/DashboardScreen.tsx +1 -2
  55. package/src/backend/injection/InjectionSpot.tsx +27 -7
  56. package/src/backend/injection/__tests__/InjectionSpot.remount.test.tsx +140 -0
  57. package/src/backend/injection/useGuardedMutation.ts +1 -1
  58. package/src/backend/utils/optimisticLock.ts +1 -1
  59. package/src/primitives/__tests__/alert.test.tsx +16 -0
  60. package/src/primitives/__tests__/avatar.test.tsx +36 -1
  61. package/src/primitives/__tests__/pagination.test.tsx +31 -0
  62. package/src/primitives/avatar.tsx +8 -2
  63. package/src/primitives/pagination.tsx +3 -3
  64. package/src/utils/__tests__/format.test.ts +58 -0
  65. package/src/utils/format.ts +40 -0
@@ -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
+ })
@@ -43,7 +43,7 @@ export function useGuardedMutation<TContext extends GuardedMutationContext>({
43
43
  error,
44
44
  })
45
45
  // Default UX for OSS optimistic-lock conflicts (spec
46
- // .ai/specs/2026-05-25-oss-optimistic-locking.md §3.6): when the
46
+ // .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md §3.6): when the
47
47
  // server returns 409 with `code: 'optimistic_lock_conflict'`,
48
48
  // surface the conflict on the unified, persistent, error-styled
49
49
  // RecordConflictBanner (rendered in AppShell) instead of a transient
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Client-side helpers for the OSS opt-in optimistic-locking guard
3
- * (spec: .ai/specs/2026-05-25-oss-optimistic-locking.md).
3
+ * (spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md).
4
4
  *
5
5
  * These are deliberately small and dependency-light so they can be wired
6
6
  * into any backend page without touching the shared CrudForm / useGuardedMutation
@@ -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
+ })
@@ -60,6 +60,37 @@ describe('Pagination', () => {
60
60
  expect(nav.getAttribute('aria-label')).toBe('Pagination')
61
61
  })
62
62
 
63
+ // The info text, page buttons, and page-size select together need ~600px of
64
+ // min-content (info and the select are shrink-0). Without flex-wrap the row
65
+ // cannot compress on a phone-width container, overflows its card, and drags
66
+ // the whole page into horizontal scroll. Every level that holds a fixed-width,
67
+ // shrink-0 button run must be allowed to wrap: the root row, the controls
68
+ // group (first/prev/pages/next/last ≈ 392px at 6 pages — wider than a phone
69
+ // on its own), and the page-number list itself (so a long run wraps instead
70
+ // of overflowing). Wrapping just the outer row leaves the ~392px controls
71
+ // group on a single unbreakable line that still overflows.
72
+ it('lets every fixed-width control row wrap on narrow containers', () => {
73
+ const { container } = render(
74
+ <Pagination
75
+ page={3}
76
+ pageSize={20}
77
+ total={120}
78
+ onPageChange={() => {}}
79
+ onPageSizeChange={() => {}}
80
+ />,
81
+ )
82
+ const tokensOf = (selector: string) => {
83
+ const el = container.querySelector(selector) as HTMLElement | null
84
+ expect(el).not.toBeNull()
85
+ return (el!.getAttribute('class') ?? '').split(/\s+/).filter(Boolean)
86
+ }
87
+ expect(tokensOf('[data-slot="pagination"]')).toEqual(
88
+ expect.arrayContaining(['flex-wrap', 'justify-between']),
89
+ )
90
+ expect(tokensOf('[data-slot="pagination-controls"]')).toContain('flex-wrap')
91
+ expect(tokensOf('[data-slot="pagination-pages"]')).toContain('flex-wrap')
92
+ })
93
+
63
94
  it('renders "Page X of Y" info by default', () => {
64
95
  const { container } = render(
65
96
  <Pagination page={2} pageSize={10} total={100} onPageChange={() => {}} />,
@@ -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
@@ -235,7 +235,7 @@ export const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(
235
235
  ref={ref}
236
236
  data-slot="pagination"
237
237
  aria-label={props['aria-label'] ?? t('ui.pagination.landmark.ariaLabel', 'Pagination')}
238
- className={cn('flex w-full items-center justify-between gap-6', className)}
238
+ className={cn('flex w-full flex-wrap items-center justify-between gap-x-6 gap-y-2', className)}
239
239
  {...props}
240
240
  >
241
241
  {showInfo ? (
@@ -251,7 +251,7 @@ export const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(
251
251
 
252
252
  <div
253
253
  data-slot="pagination-controls"
254
- className="inline-flex items-center gap-2"
254
+ className="flex flex-wrap items-center justify-center gap-2"
255
255
  >
256
256
  {showFirstLast ? (
257
257
  <button
@@ -280,7 +280,7 @@ export const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(
280
280
 
281
281
  <ol
282
282
  data-slot="pagination-pages"
283
- className="inline-flex items-center gap-2 list-none"
283
+ className="flex flex-wrap items-center justify-center gap-2 list-none"
284
284
  >
285
285
  {items.map((entry, index) => {
286
286
  if (entry === 'ellipsis-left' || entry === 'ellipsis-right') {
@@ -0,0 +1,58 @@
1
+ import { formatCurrency, formatDate } from '../format'
2
+
3
+ describe('formatCurrency', () => {
4
+ it('returns null for empty input', () => {
5
+ expect(formatCurrency(null)).toBeNull()
6
+ expect(formatCurrency(undefined)).toBeNull()
7
+ expect(formatCurrency('')).toBeNull()
8
+ })
9
+
10
+ it('echoes back a non-numeric string', () => {
11
+ expect(formatCurrency('n/a')).toBe('n/a')
12
+ })
13
+
14
+ it('returns null for a non-finite number', () => {
15
+ expect(formatCurrency(Number.NaN)).toBeNull()
16
+ })
17
+
18
+ it('formats a numeric value with an ISO currency code', () => {
19
+ const result = formatCurrency(1234.5, 'usd')
20
+ expect(result).toMatch(/1,234\.5/)
21
+ expect(result).toMatch(/\$|USD/)
22
+ })
23
+
24
+ it('formats a numeric string the same as a number', () => {
25
+ expect(formatCurrency('1234.5', 'USD')).toBe(formatCurrency(1234.5, 'USD'))
26
+ })
27
+
28
+ it('falls back to a plain number without a currency code', () => {
29
+ expect(formatCurrency(1000)).toBe(new Intl.NumberFormat().format(1000))
30
+ })
31
+
32
+ it('ignores currency codes that are not three characters', () => {
33
+ expect(formatCurrency(1000, 'US')).toBe(new Intl.NumberFormat().format(1000))
34
+ })
35
+ })
36
+
37
+ describe('formatDate', () => {
38
+ it('returns null for empty input', () => {
39
+ expect(formatDate(null)).toBeNull()
40
+ expect(formatDate(undefined)).toBeNull()
41
+ expect(formatDate('')).toBeNull()
42
+ })
43
+
44
+ it('echoes back an unparseable date', () => {
45
+ expect(formatDate('not-a-date')).toBe('not-a-date')
46
+ })
47
+
48
+ it('formats a valid ISO date as a localized short date', () => {
49
+ const result = formatDate('2026-06-09T00:00:00.000Z')
50
+ expect(result).toEqual(
51
+ new Date('2026-06-09T00:00:00.000Z').toLocaleDateString(undefined, {
52
+ year: 'numeric',
53
+ month: 'short',
54
+ day: 'numeric',
55
+ }),
56
+ )
57
+ })
58
+ })
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Format a monetary value with an optional ISO-4217 currency code.
3
+ *
4
+ * Returns `null` for empty input, the original string for non-numeric input,
5
+ * and falls back to a plain number format (optionally suffixed with the code)
6
+ * when the currency code is missing or rejected by `Intl.NumberFormat`.
7
+ */
8
+ export function formatCurrency(
9
+ value: string | number | null | undefined,
10
+ currency?: string | null,
11
+ ): string | null {
12
+ if (value === null || value === undefined || value === '') return null
13
+ const numeric = typeof value === 'number' ? value : Number(value)
14
+ if (!Number.isFinite(numeric)) {
15
+ return typeof value === 'string' ? value : null
16
+ }
17
+ const code = currency && currency.length === 3 ? currency.toUpperCase() : undefined
18
+ try {
19
+ if (code) {
20
+ return new Intl.NumberFormat(undefined, { style: 'currency', currency: code }).format(numeric)
21
+ }
22
+ } catch {
23
+ // fall through to plain number formatting
24
+ }
25
+ const formatted = new Intl.NumberFormat().format(numeric)
26
+ return code ? `${formatted} ${code}` : formatted
27
+ }
28
+
29
+ /**
30
+ * Format an ISO date string as a localized short date (e.g. `Jun 9, 2026`).
31
+ *
32
+ * Returns `null` for empty input and echoes the original value back when it is
33
+ * not a parseable date.
34
+ */
35
+ export function formatDate(value: string | null | undefined): string | null {
36
+ if (!value) return null
37
+ const date = new Date(value)
38
+ if (Number.isNaN(date.getTime())) return value
39
+ return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
40
+ }