@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.
- 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
|
@@ -396,148 +396,6 @@ describe('ComboboxInput — eager label resolution', () => {
|
|
|
396
396
|
rerender(<ComboboxInput value="b" onChange={() => {}} resolveLabel={resolveLabel} />)
|
|
397
397
|
await waitFor(() => expect(getInput(container).value).toBe('Label-b'))
|
|
398
398
|
})
|
|
399
|
-
|
|
400
|
-
it('shows a default value that arrives after autoFocus already took the field', async () => {
|
|
401
|
-
const options = [{ value: 'wh-1', label: 'Central warehouse' }]
|
|
402
|
-
const { container, rerender } = render(
|
|
403
|
-
<ComboboxInput value="" onChange={() => {}} autoFocus seedOptions={options} />,
|
|
404
|
-
)
|
|
405
|
-
expect(document.activeElement).toBe(getInput(container))
|
|
406
|
-
rerender(<ComboboxInput value="wh-1" onChange={() => {}} autoFocus seedOptions={options} />)
|
|
407
|
-
await waitFor(() => expect(getInput(container).value).toBe('Central warehouse'))
|
|
408
|
-
})
|
|
409
|
-
|
|
410
|
-
// Guards the opposite direction of the case above: it passes on both the old
|
|
411
|
-
// focus-based guard and the current typing-based one, so it is a behavior pin
|
|
412
|
-
// rather than a regression test for any specific bug.
|
|
413
|
-
it('does not overwrite the query while the user is typing into a focused field', async () => {
|
|
414
|
-
const options = [
|
|
415
|
-
{ value: 'red', label: 'Red' },
|
|
416
|
-
{ value: 'green', label: 'Green' },
|
|
417
|
-
]
|
|
418
|
-
const { container, rerender } = render(
|
|
419
|
-
<ComboboxInput value="red" onChange={() => {}} seedOptions={options} />,
|
|
420
|
-
)
|
|
421
|
-
await waitFor(() => expect(getInput(container).value).toBe('Red'))
|
|
422
|
-
const input = getInput(container)
|
|
423
|
-
act(() => {
|
|
424
|
-
input.focus()
|
|
425
|
-
fireEvent.change(input, { target: { value: 'gre' } })
|
|
426
|
-
})
|
|
427
|
-
rerender(<ComboboxInput value="green" onChange={() => {}} seedOptions={options} />)
|
|
428
|
-
expect(getInput(container).value).toBe('gre')
|
|
429
|
-
})
|
|
430
|
-
|
|
431
|
-
// The regression the typing-based guard originally introduced: `selectValue` clears
|
|
432
|
-
// `userTypedRef` while the field keeps focus, so a follow-up load that no longer
|
|
433
|
-
// carries the picked option let the self-mapping placeholder overwrite the label with
|
|
434
|
-
// the raw record id. Fails on the intermediate implementation, passes on this one.
|
|
435
|
-
it('keeps a picked label when a follow-up load drops the option from the list', async () => {
|
|
436
|
-
const loadSuggestions = jest.fn(async (query?: string) =>
|
|
437
|
-
query === 'WH-B' ? [{ value: 'wh-b', label: 'Warehouse B (WH-B)' }] : [],
|
|
438
|
-
)
|
|
439
|
-
function Controlled() {
|
|
440
|
-
const [value, setValue] = React.useState('')
|
|
441
|
-
return (
|
|
442
|
-
<ComboboxInput
|
|
443
|
-
value={value}
|
|
444
|
-
onChange={setValue}
|
|
445
|
-
loadSuggestions={loadSuggestions}
|
|
446
|
-
allowCustomValues={false}
|
|
447
|
-
/>
|
|
448
|
-
)
|
|
449
|
-
}
|
|
450
|
-
const { container } = render(<Controlled />)
|
|
451
|
-
const input = getInput(container)
|
|
452
|
-
act(() => {
|
|
453
|
-
input.focus()
|
|
454
|
-
fireEvent.change(input, { target: { value: 'WH-B' } })
|
|
455
|
-
})
|
|
456
|
-
|
|
457
|
-
const option = await screen.findByRole('option', { name: /Warehouse B \(WH-B\)/ })
|
|
458
|
-
act(() => {
|
|
459
|
-
fireEvent.click(option)
|
|
460
|
-
})
|
|
461
|
-
expect(getInput(container).value).toBe('Warehouse B (WH-B)')
|
|
462
|
-
expect(document.activeElement).toBe(getInput(container))
|
|
463
|
-
|
|
464
|
-
// Picking rewrote the query to the label, which re-fires the debounced loader; the
|
|
465
|
-
// route cannot match a composite label, so the option list comes back empty.
|
|
466
|
-
await waitFor(() => expect(loadSuggestions).toHaveBeenCalledWith('Warehouse B (WH-B)'))
|
|
467
|
-
await act(async () => {
|
|
468
|
-
await new Promise((res) => setTimeout(res, 250))
|
|
469
|
-
})
|
|
470
|
-
expect(getInput(container).value).toBe('Warehouse B (WH-B)')
|
|
471
|
-
})
|
|
472
|
-
|
|
473
|
-
it('adopts a real label that arrives while the field is focused', async () => {
|
|
474
|
-
const { container, rerender } = render(
|
|
475
|
-
<ComboboxInput value="wh-1" onChange={() => {}} seedOptions={[]} />,
|
|
476
|
-
)
|
|
477
|
-
const input = getInput(container)
|
|
478
|
-
act(() => {
|
|
479
|
-
input.focus()
|
|
480
|
-
})
|
|
481
|
-
expect(getInput(container).value).toBe('wh-1')
|
|
482
|
-
|
|
483
|
-
rerender(
|
|
484
|
-
<ComboboxInput
|
|
485
|
-
value="wh-1"
|
|
486
|
-
onChange={() => {}}
|
|
487
|
-
seedOptions={[{ value: 'wh-1', label: 'Central warehouse' }]}
|
|
488
|
-
/>,
|
|
489
|
-
)
|
|
490
|
-
await waitFor(() => expect(getInput(container).value).toBe('Central warehouse'))
|
|
491
|
-
})
|
|
492
|
-
|
|
493
|
-
it('does not resurrect the previous label when the parent keeps the value after a clear', () => {
|
|
494
|
-
const onChange = jest.fn()
|
|
495
|
-
const { container } = render(
|
|
496
|
-
<ComboboxInput
|
|
497
|
-
value="red"
|
|
498
|
-
onChange={onChange}
|
|
499
|
-
clearable
|
|
500
|
-
seedOptions={[{ value: 'red', label: 'Red' }]}
|
|
501
|
-
/>,
|
|
502
|
-
)
|
|
503
|
-
expect(getInput(container).value).toBe('Red')
|
|
504
|
-
|
|
505
|
-
act(() => {
|
|
506
|
-
fireEvent.click(screen.getByRole('button', { name: /clear value/i }))
|
|
507
|
-
})
|
|
508
|
-
|
|
509
|
-
expect(onChange).toHaveBeenCalledWith('')
|
|
510
|
-
expect(getInput(container).value).toBe('')
|
|
511
|
-
})
|
|
512
|
-
|
|
513
|
-
it('resumes syncing after a blur, so a later external change is not mistaken for typing', async () => {
|
|
514
|
-
const options = [
|
|
515
|
-
{ value: 'red', label: 'Red' },
|
|
516
|
-
{ value: 'green', label: 'Green' },
|
|
517
|
-
]
|
|
518
|
-
const { container, rerender } = render(
|
|
519
|
-
<ComboboxInput value="red" onChange={() => {}} seedOptions={options} allowCustomValues={false} />,
|
|
520
|
-
)
|
|
521
|
-
const input = getInput(container)
|
|
522
|
-
act(() => {
|
|
523
|
-
input.focus()
|
|
524
|
-
fireEvent.change(input, { target: { value: 'gre' } })
|
|
525
|
-
})
|
|
526
|
-
expect(getInput(container).value).toBe('gre')
|
|
527
|
-
|
|
528
|
-
act(() => {
|
|
529
|
-
fireEvent.blur(input)
|
|
530
|
-
})
|
|
531
|
-
await waitFor(() => expect(getInput(container).value).toBe('Red'))
|
|
532
|
-
|
|
533
|
-
act(() => {
|
|
534
|
-
getInput(container).focus()
|
|
535
|
-
})
|
|
536
|
-
rerender(
|
|
537
|
-
<ComboboxInput value="green" onChange={() => {}} seedOptions={options} allowCustomValues={false} />,
|
|
538
|
-
)
|
|
539
|
-
await waitFor(() => expect(getInput(container).value).toBe('Green'))
|
|
540
|
-
})
|
|
541
399
|
})
|
|
542
400
|
|
|
543
401
|
describe('ComboboxInput accessibility', () => {
|
|
@@ -162,79 +162,56 @@ describe('LookupSelect keyboard accessibility', () => {
|
|
|
162
162
|
expect(input.value).toBe('')
|
|
163
163
|
expect(escapeSpy).not.toHaveBeenCalled()
|
|
164
164
|
})
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
// `disabled` used to gate only the search box, so a caller that locked the
|
|
168
|
-
// control still shipped a live option list: the selected card kept its click and
|
|
169
|
-
// Enter/Space handlers, "Clear selection" stayed reachable, and the action slot
|
|
170
|
-
// could still create a new record. Issue #5248 depended on `disabled` meaning
|
|
171
|
-
// "no interaction at all", so every one of those paths is pinned here.
|
|
172
|
-
describe('LookupSelect disabled', () => {
|
|
173
|
-
const SELECTED = [{ id: 'product-1', title: 'Product One' }]
|
|
174
165
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
disabled
|
|
182
|
-
actionSlot={
|
|
183
|
-
<button type="button" data-testid="quick-create">
|
|
184
|
-
Create
|
|
185
|
-
</button>
|
|
186
|
-
}
|
|
187
|
-
clearLabel="Clear selection"
|
|
188
|
-
/>,
|
|
166
|
+
// Issue #5456 item 3: a `minQuery` of 0 makes `shouldSearch` true for the empty
|
|
167
|
+
// query, so the full option list renders before any interaction. Callers that
|
|
168
|
+
// want the collapsed "start typing" affordance must keep minQuery >= 1.
|
|
169
|
+
it('renders pre-expanded with minQuery=0 and collapsed with minQuery=1', async () => {
|
|
170
|
+
const expanded = render(
|
|
171
|
+
<LookupSelect value={null} onChange={() => {}} fetchItems={async () => ITEMS} minQuery={0} />,
|
|
189
172
|
)
|
|
190
|
-
|
|
173
|
+
expect(await expanded.findAllByRole('option')).toHaveLength(2)
|
|
174
|
+
expanded.unmount()
|
|
191
175
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
it('ignores clicks on the option row', () => {
|
|
198
|
-
const onChange = jest.fn()
|
|
199
|
-
renderDisabled(onChange)
|
|
200
|
-
|
|
201
|
-
fireEvent.click(screen.getByRole('option'))
|
|
176
|
+
const collapsed = render(
|
|
177
|
+
<LookupSelect value={null} onChange={() => {}} fetchItems={async () => ITEMS} minQuery={1} />,
|
|
178
|
+
)
|
|
179
|
+
expect(collapsed.queryByRole('listbox')).toBeNull()
|
|
180
|
+
expect(collapsed.getByText('Start typing to search.')).toBeInTheDocument()
|
|
202
181
|
|
|
203
|
-
|
|
182
|
+
fireEvent.change(getInput(collapsed.container), { target: { value: 'F' } })
|
|
183
|
+
expect(await collapsed.findAllByRole('option')).toHaveLength(2)
|
|
204
184
|
})
|
|
205
185
|
|
|
206
|
-
|
|
186
|
+
// A selection must never be invisible. Before this, `shouldSearch` only made an
|
|
187
|
+
// exception for a set `value` when the caller ALSO passed `options`, so a
|
|
188
|
+
// minQuery >= 1 lookup without that prop collapsed over its own selection: no
|
|
189
|
+
// selected row, no checkmark and no clear control — the user could not see or
|
|
190
|
+
// undo what was chosen (review of #5481, order-line Status field).
|
|
191
|
+
it('keeps a made selection visible while collapsed, without an options prop', async () => {
|
|
207
192
|
const onChange = jest.fn()
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
expect(option).toHaveAttribute('aria-disabled', 'true')
|
|
217
|
-
})
|
|
193
|
+
const view = render(
|
|
194
|
+
<LookupSelect
|
|
195
|
+
value="plot-1"
|
|
196
|
+
onChange={onChange}
|
|
197
|
+
fetchItems={async () => ITEMS}
|
|
198
|
+
minQuery={1}
|
|
199
|
+
/>,
|
|
200
|
+
)
|
|
218
201
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
expect(screen.queryByRole('button', { name: /clear selection/i })).toBeNull()
|
|
222
|
-
})
|
|
202
|
+
const selected = await view.findByRole('option', { selected: true })
|
|
203
|
+
expect(selected).toHaveTextContent(ITEMS[0].title)
|
|
223
204
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
expect(
|
|
205
|
+
const clear = view.getByRole('button', { name: 'Clear selection' })
|
|
206
|
+
fireEvent.click(clear)
|
|
207
|
+
expect(onChange).toHaveBeenCalledWith(null)
|
|
227
208
|
})
|
|
228
209
|
|
|
229
|
-
it('
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
expect(
|
|
235
|
-
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
|
236
|
-
fireEvent.keyDown(input, { key: 'Enter' })
|
|
237
|
-
|
|
238
|
-
expect(onChange).not.toHaveBeenCalled()
|
|
210
|
+
it('stays collapsed at minQuery >= 1 once the selection is cleared', async () => {
|
|
211
|
+
const view = render(
|
|
212
|
+
<LookupSelect value={null} onChange={() => {}} fetchItems={async () => ITEMS} minQuery={1} />,
|
|
213
|
+
)
|
|
214
|
+
expect(view.queryByRole('listbox')).toBeNull()
|
|
215
|
+
expect(view.getByText('Start typing to search.')).toBeInTheDocument()
|
|
239
216
|
})
|
|
240
217
|
})
|
|
@@ -1579,10 +1579,11 @@ function SidebarPreview({
|
|
|
1579
1579
|
|
|
1580
1580
|
return (
|
|
1581
1581
|
<div className="relative w-[240px] overflow-hidden rounded-xl border bg-background shadow-sm">
|
|
1582
|
-
{/* Match AppShell's
|
|
1583
|
-
|
|
1582
|
+
{/* Match AppShell's outer aside: border-r, py-4, px-3 — minus border-r since the
|
|
1583
|
+
card border already serves that purpose, plus rounded so it reads as a preview tile. */}
|
|
1584
|
+
<div className="flex flex-col gap-3 px-3 py-4">
|
|
1584
1585
|
{/* Brand block — same classes as AppShell brand tile */}
|
|
1585
|
-
<div className="mb-2
|
|
1586
|
+
<div className="mb-2">
|
|
1586
1587
|
<div className="flex items-center gap-3 rounded-xl p-3">
|
|
1587
1588
|
<Image
|
|
1588
1589
|
src="/open-mercato.svg"
|
|
@@ -1595,13 +1596,11 @@ function SidebarPreview({
|
|
|
1595
1596
|
</div>
|
|
1596
1597
|
</div>
|
|
1597
1598
|
{/* Search input mock — same container styling as the real sidebar */}
|
|
1598
|
-
<div className="
|
|
1599
|
-
<
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
</span>
|
|
1604
|
-
</div>
|
|
1599
|
+
<div className="mb-2 flex items-center gap-2 rounded-lg border border-border bg-background pl-2.5 pr-2 py-2 shadow-sm">
|
|
1600
|
+
<Search className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
1601
|
+
<span className="min-w-0 flex-1 text-sm text-muted-foreground/70 truncate">
|
|
1602
|
+
{t('appShell.sidebarCustomizationPreviewSearchPlaceholder', 'Search...')}
|
|
1603
|
+
</span>
|
|
1605
1604
|
</div>
|
|
1606
1605
|
{groups.length === 0 ? (
|
|
1607
1606
|
<p className="px-2 text-sm text-muted-foreground">
|
|
@@ -1613,10 +1612,7 @@ function SidebarPreview({
|
|
|
1613
1612
|
const visibleItems = group.items.filter((item) => item.hidden !== true)
|
|
1614
1613
|
if (visibleItems.length === 0) return null
|
|
1615
1614
|
return (
|
|
1616
|
-
<div
|
|
1617
|
-
key={resolveGroupKey(group)}
|
|
1618
|
-
className={`px-3 ${gi < groups.length - 1 ? 'border-b pb-2' : ''}`}
|
|
1619
|
-
>
|
|
1615
|
+
<div key={resolveGroupKey(group)}>
|
|
1620
1616
|
<div className="w-full px-1 justify-between flex text-xs font-medium uppercase tracking-wider text-muted-foreground/70 py-1">
|
|
1621
1617
|
<span>{group.name}</span>
|
|
1622
1618
|
</div>
|
|
@@ -1645,6 +1641,7 @@ function SidebarPreview({
|
|
|
1645
1641
|
)
|
|
1646
1642
|
})}
|
|
1647
1643
|
</div>
|
|
1644
|
+
{gi < groups.length - 1 ? <div className="my-2 border-t -ml-3 -mr-4" /> : null}
|
|
1648
1645
|
</div>
|
|
1649
1646
|
)
|
|
1650
1647
|
})}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import * as React from 'react'
|
|
5
|
+
import { renderHook } from '@testing-library/react'
|
|
6
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
7
|
+
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
8
|
+
import { useAutoDiscoveredFields } from '../useAutoDiscoveredFields'
|
|
9
|
+
import type { CustomFieldDefDto } from '../customFieldDefs'
|
|
10
|
+
|
|
11
|
+
type Row = { id: string; name: string }
|
|
12
|
+
|
|
13
|
+
const COLUMNS: ColumnDef<Row>[] = [
|
|
14
|
+
{ accessorKey: 'name', header: 'Name' },
|
|
15
|
+
{ accessorKey: 'grouped', header: 'Grouped', meta: { columnChooserGroup: 'Shipping' } },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const CUSTOM_FIELD_DEFS = [
|
|
19
|
+
{ key: 'note', label: 'Note', kind: 'text' },
|
|
20
|
+
] as unknown as CustomFieldDefDto[]
|
|
21
|
+
|
|
22
|
+
function wrapperFor(locale: string, dict: Record<string, string>) {
|
|
23
|
+
return function Wrapper({ children }: { children: React.ReactNode }) {
|
|
24
|
+
return <I18nProvider locale={locale} dict={dict}>{children}</I18nProvider>
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function renderFields(locale: string, dict: Record<string, string>) {
|
|
29
|
+
return renderHook(
|
|
30
|
+
() => useAutoDiscoveredFields<Row>({ columns: COLUMNS, customFieldDefs: CUSTOM_FIELD_DEFS }),
|
|
31
|
+
{ wrapper: wrapperFor(locale, dict) },
|
|
32
|
+
).result.current
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('useAutoDiscoveredFields group labels', () => {
|
|
36
|
+
it('falls back to English group labels when no translation is registered', () => {
|
|
37
|
+
const { columnChooserFields } = renderFields('en', {})
|
|
38
|
+
expect(columnChooserFields.find((field) => field.key === 'name')?.group).toBe('Columns')
|
|
39
|
+
expect(columnChooserFields.find((field) => field.key === 'cf_note')?.group).toBe('Custom Fields')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('translates the default and custom-field group labels', () => {
|
|
43
|
+
const { columnChooserFields, advancedFilterFields } = renderFields('pl', {
|
|
44
|
+
'ui.columnChooser.defaultGroup': 'Kolumny',
|
|
45
|
+
'ui.columnChooser.customFieldsGroup': 'Pola niestandardowe',
|
|
46
|
+
})
|
|
47
|
+
expect(columnChooserFields.find((field) => field.key === 'name')?.group).toBe('Kolumny')
|
|
48
|
+
expect(columnChooserFields.find((field) => field.key === 'cf_note')?.group).toBe('Pola niestandardowe')
|
|
49
|
+
expect(advancedFilterFields.find((field) => field.key === 'cf_note')?.group).toBe('Pola niestandardowe')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('keeps explicit column-chooser groups untouched', () => {
|
|
53
|
+
const { columnChooserFields } = renderFields('pl', {
|
|
54
|
+
'ui.columnChooser.defaultGroup': 'Kolumny',
|
|
55
|
+
'ui.columnChooser.customFieldsGroup': 'Pola niestandardowe',
|
|
56
|
+
})
|
|
57
|
+
expect(columnChooserFields.find((field) => field.key === 'grouped')?.group).toBe('Shipping')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -6,8 +6,6 @@ export type ListResponse<T> = {
|
|
|
6
6
|
page: number
|
|
7
7
|
pageSize: number
|
|
8
8
|
totalPages: number
|
|
9
|
-
/** Present (true) when the server capped the count: `total`/`totalPages` are floors. */
|
|
10
|
-
totalIsCapped?: boolean
|
|
11
9
|
}
|
|
12
10
|
|
|
13
11
|
export type CrudExportFormat = 'csv' | 'json' | 'xml' | 'markdown'
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import type { RowData } from '@tanstack/react-table'
|
|
4
4
|
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
5
|
+
import { useOptionalT } from '@open-mercato/shared/lib/i18n/context'
|
|
5
6
|
import type { FilterFieldDef as AdvancedFilterFieldDef, FilterFieldType, FilterOption } from '@open-mercato/shared/lib/query/advanced-filter'
|
|
6
7
|
import type { ColumnChooserField } from '../columns/ColumnChooserPanel'
|
|
7
8
|
import type { CustomFieldDefDto } from './customFieldDefs'
|
|
@@ -61,6 +62,11 @@ export function useAutoDiscoveredFields<T extends RowData = RowData>({
|
|
|
61
62
|
columns,
|
|
62
63
|
customFieldDefs,
|
|
63
64
|
}: UseAutoDiscoveredFieldsInput<T>): UseAutoDiscoveredFieldsResult {
|
|
65
|
+
// Optional translator: this hook is a public entry point third-party modules
|
|
66
|
+
// render, and it worked without an I18nProvider before it needed labels.
|
|
67
|
+
const t = useOptionalT()
|
|
68
|
+
const defaultGroupLabel = t?.('ui.columnChooser.defaultGroup', 'Columns') ?? 'Columns'
|
|
69
|
+
const customFieldsGroupLabel = t?.('ui.columnChooser.customFieldsGroup', 'Custom Fields') ?? 'Custom Fields'
|
|
64
70
|
return React.useMemo(() => {
|
|
65
71
|
const filterFields: AdvancedFilterFieldDef[] = []
|
|
66
72
|
const chooserFields: ColumnChooserField[] = []
|
|
@@ -99,7 +105,7 @@ export function useAutoDiscoveredFields<T extends RowData = RowData>({
|
|
|
99
105
|
chooserFields.push({
|
|
100
106
|
key: accessorKey,
|
|
101
107
|
label,
|
|
102
|
-
group: meta?.columnChooserGroup ??
|
|
108
|
+
group: meta?.columnChooserGroup ?? defaultGroupLabel,
|
|
103
109
|
alwaysVisible: meta?.alwaysVisible ?? i === 0,
|
|
104
110
|
defaultVisible: true,
|
|
105
111
|
})
|
|
@@ -118,7 +124,7 @@ export function useAutoDiscoveredFields<T extends RowData = RowData>({
|
|
|
118
124
|
key: filterKey,
|
|
119
125
|
label: def.label || def.key,
|
|
120
126
|
type,
|
|
121
|
-
group:
|
|
127
|
+
group: customFieldsGroupLabel,
|
|
122
128
|
}
|
|
123
129
|
if (type === 'select' && Array.isArray(def.options) && def.options.length) {
|
|
124
130
|
field.options = normalizeCustomFieldFilterOptions(def.options)
|
|
@@ -130,12 +136,12 @@ export function useAutoDiscoveredFields<T extends RowData = RowData>({
|
|
|
130
136
|
chooserFields.push({
|
|
131
137
|
key: filterKey,
|
|
132
138
|
label: def.label || def.key,
|
|
133
|
-
group: def.group?.title ??
|
|
139
|
+
group: def.group?.title ?? customFieldsGroupLabel,
|
|
134
140
|
defaultVisible: false,
|
|
135
141
|
})
|
|
136
142
|
}
|
|
137
143
|
}
|
|
138
144
|
|
|
139
145
|
return { advancedFilterFields: filterFields, columnChooserFields: chooserFields }
|
|
140
|
-
}, [columns, customFieldDefs])
|
|
146
|
+
}, [columns, customFieldDefs, defaultGroupLabel, customFieldsGroupLabel])
|
|
141
147
|
}
|
|
@@ -189,47 +189,7 @@ describe('Alert primitive', () => {
|
|
|
189
189
|
</Alert>,
|
|
190
190
|
)
|
|
191
191
|
expect(screen.getByText('Saved').tagName).toBe('H5')
|
|
192
|
-
expect(screen.getByText('Your changes were saved.').tagName).toBe('
|
|
193
|
-
})
|
|
194
|
-
|
|
195
|
-
describe('AlertDescription accepts block-level children', () => {
|
|
196
|
-
it('renders a div so nested paragraphs stay valid HTML', () => {
|
|
197
|
-
const { container } = render(
|
|
198
|
-
<Alert status="information">
|
|
199
|
-
<AlertDescription>
|
|
200
|
-
<p>First paragraph.</p>
|
|
201
|
-
<p>Second paragraph.</p>
|
|
202
|
-
</AlertDescription>
|
|
203
|
-
</Alert>,
|
|
204
|
-
)
|
|
205
|
-
expect(screen.getByText('First paragraph.').parentElement?.tagName).toBe('DIV')
|
|
206
|
-
expect(container.querySelectorAll('p p')).toHaveLength(0)
|
|
207
|
-
})
|
|
208
|
-
|
|
209
|
-
it('keeps a nested list out of a paragraph', () => {
|
|
210
|
-
const { container } = render(
|
|
211
|
-
<Alert status="warning">
|
|
212
|
-
<AlertDescription>
|
|
213
|
-
<p>These records changed:</p>
|
|
214
|
-
<ul>
|
|
215
|
-
<li>r-1</li>
|
|
216
|
-
</ul>
|
|
217
|
-
</AlertDescription>
|
|
218
|
-
</Alert>,
|
|
219
|
-
)
|
|
220
|
-
expect(container.querySelectorAll('p ul')).toHaveLength(0)
|
|
221
|
-
expect(container.querySelectorAll('p p')).toHaveLength(0)
|
|
222
|
-
})
|
|
223
|
-
|
|
224
|
-
it('forwards a ref to the rendered div', () => {
|
|
225
|
-
const ref = React.createRef<HTMLDivElement>()
|
|
226
|
-
render(
|
|
227
|
-
<Alert>
|
|
228
|
-
<AlertDescription ref={ref}>Body</AlertDescription>
|
|
229
|
-
</Alert>,
|
|
230
|
-
)
|
|
231
|
-
expect(ref.current).toBeInstanceOf(HTMLDivElement)
|
|
232
|
-
})
|
|
192
|
+
expect(screen.getByText('Your changes were saved.').tagName).toBe('P')
|
|
233
193
|
})
|
|
234
194
|
|
|
235
195
|
describe('legacy variant prop (BC)', () => {
|
|
@@ -38,73 +38,3 @@ describe('IconButton solid variants', () => {
|
|
|
38
38
|
expect(className).not.toContain('dark:bg-input')
|
|
39
39
|
})
|
|
40
40
|
})
|
|
41
|
-
|
|
42
|
-
describe('IconButton pressed state', () => {
|
|
43
|
-
// The pressed surface must be restated under `dark:` for the same reason:
|
|
44
|
-
// `variant="outline"` (`dark:bg-input/30`) and `variant="ghost"`
|
|
45
|
-
// (`dark:hover:bg-accent/50`) otherwise outrank `aria-pressed:bg-primary`
|
|
46
|
-
// while `aria-pressed:text-primary-foreground` still applies, leaving a
|
|
47
|
-
// dark icon on a dark surface (invisible favorite/watch toggles).
|
|
48
|
-
it.each(['outline', 'ghost'] as const)('keeps the primary fill on a pressed %s button in dark mode', (variant) => {
|
|
49
|
-
const classes = iconButtonVariants({ variant })
|
|
50
|
-
expect(classes).toContain('not-disabled:aria-pressed:bg-primary')
|
|
51
|
-
expect(classes).toContain('dark:not-disabled:aria-pressed:bg-primary')
|
|
52
|
-
expect(classes).toContain('dark:not-disabled:aria-pressed:hover:bg-primary-hover')
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
// A toggle can be pressed and disabled at once — an active editor tool on a
|
|
56
|
-
// read-only document. The pressed rules are more specific than the
|
|
57
|
-
// `disabled:` surface, so without a not-disabled gate the disabled control
|
|
58
|
-
// renders as a primary, actionable button in both themes.
|
|
59
|
-
it.each(['outline', 'ghost'] as const)(
|
|
60
|
-
'never lets the pressed surface outrank the disabled surface on a %s button',
|
|
61
|
-
(variant) => {
|
|
62
|
-
const classes = iconButtonVariants({ variant })
|
|
63
|
-
expect(classes).toContain('disabled:bg-bg-disabled')
|
|
64
|
-
expect(classes).toContain('disabled:text-text-disabled')
|
|
65
|
-
for (const pressed of classes.split(' ').filter((cls) => cls.includes('aria-pressed:'))) {
|
|
66
|
-
expect(pressed).toContain('not-disabled:aria-pressed:')
|
|
67
|
-
}
|
|
68
|
-
},
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
it('renders a pressed disabled toggle with only not-disabled-gated pressed rules', () => {
|
|
72
|
-
const { getByRole } = render(
|
|
73
|
-
<IconButton variant="outline" aria-label="Highlight" aria-pressed disabled>
|
|
74
|
-
<svg />
|
|
75
|
-
</IconButton>,
|
|
76
|
-
)
|
|
77
|
-
const classes = getByRole('button').className.split(' ')
|
|
78
|
-
expect(classes).toContain('disabled:bg-bg-disabled')
|
|
79
|
-
expect(classes).not.toContain('aria-pressed:bg-primary')
|
|
80
|
-
expect(classes).not.toContain('dark:aria-pressed:bg-primary')
|
|
81
|
-
expect(classes).toContain('not-disabled:aria-pressed:bg-primary')
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
// `enabled:` compiles to `&:enabled`, which the CSS spec restricts to form
|
|
85
|
-
// elements. `IconButton` supports `asChild` and is rendered onto links across
|
|
86
|
-
// the app (MessagesIcon, RoleAssignmentRow, DealsSection), so an `enabled:`
|
|
87
|
-
// gate would silently drop the pressed state on every one of those hosts.
|
|
88
|
-
// `not-disabled:` (`&:not(:disabled)`) matches a non-form host just fine.
|
|
89
|
-
it('keeps the pressed surface on an asChild anchor host', () => {
|
|
90
|
-
const { getByRole } = render(
|
|
91
|
-
<IconButton variant="ghost" asChild aria-pressed>
|
|
92
|
-
<a href="/inbox" aria-label="Messages">
|
|
93
|
-
<svg />
|
|
94
|
-
</a>
|
|
95
|
-
</IconButton>,
|
|
96
|
-
)
|
|
97
|
-
const anchor = getByRole('link')
|
|
98
|
-
const classes = anchor.className.split(' ')
|
|
99
|
-
expect(anchor.tagName).toBe('A')
|
|
100
|
-
expect(classes).toContain('not-disabled:aria-pressed:bg-primary')
|
|
101
|
-
expect(classes).toContain('not-disabled:aria-pressed:text-primary-foreground')
|
|
102
|
-
expect(classes).toContain('dark:not-disabled:aria-pressed:bg-primary')
|
|
103
|
-
// The whole point of the fix: no pressed rule may be gated on `:enabled`,
|
|
104
|
-
// which can never match an anchor.
|
|
105
|
-
for (const pressed of classes.filter((cls) => cls.includes('aria-pressed:'))) {
|
|
106
|
-
expect(pressed).not.toContain('enabled:aria-pressed:')
|
|
107
|
-
expect(pressed).toContain('not-disabled:aria-pressed:')
|
|
108
|
-
}
|
|
109
|
-
})
|
|
110
|
-
})
|
|
@@ -26,7 +26,7 @@ describe('Notification primitive', () => {
|
|
|
26
26
|
it('renders title as AlertTitle and description below', () => {
|
|
27
27
|
render(<Notification title="John commented" description="On the latest deal" />)
|
|
28
28
|
expect(screen.getByText('John commented').tagName).toBe('H5')
|
|
29
|
-
expect(screen.getByText('On the latest deal').tagName).toBe('
|
|
29
|
+
expect(screen.getByText('On the latest deal').tagName).toBe('P')
|
|
30
30
|
})
|
|
31
31
|
|
|
32
32
|
it('description gets opacity-70 (DS opacity scale; was opacity-72 in early v5, snapped to scale per .ai/ds-rules.md)', () => {
|
|
@@ -297,57 +297,3 @@ describe('Pagination', () => {
|
|
|
297
297
|
expect(ref.current?.getAttribute('data-slot')).toBe('pagination')
|
|
298
298
|
})
|
|
299
299
|
})
|
|
300
|
-
|
|
301
|
-
describe('Pagination with a capped total (totalIsCapped)', () => {
|
|
302
|
-
// 100 rows / pageSize 10 → a floor of 10 pages; the real set is larger.
|
|
303
|
-
const capped = { page: 1, pageSize: 10, total: 100, totalIsCapped: true, onPageChange: () => {} }
|
|
304
|
-
|
|
305
|
-
it('renders the capped page info with a trailing plus', () => {
|
|
306
|
-
const { container } = render(<Pagination {...capped} />)
|
|
307
|
-
const info = container.querySelector('[data-slot="pagination-info"]')
|
|
308
|
-
expect(info?.textContent).toBe('Page 1 of 10+')
|
|
309
|
-
})
|
|
310
|
-
|
|
311
|
-
it('suppresses the last-page jump — it would present the floor as the end of the data', () => {
|
|
312
|
-
const { container } = render(<Pagination {...capped} />)
|
|
313
|
-
expect(container.querySelector('[data-slot="pagination-last"]')).toBeNull()
|
|
314
|
-
expect(container.querySelector('[data-slot="pagination-first"]')).not.toBeNull()
|
|
315
|
-
})
|
|
316
|
-
|
|
317
|
-
it('keeps Next enabled past the floor while hasNextPage is true', () => {
|
|
318
|
-
const onPageChange = jest.fn()
|
|
319
|
-
const { container } = render(
|
|
320
|
-
<Pagination {...capped} page={10} hasNextPage onPageChange={onPageChange} />,
|
|
321
|
-
)
|
|
322
|
-
const next = container.querySelector('[data-slot="pagination-next"]') as HTMLButtonElement
|
|
323
|
-
expect(next.disabled).toBe(false)
|
|
324
|
-
fireEvent.click(next)
|
|
325
|
-
expect(onPageChange).toHaveBeenCalledWith(11)
|
|
326
|
-
})
|
|
327
|
-
|
|
328
|
-
it('disables Next at the floor when hasNextPage reports a short page', () => {
|
|
329
|
-
const { container } = render(<Pagination {...capped} page={10} hasNextPage={false} />)
|
|
330
|
-
const next = container.querySelector('[data-slot="pagination-next"]') as HTMLButtonElement
|
|
331
|
-
expect(next.disabled).toBe(true)
|
|
332
|
-
})
|
|
333
|
-
|
|
334
|
-
it('never clamps a deep-linked page down to the floor', () => {
|
|
335
|
-
const { container } = render(<Pagination {...capped} page={37} hasNextPage />)
|
|
336
|
-
const current = container.querySelector('[data-slot="pagination-page"][data-state="on"]')
|
|
337
|
-
expect(current?.textContent).toBe('37')
|
|
338
|
-
const info = container.querySelector('[data-slot="pagination-info"]')
|
|
339
|
-
expect(info?.textContent).toBe('Page 37 of 37+')
|
|
340
|
-
})
|
|
341
|
-
|
|
342
|
-
it('keeps exact-total behavior byte-identical when the flag is absent', () => {
|
|
343
|
-
const onPageChange = jest.fn()
|
|
344
|
-
const { container } = render(
|
|
345
|
-
<Pagination page={10} pageSize={10} total={100} onPageChange={onPageChange} />,
|
|
346
|
-
)
|
|
347
|
-
const next = container.querySelector('[data-slot="pagination-next"]') as HTMLButtonElement
|
|
348
|
-
expect(next.disabled).toBe(true)
|
|
349
|
-
expect(container.querySelector('[data-slot="pagination-last"]')).not.toBeNull()
|
|
350
|
-
const info = container.querySelector('[data-slot="pagination-info"]')
|
|
351
|
-
expect(info?.textContent).toBe('Page 10 of 10')
|
|
352
|
-
})
|
|
353
|
-
})
|