@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/ai/AiChat.js.map +1 -1
- package/dist/ai/records/ActivityCard.js +1 -6
- package/dist/ai/records/ActivityCard.js.map +2 -2
- package/dist/ai/records/DealCard.js +2 -23
- package/dist/ai/records/DealCard.js.map +2 -2
- package/dist/ai/records/ProductCard.js +2 -17
- package/dist/ai/records/ProductCard.js.map +2 -2
- package/dist/ai/useAiChat.js.map +1 -1
- package/dist/backend/AppShell.js +15 -8
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js.map +1 -1
- package/dist/backend/DataTable.js +10 -9
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/ProfileDropdown.js +62 -33
- package/dist/backend/ProfileDropdown.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/backend/injection/useGuardedMutation.js.map +1 -1
- package/dist/backend/utils/optimisticLock.js.map +1 -1
- 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/dist/utils/format.js +27 -0
- package/dist/utils/format.js.map +7 -0
- package/package.json +9 -6
- package/src/ai/AiChat.tsx +1 -1
- package/src/ai/__tests__/AiChat.task-plan.test.tsx +1 -1
- package/src/ai/records/ActivityCard.tsx +1 -7
- package/src/ai/records/DealCard.tsx +2 -26
- package/src/ai/records/ProductCard.tsx +2 -19
- package/src/ai/useAiChat.ts +1 -1
- package/src/backend/AppShell.tsx +18 -8
- package/src/backend/CrudForm.tsx +1 -1
- package/src/backend/DataTable.tsx +18 -11
- package/src/backend/ProfileDropdown.tsx +35 -3
- package/src/backend/__tests__/AppShell.test.tsx +61 -1
- package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +52 -0
- package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +99 -0
- package/src/backend/__tests__/ProfileDropdown.test.tsx +59 -0
- package/src/backend/__tests__/lazy-heavy-libraries.test.ts +8 -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/backend/injection/useGuardedMutation.ts +1 -1
- package/src/backend/utils/optimisticLock.ts +1 -1
- 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
- package/src/utils/__tests__/format.test.ts +58 -0
- 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="
|
|
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="
|
|
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
|
+
}
|