@open-mercato/ui 0.6.8-develop.7099.1.7ba2771d06 → 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.
Files changed (71) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/ai/records/ActivityCard.js +3 -4
  3. package/dist/ai/records/ActivityCard.js.map +2 -2
  4. package/dist/ai/records/DealCard.js +3 -4
  5. package/dist/ai/records/DealCard.js.map +2 -2
  6. package/dist/ai/records/ProductCard.js +2 -3
  7. package/dist/ai/records/ProductCard.js.map +2 -2
  8. package/dist/backend/AppShell.js +106 -129
  9. package/dist/backend/AppShell.js.map +2 -2
  10. package/dist/backend/DataTable.js +9 -22
  11. package/dist/backend/DataTable.js.map +2 -2
  12. package/dist/backend/detail/NotesSection.js +20 -26
  13. package/dist/backend/detail/NotesSection.js.map +2 -2
  14. package/dist/backend/icons/lucideRegistry.generated.js +0 -4
  15. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  16. package/dist/backend/injection/useRegisteredComponent.js +6 -24
  17. package/dist/backend/injection/useRegisteredComponent.js.map +2 -2
  18. package/dist/backend/inputs/ComboboxInput.js +4 -8
  19. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  20. package/dist/backend/inputs/LookupSelect.js +9 -9
  21. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  22. package/dist/backend/sidebar/SidebarCustomizationEditor.js +30 -36
  23. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  24. package/dist/backend/utils/crud.js.map +2 -2
  25. package/dist/backend/utils/useAutoDiscoveredFields.js +8 -4
  26. package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
  27. package/dist/primitives/alert.js +1 -1
  28. package/dist/primitives/alert.js.map +2 -2
  29. package/dist/primitives/icon-button.js +1 -20
  30. package/dist/primitives/icon-button.js.map +2 -2
  31. package/dist/primitives/pagination.js +9 -14
  32. package/dist/primitives/pagination.js.map +2 -2
  33. package/dist/primitives/rich-editor.js +7 -14
  34. package/dist/primitives/rich-editor.js.map +2 -2
  35. package/dist/utils/format.js +5 -5
  36. package/dist/utils/format.js.map +2 -2
  37. package/package.json +4 -5
  38. package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +0 -2
  39. package/src/ai/records/ActivityCard.tsx +3 -4
  40. package/src/ai/records/DealCard.tsx +3 -4
  41. package/src/ai/records/ProductCard.tsx +2 -3
  42. package/src/backend/AppShell.tsx +58 -89
  43. package/src/backend/DataTable.tsx +12 -46
  44. package/src/backend/__tests__/AppShell.test.tsx +1 -162
  45. package/src/backend/__tests__/DataTable.render.test.tsx +0 -37
  46. package/src/backend/__tests__/NotesSection.test.tsx +0 -89
  47. package/src/backend/detail/NotesSection.tsx +67 -92
  48. package/src/backend/icons/lucideRegistry.generated.tsx +0 -4
  49. package/src/backend/injection/useRegisteredComponent.tsx +8 -49
  50. package/src/backend/inputs/ComboboxInput.tsx +5 -21
  51. package/src/backend/inputs/LookupSelect.tsx +14 -10
  52. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +0 -142
  53. package/src/backend/inputs/__tests__/LookupSelect.test.tsx +40 -63
  54. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +11 -14
  55. package/src/backend/utils/__tests__/useAutoDiscoveredFields.groupLabels.test.tsx +59 -0
  56. package/src/backend/utils/crud.ts +0 -2
  57. package/src/backend/utils/useAutoDiscoveredFields.ts +10 -4
  58. package/src/primitives/__tests__/alert.test.tsx +1 -41
  59. package/src/primitives/__tests__/icon-button.test.tsx +0 -70
  60. package/src/primitives/__tests__/notification.test.tsx +1 -1
  61. package/src/primitives/__tests__/pagination.test.tsx +0 -54
  62. package/src/primitives/__tests__/rich-editor.test.tsx +0 -43
  63. package/src/primitives/alert.tsx +2 -5
  64. package/src/primitives/icon-button.tsx +1 -20
  65. package/src/primitives/pagination.tsx +9 -40
  66. package/src/primitives/rich-editor.tsx +4 -9
  67. package/src/utils/__tests__/format.test.ts +14 -27
  68. package/src/utils/format.ts +5 -10
  69. package/src/backend/__tests__/DataTable.cappedPagination.test.tsx +0 -122
  70. package/src/backend/__tests__/DataTable.headerWrap.test.tsx +0 -111
  71. 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(
@@ -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
- // Renders a <div>, not a <p>: callers routinely nest paragraphs, lists and other
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
- <div ref={ref} className={cn('text-sm leading-5', className)} {...props} />
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
- // The pressed surface is repeated under `dark:` on purpose: the outline and
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
- totalIsCapped
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
- // A capped total is a floor: never clamp the current page down to the
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, listPages, siblingCount, boundaryCount),
246
- [safePage, listPages, siblingCount, boundaryCount],
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 upperBound = totalIsCapped ? Math.max(listPages, safePage + 1) : totalPages
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, totalIsCapped, listPages],
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
- {/* When capped, report the best-known floor: a deep page proves at
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 || !canGoNext}
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
- {/* The last-page jump is suppressed for capped totals: it would land
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
- data-slot="rich-editor-toolbar-measure-clip"
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
- <div
1551
- ref={measureRef}
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
- expect(formatCurrency(1234.5, 'usd', EN)).toBe('$1,234.50')
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', EN)).toBe(formatCurrency(1234.5, 'USD', EN))
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, null, EN)).toBe('1,000')
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', EN)).toBe('1,000')
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
- expect(formatDate(LOCAL_MIDDAY, EN)).toBe('Jun 9, 2026')
66
- })
67
-
68
- it('formats in the requested locale rather than the runtime default', () => {
69
- expect(formatDate(LOCAL_MIDDAY, PL)).toBe('9 cze 2026')
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
  })
@@ -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(locale, { style: 'currency', currency: code }).format(numeric)
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(locale).format(numeric)
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. Pass `locale` to format in the application locale.
33
+ * not a parseable date.
39
34
  */
40
- export function formatDate(value: string | null | undefined, locale?: string): string | null {
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(locale, { year: 'numeric', month: 'short', day: 'numeric' })
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
- })