@open-mercato/ui 0.6.8-develop.7100.1.fbf66fca35 → 0.7.0
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/records/ActivityCard.js +3 -4
- package/dist/ai/records/ActivityCard.js.map +2 -2
- package/dist/ai/records/DealCard.js +3 -4
- package/dist/ai/records/DealCard.js.map +2 -2
- package/dist/ai/records/ProductCard.js +2 -3
- package/dist/ai/records/ProductCard.js.map +2 -2
- package/dist/backend/AppShell.js +106 -129
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/DataTable.js +9 -22
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +20 -26
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +0 -4
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/useRegisteredComponent.js +6 -24
- package/dist/backend/injection/useRegisteredComponent.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +4 -8
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +9 -9
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +30 -36
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/utils/crud.js.map +2 -2
- package/dist/backend/utils/useAutoDiscoveredFields.js +8 -4
- package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
- package/dist/primitives/alert.js +1 -1
- package/dist/primitives/alert.js.map +2 -2
- package/dist/primitives/icon-button.js +1 -20
- package/dist/primitives/icon-button.js.map +2 -2
- package/dist/primitives/pagination.js +9 -14
- package/dist/primitives/pagination.js.map +2 -2
- package/dist/primitives/rich-editor.js +7 -14
- package/dist/primitives/rich-editor.js.map +2 -2
- package/dist/utils/format.js +5 -5
- package/dist/utils/format.js.map +2 -2
- package/package.json +4 -5
- package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +0 -2
- package/src/ai/records/ActivityCard.tsx +3 -4
- package/src/ai/records/DealCard.tsx +3 -4
- package/src/ai/records/ProductCard.tsx +2 -3
- package/src/backend/AppShell.tsx +58 -89
- package/src/backend/DataTable.tsx +12 -46
- package/src/backend/__tests__/AppShell.test.tsx +1 -162
- package/src/backend/__tests__/DataTable.render.test.tsx +0 -37
- package/src/backend/__tests__/NotesSection.test.tsx +0 -89
- package/src/backend/detail/NotesSection.tsx +67 -92
- package/src/backend/icons/lucideRegistry.generated.tsx +0 -4
- package/src/backend/injection/useRegisteredComponent.tsx +8 -49
- package/src/backend/inputs/ComboboxInput.tsx +5 -21
- package/src/backend/inputs/LookupSelect.tsx +14 -10
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +0 -142
- package/src/backend/inputs/__tests__/LookupSelect.test.tsx +40 -63
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +11 -14
- package/src/backend/utils/__tests__/useAutoDiscoveredFields.groupLabels.test.tsx +59 -0
- package/src/backend/utils/crud.ts +0 -2
- package/src/backend/utils/useAutoDiscoveredFields.ts +10 -4
- package/src/primitives/__tests__/alert.test.tsx +1 -41
- package/src/primitives/__tests__/icon-button.test.tsx +0 -70
- package/src/primitives/__tests__/notification.test.tsx +1 -1
- package/src/primitives/__tests__/pagination.test.tsx +0 -54
- package/src/primitives/__tests__/rich-editor.test.tsx +0 -43
- package/src/primitives/alert.tsx +2 -5
- package/src/primitives/icon-button.tsx +1 -20
- package/src/primitives/pagination.tsx +9 -40
- package/src/primitives/rich-editor.tsx +4 -9
- package/src/utils/__tests__/format.test.ts +14 -27
- package/src/utils/format.ts +5 -10
- package/src/backend/__tests__/DataTable.cappedPagination.test.tsx +0 -122
- package/src/backend/__tests__/DataTable.headerWrap.test.tsx +0 -111
- package/src/backend/__tests__/useRegisteredComponent.identity.test.tsx +0 -305
|
@@ -347,49 +347,6 @@ describe('RichEditor — custom variant + compound API', () => {
|
|
|
347
347
|
})
|
|
348
348
|
})
|
|
349
349
|
|
|
350
|
-
// jsdom has no layout engine, so the page-overflow bug this guards against
|
|
351
|
-
// (the hidden measurement row is wider than its column and grows the document)
|
|
352
|
-
// cannot be reproduced by measuring here. What these tests pin instead is the
|
|
353
|
-
// containment structure the fix relies on: the measurement row sits inside its
|
|
354
|
-
// own clipping layer, and the visible toolbar sits outside it so the clip never
|
|
355
|
-
// swallows the toolbar's own drop shadow.
|
|
356
|
-
describe('RichEditor — auto toolbar measurement row containment', () => {
|
|
357
|
-
const renderAutoToolbar = () => {
|
|
358
|
-
const { container } = render(<RichEditor value="" onChange={jest.fn()} variant="full" />)
|
|
359
|
-
const clip = container.querySelector('[data-slot="rich-editor-toolbar-measure-clip"]')!
|
|
360
|
-
const toolbar = container.querySelector('[data-slot="rich-editor-toolbar"]')!
|
|
361
|
-
return { clip, toolbar }
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
it('clips the hidden measurement row in its own layer so it cannot widen the page', () => {
|
|
365
|
-
const { clip } = renderAutoToolbar()
|
|
366
|
-
expect(clip).not.toBeNull()
|
|
367
|
-
expect(clip.getAttribute('aria-hidden')).toBe('true')
|
|
368
|
-
expect(clip.className).toContain('absolute')
|
|
369
|
-
expect(clip.className).toContain('inset-0')
|
|
370
|
-
expect(clip.className).toContain('overflow-hidden')
|
|
371
|
-
|
|
372
|
-
const measureRow = clip.firstElementChild!
|
|
373
|
-
expect(measureRow.className).toContain('invisible')
|
|
374
|
-
expect(measureRow.className).toContain('absolute')
|
|
375
|
-
expect(measureRow.className).toContain('flex-nowrap')
|
|
376
|
-
expect(measureRow.childElementCount).toBeGreaterThan(0)
|
|
377
|
-
})
|
|
378
|
-
|
|
379
|
-
it('keeps the visible toolbar outside the clipping layer so shadow-xs still paints', () => {
|
|
380
|
-
const { clip, toolbar } = renderAutoToolbar()
|
|
381
|
-
expect(clip.contains(toolbar)).toBe(false)
|
|
382
|
-
expect(toolbar.className).toContain('shadow-xs')
|
|
383
|
-
})
|
|
384
|
-
|
|
385
|
-
it('leaves the toolbar wrapper unclipped', () => {
|
|
386
|
-
const { toolbar } = renderAutoToolbar()
|
|
387
|
-
const wrapper = toolbar.parentElement!
|
|
388
|
-
expect(wrapper.className).toContain('relative')
|
|
389
|
-
expect(wrapper.className).not.toContain('overflow-hidden')
|
|
390
|
-
})
|
|
391
|
-
})
|
|
392
|
-
|
|
393
350
|
describe('RichEditorDropdownButton + RichEditorTextDropdown', () => {
|
|
394
351
|
it("RichEditorDropdownButton renders inside <RichEditor variant='custom'>", () => {
|
|
395
352
|
render(
|
package/src/primitives/alert.tsx
CHANGED
|
@@ -252,12 +252,9 @@ const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTM
|
|
|
252
252
|
AlertTitle.displayName = 'AlertTitle'
|
|
253
253
|
|
|
254
254
|
// Figma "Paragraph/Small": Inter Regular 14 / 20.
|
|
255
|
-
|
|
256
|
-
// block elements inside the description, which is invalid inside <p> and makes
|
|
257
|
-
// React fail hydration with "In HTML, <p> cannot be a descendant of <p>".
|
|
258
|
-
const AlertDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
255
|
+
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
|
259
256
|
({ className, ...props }, ref) => (
|
|
260
|
-
<
|
|
257
|
+
<p ref={ref} className={cn('text-sm leading-5', className)} {...props} />
|
|
261
258
|
),
|
|
262
259
|
)
|
|
263
260
|
AlertDescription.displayName = 'AlertDescription'
|
|
@@ -4,26 +4,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
|
|
|
4
4
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
5
5
|
|
|
6
6
|
const iconButtonVariants = cva(
|
|
7
|
-
|
|
8
|
-
// ghost variants ship `dark:bg-input/30` / `dark:hover:bg-accent/50`, which
|
|
9
|
-
// otherwise outrank the un-prefixed `aria-pressed:` background while the
|
|
10
|
-
// `aria-pressed:text-primary-foreground` still applies — leaving a dark icon
|
|
11
|
-
// on a dark surface (invisible pressed star/bell buttons on the dark theme).
|
|
12
|
-
//
|
|
13
|
-
// Every pressed rule is additionally gated on `not-disabled:` because a toggle
|
|
14
|
-
// can be pressed AND disabled at the same time (an active editor tool while the
|
|
15
|
-
// document is read-only). Without the gate the pressed rules outrank the
|
|
16
|
-
// `disabled:` surface — `dark:aria-pressed:bg-primary` is more specific than
|
|
17
|
-
// `disabled:bg-bg-disabled` — and a control the user cannot operate renders
|
|
18
|
-
// as a primary, actionable button.
|
|
19
|
-
//
|
|
20
|
-
// The gate MUST stay `not-disabled:` (`&:not(:disabled)`) and not `enabled:`
|
|
21
|
-
// (`&:enabled`): the CSS `:enabled` pseudo-class only matches form elements,
|
|
22
|
-
// so on an `asChild` host — this primitive is routinely rendered onto a link —
|
|
23
|
-
// no pressed rule could ever match and the pressed state would not paint at
|
|
24
|
-
// all. `:not(:disabled)` gates the same way on a real `<button>` while leaving
|
|
25
|
-
// a non-form host styled.
|
|
26
|
-
"inline-flex items-center justify-center cursor-pointer transition-all outline-none disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-disabled disabled:shadow-none [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:outline-none focus-visible:shadow-focus not-disabled:aria-pressed:bg-primary not-disabled:aria-pressed:text-primary-foreground not-disabled:aria-pressed:hover:bg-primary-hover dark:not-disabled:aria-pressed:bg-primary dark:not-disabled:aria-pressed:hover:bg-primary-hover",
|
|
7
|
+
"inline-flex items-center justify-center cursor-pointer transition-all outline-none disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-disabled disabled:shadow-none [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:outline-none focus-visible:shadow-focus aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary-hover",
|
|
27
8
|
{
|
|
28
9
|
variants: {
|
|
29
10
|
variant: {
|
|
@@ -151,20 +151,6 @@ export type PaginationProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
|
151
151
|
pageSize: number
|
|
152
152
|
/** Total item count. Used to derive `Math.ceil(total / pageSize)` pages. */
|
|
153
153
|
total: number
|
|
154
|
-
/**
|
|
155
|
-
* `total` is a floor, not an exact count (a capped list count,
|
|
156
|
-
* `OM_LIST_COUNT_CAP`). The derived page count then only bounds the page
|
|
157
|
-
* *list*: the current page is never clamped down to it, the last-page jump
|
|
158
|
-
* is suppressed (it would present the floor as the end of the data), and
|
|
159
|
-
* Next stays available past the floor when `hasNextPage` says so.
|
|
160
|
-
*/
|
|
161
|
-
totalIsCapped?: boolean
|
|
162
|
-
/**
|
|
163
|
-
* Caller-provided "a next page exists" signal for the capped case, typically
|
|
164
|
-
* short-page detection (the current page came back full). Ignored when
|
|
165
|
-
* `totalIsCapped` is false; when omitted, Next ends at the known floor.
|
|
166
|
-
*/
|
|
167
|
-
hasNextPage?: boolean
|
|
168
154
|
/** Called when the user changes page. */
|
|
169
155
|
onPageChange: (next: number) => void
|
|
170
156
|
/** Called when the user changes page size. Optional — when omitted,
|
|
@@ -202,8 +188,6 @@ export const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(
|
|
|
202
188
|
page,
|
|
203
189
|
pageSize,
|
|
204
190
|
total,
|
|
205
|
-
totalIsCapped = false,
|
|
206
|
-
hasNextPage,
|
|
207
191
|
onPageChange,
|
|
208
192
|
onPageSizeChange,
|
|
209
193
|
pageSizeOptions = [10, 25, 50, 100],
|
|
@@ -224,37 +208,26 @@ export const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(
|
|
|
224
208
|
const resolvedFormatPageInfo =
|
|
225
209
|
formatPageInfo ??
|
|
226
210
|
((p: number, total: number) =>
|
|
227
|
-
|
|
228
|
-
? t('ui.pagination.info.pageOfCapped', 'Page {page} of {total}+', { page: p, total })
|
|
229
|
-
: t('ui.pagination.info.pageOf', 'Page {page} of {total}', { page: p, total }))
|
|
211
|
+
t('ui.pagination.info.pageOf', 'Page {page} of {total}', { page: p, total }))
|
|
230
212
|
const resolvedFormatPageSizeLabel =
|
|
231
213
|
formatPageSizeLabel ??
|
|
232
214
|
((size: number) =>
|
|
233
215
|
t('ui.pagination.itemsPerPage.label', '{size} / page', { size }))
|
|
234
216
|
const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)))
|
|
235
|
-
|
|
236
|
-
// derived count — a page past the floor holds reachable rows.
|
|
237
|
-
const safePage = totalIsCapped
|
|
238
|
-
? Math.max(1, page)
|
|
239
|
-
: Math.min(Math.max(1, page), totalPages)
|
|
240
|
-
const listPages = Math.max(totalPages, safePage)
|
|
241
|
-
const canGoNext = totalIsCapped
|
|
242
|
-
? (hasNextPage ?? safePage < listPages)
|
|
243
|
-
: safePage < totalPages
|
|
217
|
+
const safePage = Math.min(Math.max(1, page), totalPages)
|
|
244
218
|
const items = React.useMemo(
|
|
245
|
-
() => buildPaginationItems(safePage,
|
|
246
|
-
[safePage,
|
|
219
|
+
() => buildPaginationItems(safePage, totalPages, siblingCount, boundaryCount),
|
|
220
|
+
[safePage, totalPages, siblingCount, boundaryCount],
|
|
247
221
|
)
|
|
248
222
|
const showPageSize = showPageSizeProp ?? Boolean(onPageSizeChange)
|
|
249
223
|
|
|
250
224
|
const goTo = React.useCallback(
|
|
251
225
|
(next: number) => {
|
|
252
226
|
if (disabled) return
|
|
253
|
-
const
|
|
254
|
-
const bounded = Math.min(Math.max(1, next), upperBound)
|
|
227
|
+
const bounded = Math.min(Math.max(1, next), totalPages)
|
|
255
228
|
if (bounded !== safePage) onPageChange(bounded)
|
|
256
229
|
},
|
|
257
|
-
[disabled, onPageChange, safePage, totalPages
|
|
230
|
+
[disabled, onPageChange, safePage, totalPages],
|
|
258
231
|
)
|
|
259
232
|
|
|
260
233
|
return (
|
|
@@ -270,9 +243,7 @@ export const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(
|
|
|
270
243
|
data-slot="pagination-info"
|
|
271
244
|
className="shrink-0 text-sm text-muted-foreground tabular-nums"
|
|
272
245
|
>
|
|
273
|
-
{
|
|
274
|
-
least that many pages exist. */}
|
|
275
|
-
{resolvedFormatPageInfo(safePage, totalIsCapped ? listPages : totalPages)}
|
|
246
|
+
{resolvedFormatPageInfo(safePage, totalPages)}
|
|
276
247
|
</div>
|
|
277
248
|
) : (
|
|
278
249
|
<div />
|
|
@@ -353,16 +324,14 @@ export const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(
|
|
|
353
324
|
type="button"
|
|
354
325
|
data-slot="pagination-next"
|
|
355
326
|
aria-label={t('ui.pagination.next.ariaLabel', 'Next page')}
|
|
356
|
-
disabled={disabled ||
|
|
327
|
+
disabled={disabled || safePage >= totalPages}
|
|
357
328
|
onClick={() => goTo(safePage + 1)}
|
|
358
329
|
className={cn(navButtonVariants())}
|
|
359
330
|
>
|
|
360
331
|
<ChevronRight aria-hidden="true" className="size-5" />
|
|
361
332
|
</button>
|
|
362
333
|
) : null}
|
|
363
|
-
{
|
|
364
|
-
on the floor page while presenting itself as the end of the data. */}
|
|
365
|
-
{showFirstLast && !totalIsCapped ? (
|
|
334
|
+
{showFirstLast ? (
|
|
366
335
|
<button
|
|
367
336
|
type="button"
|
|
368
337
|
data-slot="pagination-last"
|
|
@@ -1543,17 +1543,12 @@ function RichEditorAutoToolbar({
|
|
|
1543
1543
|
return (
|
|
1544
1544
|
<div className="relative w-full">
|
|
1545
1545
|
<div
|
|
1546
|
+
ref={measureRef}
|
|
1546
1547
|
aria-hidden="true"
|
|
1547
|
-
|
|
1548
|
-
className="pointer-events-none absolute inset-0 overflow-hidden"
|
|
1548
|
+
className="pointer-events-none invisible absolute left-0 top-0 flex flex-row flex-nowrap items-center gap-0.5"
|
|
1549
1549
|
>
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
className="invisible absolute left-0 top-0 flex flex-row flex-nowrap items-center gap-0.5"
|
|
1553
|
-
>
|
|
1554
|
-
{items.map(renderToolbarItem)}
|
|
1555
|
-
<RichEditorDropdownButton icon={<MoreVertical />} ariaLabel={labels.more} showChevron={false} menu={<div />} />
|
|
1556
|
-
</div>
|
|
1550
|
+
{items.map(renderToolbarItem)}
|
|
1551
|
+
<RichEditorDropdownButton icon={<MoreVertical />} ariaLabel={labels.more} showChevron={false} menu={<div />} />
|
|
1557
1552
|
</div>
|
|
1558
1553
|
<RichEditorToolbar ref={containerRef} className="w-full flex-nowrap overflow-hidden">
|
|
1559
1554
|
{visibleItems.map(renderToolbarItem)}
|
|
@@ -1,17 +1,5 @@
|
|
|
1
1
|
import { formatCurrency, formatDate } from '../format'
|
|
2
2
|
|
|
3
|
-
// Every assertion below pins the locale explicitly (#5105). Without it these tests read the
|
|
4
|
-
// runtime's default locale, so they pass in en-US and fail on any contributor machine whose
|
|
5
|
-
// default is something else.
|
|
6
|
-
const EN = 'en-US'
|
|
7
|
-
const PL = 'pl-PL'
|
|
8
|
-
|
|
9
|
-
// A date-time literal without an offset is parsed as *local* time, so it lands on the same
|
|
10
|
-
// calendar day in every timezone and both the month and the day can be asserted exactly. The
|
|
11
|
-
// instant form cannot: midday UTC keeps the month stable but still shifts the day at the
|
|
12
|
-
// extremes (UTC+14 / UTC-12), which is why the assertions below no longer use one.
|
|
13
|
-
const LOCAL_MIDDAY = '2026-06-09T12:00:00'
|
|
14
|
-
|
|
15
3
|
describe('formatCurrency', () => {
|
|
16
4
|
it('returns null for empty input', () => {
|
|
17
5
|
expect(formatCurrency(null)).toBeNull()
|
|
@@ -28,25 +16,21 @@ describe('formatCurrency', () => {
|
|
|
28
16
|
})
|
|
29
17
|
|
|
30
18
|
it('formats a numeric value with an ISO currency code', () => {
|
|
31
|
-
|
|
19
|
+
const result = formatCurrency(1234.5, 'usd')
|
|
20
|
+
expect(result).toMatch(/1,234\.5/)
|
|
21
|
+
expect(result).toMatch(/\$|USD/)
|
|
32
22
|
})
|
|
33
23
|
|
|
34
24
|
it('formats a numeric string the same as a number', () => {
|
|
35
|
-
expect(formatCurrency('1234.5', 'USD'
|
|
25
|
+
expect(formatCurrency('1234.5', 'USD')).toBe(formatCurrency(1234.5, 'USD'))
|
|
36
26
|
})
|
|
37
27
|
|
|
38
28
|
it('falls back to a plain number without a currency code', () => {
|
|
39
|
-
expect(formatCurrency(1000
|
|
29
|
+
expect(formatCurrency(1000)).toBe(new Intl.NumberFormat().format(1000))
|
|
40
30
|
})
|
|
41
31
|
|
|
42
32
|
it('ignores currency codes that are not three characters', () => {
|
|
43
|
-
expect(formatCurrency(1000, 'US'
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
it('formats in the requested locale rather than the runtime default', () => {
|
|
47
|
-
expect(formatCurrency(1234.5, 'USD', PL)).toMatch(/^1234,50/)
|
|
48
|
-
expect(formatCurrency(1234.5, 'USD', PL)).toMatch(/USD/)
|
|
49
|
-
expect(formatCurrency(1000, null, PL)).toBe('1000')
|
|
33
|
+
expect(formatCurrency(1000, 'US')).toBe(new Intl.NumberFormat().format(1000))
|
|
50
34
|
})
|
|
51
35
|
})
|
|
52
36
|
|
|
@@ -62,10 +46,13 @@ describe('formatDate', () => {
|
|
|
62
46
|
})
|
|
63
47
|
|
|
64
48
|
it('formats a valid ISO date as a localized short date', () => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
+
)
|
|
70
57
|
})
|
|
71
58
|
})
|
package/src/utils/format.ts
CHANGED
|
@@ -4,15 +4,10 @@
|
|
|
4
4
|
* Returns `null` for empty input, the original string for non-numeric input,
|
|
5
5
|
* and falls back to a plain number format (optionally suffixed with the code)
|
|
6
6
|
* when the currency code is missing or rejected by `Intl.NumberFormat`.
|
|
7
|
-
*
|
|
8
|
-
* Pass `locale` to format in the application locale (`useLocale()` in client
|
|
9
|
-
* components). Omitting it keeps the runtime default, which varies per machine
|
|
10
|
-
* and is therefore not assertable in tests.
|
|
11
7
|
*/
|
|
12
8
|
export function formatCurrency(
|
|
13
9
|
value: string | number | null | undefined,
|
|
14
10
|
currency?: string | null,
|
|
15
|
-
locale?: string,
|
|
16
11
|
): string | null {
|
|
17
12
|
if (value === null || value === undefined || value === '') return null
|
|
18
13
|
const numeric = typeof value === 'number' ? value : Number(value)
|
|
@@ -22,12 +17,12 @@ export function formatCurrency(
|
|
|
22
17
|
const code = currency && currency.length === 3 ? currency.toUpperCase() : undefined
|
|
23
18
|
try {
|
|
24
19
|
if (code) {
|
|
25
|
-
return new Intl.NumberFormat(
|
|
20
|
+
return new Intl.NumberFormat(undefined, { style: 'currency', currency: code }).format(numeric)
|
|
26
21
|
}
|
|
27
22
|
} catch {
|
|
28
23
|
// fall through to plain number formatting
|
|
29
24
|
}
|
|
30
|
-
const formatted = new Intl.NumberFormat(
|
|
25
|
+
const formatted = new Intl.NumberFormat().format(numeric)
|
|
31
26
|
return code ? `${formatted} ${code}` : formatted
|
|
32
27
|
}
|
|
33
28
|
|
|
@@ -35,11 +30,11 @@ export function formatCurrency(
|
|
|
35
30
|
* Format an ISO date string as a localized short date (e.g. `Jun 9, 2026`).
|
|
36
31
|
*
|
|
37
32
|
* Returns `null` for empty input and echoes the original value back when it is
|
|
38
|
-
* not a parseable date.
|
|
33
|
+
* not a parseable date.
|
|
39
34
|
*/
|
|
40
|
-
export function formatDate(value: string | null | undefined
|
|
35
|
+
export function formatDate(value: string | null | undefined): string | null {
|
|
41
36
|
if (!value) return null
|
|
42
37
|
const date = new Date(value)
|
|
43
38
|
if (Number.isNaN(date.getTime())) return value
|
|
44
|
-
return date.toLocaleDateString(
|
|
39
|
+
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
|
45
40
|
}
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
/** @jest-environment jsdom */
|
|
2
|
-
import * as React from 'react'
|
|
3
|
-
import { fireEvent, render, screen, within } from '@testing-library/react'
|
|
4
|
-
import { DataTable } from '../DataTable'
|
|
5
|
-
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
6
|
-
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
7
|
-
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
8
|
-
|
|
9
|
-
jest.mock('next/navigation', () => ({
|
|
10
|
-
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
|
|
11
|
-
}))
|
|
12
|
-
|
|
13
|
-
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
14
|
-
useInjectionDataWidgets: () => ({ widgets: [], isLoading: false }),
|
|
15
|
-
}))
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* The capped-count feature's risky half lives in the pager: `Pagination` decides
|
|
19
|
-
* from `totalIsCapped` plus a `hasNextPage` that `DataTable` derives from the
|
|
20
|
-
* rendered row count whether a row past the cap is reachable at all. The unit
|
|
21
|
-
* tests on the primitive pin its own logic; these pin the wiring between a
|
|
22
|
-
* capped list payload and that primitive, which is where a regression would
|
|
23
|
-
* silently strand data while every other test stayed green.
|
|
24
|
-
*
|
|
25
|
-
* Derived from the manual UI QA on #5228: 31 rows behind a reported total of 3.
|
|
26
|
-
*/
|
|
27
|
-
type Row = { id: string; name: string }
|
|
28
|
-
|
|
29
|
-
const columns: ColumnDef<Row>[] = [{ accessorKey: 'name', header: 'Name' }]
|
|
30
|
-
|
|
31
|
-
const PAGE_SIZE = 20
|
|
32
|
-
|
|
33
|
-
function rows(count: number, offset = 0): Row[] {
|
|
34
|
-
return Array.from({ length: count }, (_, i) => ({
|
|
35
|
-
id: `row-${offset + i + 1}`,
|
|
36
|
-
name: `Row ${offset + i + 1}`,
|
|
37
|
-
}))
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function renderTable(pagination: Record<string, unknown>, data: Row[]) {
|
|
41
|
-
const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0, retry: false } } })
|
|
42
|
-
return render(
|
|
43
|
-
<QueryClientProvider client={queryClient}>
|
|
44
|
-
<I18nProvider locale="en" dict={{}}>
|
|
45
|
-
<DataTable columns={columns as any} data={data} pagination={pagination as any} />
|
|
46
|
-
</I18nProvider>
|
|
47
|
-
</QueryClientProvider>,
|
|
48
|
-
)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const info = () => document.querySelector('[data-slot="pagination-info"]')?.textContent ?? ''
|
|
52
|
-
const next = () => document.querySelector('[data-slot="pagination-next"]') as HTMLButtonElement | null
|
|
53
|
-
const last = () => document.querySelector('[data-slot="pagination-last"]')
|
|
54
|
-
|
|
55
|
-
describe('DataTable pagination with a capped total', () => {
|
|
56
|
-
// The floor must read as a floor. A capped list showing "of 3 results" would
|
|
57
|
-
// state a number the server explicitly refused to vouch for.
|
|
58
|
-
it('renders the total as a floor and hides the jump-to-last control', () => {
|
|
59
|
-
renderTable(
|
|
60
|
-
{ page: 1, pageSize: PAGE_SIZE, total: 3, totalPages: 1, totalIsCapped: true, onPageChange: jest.fn() },
|
|
61
|
-
rows(PAGE_SIZE),
|
|
62
|
-
)
|
|
63
|
-
expect(info()).toBe('Showing 1 to 20 of 3+ results')
|
|
64
|
-
// The jump would land on the floor's last page while presenting itself as
|
|
65
|
-
// the end of the data, which is precisely the lie this feature must avoid.
|
|
66
|
-
expect(last()).toBeNull()
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
// This is the assertion that would have caught an unfixed clamp: with a
|
|
70
|
-
// floor-derived page count of 1, a naive pager disables Next on page 1 and
|
|
71
|
-
// every row past the cap becomes unreachable.
|
|
72
|
-
it('keeps Next live past the floor while the page comes back full', () => {
|
|
73
|
-
const onPageChange = jest.fn()
|
|
74
|
-
renderTable(
|
|
75
|
-
{ page: 1, pageSize: PAGE_SIZE, total: 3, totalPages: 1, totalIsCapped: true, onPageChange },
|
|
76
|
-
rows(PAGE_SIZE),
|
|
77
|
-
)
|
|
78
|
-
const button = next()
|
|
79
|
-
expect(button?.disabled).toBe(false)
|
|
80
|
-
fireEvent.click(button!)
|
|
81
|
-
expect(onPageChange).toHaveBeenCalledWith(2)
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
// Deep-linking past the floor must not bounce the user back, and the range
|
|
85
|
-
// label has to describe the rows actually on screen rather than the floor.
|
|
86
|
-
it('serves a page past the floor without snapping back, and retires Next on a short page', () => {
|
|
87
|
-
const onPageChange = jest.fn()
|
|
88
|
-
renderTable(
|
|
89
|
-
{ page: 2, pageSize: PAGE_SIZE, total: 3, totalPages: 1, totalIsCapped: true, onPageChange },
|
|
90
|
-
rows(11, PAGE_SIZE),
|
|
91
|
-
)
|
|
92
|
-
expect(info()).toBe('Showing 21 to 31 of 3+ results')
|
|
93
|
-
expect(within(document.body).getByText('Row 31')).toBeInTheDocument()
|
|
94
|
-
// Short page ⇒ nothing beyond it, so Next retires here rather than at the floor.
|
|
95
|
-
expect(next()?.disabled).toBe(true)
|
|
96
|
-
expect(onPageChange).not.toHaveBeenCalled()
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
// Capping is conditional: below the cap the server sends no flag, and the
|
|
100
|
-
// table must be byte-identical to its pre-feature behaviour.
|
|
101
|
-
it('leaves an uncapped list unchanged — exact total, jump-to-last offered', () => {
|
|
102
|
-
renderTable(
|
|
103
|
-
{ page: 1, pageSize: PAGE_SIZE, total: 31, totalPages: 2, onPageChange: jest.fn() },
|
|
104
|
-
rows(PAGE_SIZE),
|
|
105
|
-
)
|
|
106
|
-
expect(info()).toBe('Showing 1 to 20 of 31 results')
|
|
107
|
-
expect(last()).not.toBeNull()
|
|
108
|
-
expect(next()?.disabled).toBe(false)
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
// The exact-multiple false positive: short-page detection cannot distinguish
|
|
112
|
-
// "a full last page" from "more to come", so Next survives one page too far.
|
|
113
|
-
// The label must not then claim a row it is not showing.
|
|
114
|
-
it('claims no range when a capped page comes back empty', () => {
|
|
115
|
-
renderTable(
|
|
116
|
-
{ page: 3, pageSize: PAGE_SIZE, total: 3, totalPages: 1, totalIsCapped: true, onPageChange: jest.fn() },
|
|
117
|
-
[],
|
|
118
|
-
)
|
|
119
|
-
expect(info()).toBe('No further results past 3')
|
|
120
|
-
expect(next()?.disabled).toBe(true)
|
|
121
|
-
})
|
|
122
|
-
})
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
/** @jest-environment jsdom */
|
|
2
|
-
import * as React from 'react'
|
|
3
|
-
import { DataTable } from '../DataTable'
|
|
4
|
-
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
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
|
-
|
|
9
|
-
jest.mock('next/navigation', () => ({
|
|
10
|
-
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
|
|
11
|
-
}))
|
|
12
|
-
|
|
13
|
-
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
14
|
-
useInjectionDataWidgets: () => ({ widgets: [], isLoading: false }),
|
|
15
|
-
}))
|
|
16
|
-
|
|
17
|
-
type Row = { id: string; title: string }
|
|
18
|
-
|
|
19
|
-
const tokensOf = (el: Element | null): string[] =>
|
|
20
|
-
(el?.getAttribute('class') ?? '').split(/\s+/).filter(Boolean)
|
|
21
|
-
|
|
22
|
-
// The header used to be a single non-wrapping `sm:flex-row` line. A title
|
|
23
|
-
// sitting next to a long action group (Create + Export + Refresh + injected
|
|
24
|
-
// toolbar buttons) had nothing but `min-w-0` holding it, so it collapsed to a
|
|
25
|
-
// sliver while the action buttons — which wrap on their own — spilled onto a
|
|
26
|
-
// second line and rendered over it. The row now wraps as a unit, the title
|
|
27
|
-
// keeps a 12rem floor, and the actions stay right-aligned in either layout.
|
|
28
|
-
describe('DataTable header row wrapping', () => {
|
|
29
|
-
function renderHeaderTable() {
|
|
30
|
-
const columns: ColumnDef<Row>[] = [{ accessorKey: 'title', header: 'Title' }]
|
|
31
|
-
const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
|
|
32
|
-
const result = render(
|
|
33
|
-
<QueryClientProvider client={queryClient}>
|
|
34
|
-
<I18nProvider locale="en" dict={{}}>
|
|
35
|
-
<DataTable
|
|
36
|
-
title="Documents"
|
|
37
|
-
actions={<button type="button">Create document</button>}
|
|
38
|
-
columns={columns}
|
|
39
|
-
data={[{ id: '1', title: 'Solar rollout' }]}
|
|
40
|
-
/>
|
|
41
|
-
</I18nProvider>
|
|
42
|
-
</QueryClientProvider>,
|
|
43
|
-
)
|
|
44
|
-
return { ...result, queryClient }
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function headerParts(container: HTMLElement) {
|
|
48
|
-
const headerContent = Array.from(container.querySelectorAll('div')).find((el) =>
|
|
49
|
-
tokensOf(el).includes('sm:flex-row'),
|
|
50
|
-
) ?? null
|
|
51
|
-
return {
|
|
52
|
-
headerContent,
|
|
53
|
-
titleSlot: headerContent?.firstElementChild ?? null,
|
|
54
|
-
actionsSlot: headerContent?.lastElementChild ?? null,
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
it('lets the header row wrap instead of crushing the title', () => {
|
|
59
|
-
const { container, queryClient } = renderHeaderTable()
|
|
60
|
-
try {
|
|
61
|
-
const { headerContent } = headerParts(container)
|
|
62
|
-
expect(headerContent).not.toBeNull()
|
|
63
|
-
expect(tokensOf(headerContent)).toEqual(
|
|
64
|
-
expect.arrayContaining([
|
|
65
|
-
'flex',
|
|
66
|
-
'flex-col',
|
|
67
|
-
'gap-2',
|
|
68
|
-
'sm:flex-row',
|
|
69
|
-
'sm:flex-wrap',
|
|
70
|
-
'sm:items-center',
|
|
71
|
-
'sm:justify-between',
|
|
72
|
-
]),
|
|
73
|
-
)
|
|
74
|
-
} finally {
|
|
75
|
-
queryClient.clear()
|
|
76
|
-
}
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it('gives the title a wrap threshold instead of an unbounded shrink', () => {
|
|
80
|
-
const { container, queryClient } = renderHeaderTable()
|
|
81
|
-
try {
|
|
82
|
-
const { titleSlot } = headerParts(container)
|
|
83
|
-
expect(titleSlot?.textContent).toContain('Documents')
|
|
84
|
-
expect(tokensOf(titleSlot)).toEqual(
|
|
85
|
-
expect.arrayContaining(['flex-1', 'min-w-0', 'sm:basis-48']),
|
|
86
|
-
)
|
|
87
|
-
} finally {
|
|
88
|
-
queryClient.clear()
|
|
89
|
-
}
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
it('keeps the actions right-aligned once the row has wrapped', () => {
|
|
93
|
-
const { container, queryClient } = renderHeaderTable()
|
|
94
|
-
try {
|
|
95
|
-
const { actionsSlot } = headerParts(container)
|
|
96
|
-
expect(actionsSlot?.textContent).toContain('Create document')
|
|
97
|
-
expect(tokensOf(actionsSlot)).toEqual(
|
|
98
|
-
expect.arrayContaining([
|
|
99
|
-
'flex',
|
|
100
|
-
'flex-wrap',
|
|
101
|
-
'items-center',
|
|
102
|
-
'gap-2',
|
|
103
|
-
'sm:ml-auto',
|
|
104
|
-
'sm:justify-end',
|
|
105
|
-
]),
|
|
106
|
-
)
|
|
107
|
-
} finally {
|
|
108
|
-
queryClient.clear()
|
|
109
|
-
}
|
|
110
|
-
})
|
|
111
|
-
})
|