@open-mercato/ui 0.7.0 → 0.7.1-develop.7102.1.b41f7e3e51
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 +4 -3
- package/dist/ai/records/ActivityCard.js.map +2 -2
- package/dist/ai/records/DealCard.js +4 -3
- package/dist/ai/records/DealCard.js.map +2 -2
- package/dist/ai/records/ProductCard.js +3 -2
- package/dist/ai/records/ProductCard.js.map +2 -2
- package/dist/backend/AppShell.js +129 -106
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/DataTable.js +20 -7
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +26 -20
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +4 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/useRegisteredComponent.js +24 -6
- package/dist/backend/injection/useRegisteredComponent.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +8 -4
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +8 -8
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +36 -30
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/utils/crud.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 +20 -1
- package/dist/primitives/icon-button.js.map +2 -2
- package/dist/primitives/pagination.js +14 -9
- package/dist/primitives/pagination.js.map +2 -2
- package/dist/primitives/rich-editor.js +14 -7
- 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 +5 -4
- package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +2 -0
- package/src/ai/records/ActivityCard.tsx +4 -3
- package/src/ai/records/DealCard.tsx +4 -3
- package/src/ai/records/ProductCard.tsx +3 -2
- package/src/backend/AppShell.tsx +89 -58
- package/src/backend/DataTable.tsx +44 -10
- package/src/backend/__tests__/AppShell.test.tsx +162 -1
- package/src/backend/__tests__/DataTable.cappedPagination.test.tsx +122 -0
- package/src/backend/__tests__/DataTable.headerWrap.test.tsx +111 -0
- package/src/backend/__tests__/DataTable.render.test.tsx +37 -0
- package/src/backend/__tests__/NotesSection.test.tsx +89 -0
- package/src/backend/__tests__/useRegisteredComponent.identity.test.tsx +305 -0
- package/src/backend/detail/NotesSection.tsx +92 -67
- package/src/backend/icons/lucideRegistry.generated.tsx +4 -0
- package/src/backend/injection/useRegisteredComponent.tsx +49 -8
- package/src/backend/inputs/ComboboxInput.tsx +21 -5
- package/src/backend/inputs/LookupSelect.tsx +8 -8
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +142 -0
- package/src/backend/inputs/__tests__/LookupSelect.test.tsx +75 -0
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +14 -11
- package/src/backend/utils/crud.ts +2 -0
- package/src/primitives/__tests__/alert.test.tsx +41 -1
- package/src/primitives/__tests__/icon-button.test.tsx +70 -0
- package/src/primitives/__tests__/notification.test.tsx +1 -1
- package/src/primitives/__tests__/pagination.test.tsx +54 -0
- package/src/primitives/__tests__/rich-editor.test.tsx +43 -0
- package/src/primitives/alert.tsx +5 -2
- package/src/primitives/icon-button.tsx +20 -1
- package/src/primitives/pagination.tsx +40 -9
- package/src/primitives/rich-editor.tsx +9 -4
- package/src/utils/__tests__/format.test.ts +27 -14
- package/src/utils/format.ts +10 -5
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
})
|
|
@@ -128,4 +128,41 @@ describe('DataTable SSR render', () => {
|
|
|
128
128
|
queryClient.clear()
|
|
129
129
|
}
|
|
130
130
|
})
|
|
131
|
+
|
|
132
|
+
it('forwards the click event to onRowClick, and skips it when clicking the actions cell', () => {
|
|
133
|
+
const columns: ColumnDef<Row>[] = [
|
|
134
|
+
{ accessorKey: 'name', header: 'Name' },
|
|
135
|
+
]
|
|
136
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
|
|
137
|
+
let capturedRowTagName: string | null = null
|
|
138
|
+
const onRowClick = jest.fn((_row: Row, event: React.MouseEvent<HTMLTableRowElement>) => {
|
|
139
|
+
capturedRowTagName = event.currentTarget.tagName
|
|
140
|
+
})
|
|
141
|
+
try {
|
|
142
|
+
render(
|
|
143
|
+
<QueryClientProvider client={queryClient}>
|
|
144
|
+
<I18nProvider locale="en" dict={{}}>
|
|
145
|
+
<DataTable
|
|
146
|
+
columns={columns}
|
|
147
|
+
data={[{ id: '1', name: 'Ada' }]}
|
|
148
|
+
onRowClick={onRowClick}
|
|
149
|
+
rowActions={() => <button type="button">Edit</button>}
|
|
150
|
+
/>
|
|
151
|
+
</I18nProvider>
|
|
152
|
+
</QueryClientProvider>,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
fireEvent.click(screen.getByText('Ada'))
|
|
156
|
+
expect(onRowClick).toHaveBeenCalledTimes(1)
|
|
157
|
+
const [row, event] = onRowClick.mock.calls[0]
|
|
158
|
+
expect(row).toEqual({ id: '1', name: 'Ada' })
|
|
159
|
+
expect(event.type).toBe('click')
|
|
160
|
+
expect(capturedRowTagName).toBe('TR')
|
|
161
|
+
|
|
162
|
+
fireEvent.click(screen.getByText('Edit'))
|
|
163
|
+
expect(onRowClick).toHaveBeenCalledTimes(1)
|
|
164
|
+
} finally {
|
|
165
|
+
queryClient.clear()
|
|
166
|
+
}
|
|
167
|
+
})
|
|
131
168
|
})
|
|
@@ -7,6 +7,33 @@ import { NotesSection, type NotesDataAdapter } from '../detail/NotesSection'
|
|
|
7
7
|
import { dismissRecordConflict, getRecordConflictForTest } from '../conflicts'
|
|
8
8
|
import { OPTIMISTIC_LOCK_CONFLICT_CODE } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
9
9
|
|
|
10
|
+
const createDataAdapter = (): NotesDataAdapter => ({
|
|
11
|
+
list: jest.fn(async () => [
|
|
12
|
+
{
|
|
13
|
+
id: 'note-1',
|
|
14
|
+
body: 'Existing note',
|
|
15
|
+
createdAt: '2026-04-10T08:00:00.000Z',
|
|
16
|
+
authorName: 'Ada Lovelace',
|
|
17
|
+
},
|
|
18
|
+
]),
|
|
19
|
+
create: jest.fn(async () => ({ id: 'note-2' })),
|
|
20
|
+
update: jest.fn(async () => undefined),
|
|
21
|
+
delete: jest.fn(async () => undefined),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const baseProps = (dataAdapter: NotesDataAdapter) => ({
|
|
25
|
+
entityId: 'person-1',
|
|
26
|
+
emptyLabel: '—',
|
|
27
|
+
viewerUserId: 'user-1',
|
|
28
|
+
viewerName: 'Ada Lovelace',
|
|
29
|
+
addActionLabel: 'Add note',
|
|
30
|
+
emptyState: {
|
|
31
|
+
title: 'No notes yet',
|
|
32
|
+
actionLabel: 'Add note',
|
|
33
|
+
},
|
|
34
|
+
dataAdapter,
|
|
35
|
+
})
|
|
36
|
+
|
|
10
37
|
describe('NotesSection', () => {
|
|
11
38
|
beforeEach(() => {
|
|
12
39
|
dismissRecordConflict()
|
|
@@ -171,6 +198,68 @@ describe('NotesSection', () => {
|
|
|
171
198
|
})
|
|
172
199
|
})
|
|
173
200
|
|
|
201
|
+
it('renders the markdown toggle and appearance controls by default', async () => {
|
|
202
|
+
const { container } = renderWithProviders(<NotesSection {...baseProps(createDataAdapter())} />)
|
|
203
|
+
|
|
204
|
+
await screen.findByText('Existing note')
|
|
205
|
+
expect(container.querySelectorAll('svg.lucide-palette').length).toBe(1)
|
|
206
|
+
|
|
207
|
+
fireEvent.click(screen.getByRole('button', { name: 'Add note' }))
|
|
208
|
+
|
|
209
|
+
await waitFor(() => {
|
|
210
|
+
expect(container.querySelector('button[aria-pressed]')).not.toBeNull()
|
|
211
|
+
})
|
|
212
|
+
expect(screen.getByRole('button', { name: 'Customize appearance' })).toBeTruthy()
|
|
213
|
+
expect(screen.queryByTestId('markdown-field')).toBeNull()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('keeps the rich editor active and hides the toggle when forceMarkdown is set', async () => {
|
|
217
|
+
const { container } = renderWithProviders(
|
|
218
|
+
<NotesSection {...baseProps(createDataAdapter())} forceMarkdown />,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
await screen.findByText('Existing note')
|
|
222
|
+
fireEvent.click(screen.getByRole('button', { name: 'Add note' }))
|
|
223
|
+
|
|
224
|
+
await waitFor(() => {
|
|
225
|
+
expect(screen.getByTestId('markdown-field')).toBeTruthy()
|
|
226
|
+
})
|
|
227
|
+
expect(container.querySelector('button[aria-pressed]')).toBeNull()
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('hides the toggle but honors the seeded preference when hideMarkdownToggle is set', async () => {
|
|
231
|
+
const { container } = renderWithProviders(
|
|
232
|
+
<NotesSection
|
|
233
|
+
{...baseProps(createDataAdapter())}
|
|
234
|
+
hideMarkdownToggle
|
|
235
|
+
readMarkdownPreference={() => true}
|
|
236
|
+
/>,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
await screen.findByText('Existing note')
|
|
240
|
+
fireEvent.click(screen.getByRole('button', { name: 'Add note' }))
|
|
241
|
+
|
|
242
|
+
await waitFor(() => {
|
|
243
|
+
expect(screen.getByTestId('markdown-field')).toBeTruthy()
|
|
244
|
+
})
|
|
245
|
+
expect(container.querySelector('button[aria-pressed]')).toBeNull()
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('removes every appearance affordance when disableAppearance is set', async () => {
|
|
249
|
+
const { container } = renderWithProviders(
|
|
250
|
+
<NotesSection {...baseProps(createDataAdapter())} disableAppearance />,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
await screen.findByText('Existing note')
|
|
254
|
+
fireEvent.click(screen.getByRole('button', { name: 'Add note' }))
|
|
255
|
+
|
|
256
|
+
await waitFor(() => {
|
|
257
|
+
expect(container.querySelector('button[aria-pressed]')).not.toBeNull()
|
|
258
|
+
})
|
|
259
|
+
expect(screen.queryByRole('button', { name: 'Customize appearance' })).toBeNull()
|
|
260
|
+
expect(container.querySelectorAll('svg.lucide-palette').length).toBe(0)
|
|
261
|
+
})
|
|
262
|
+
|
|
174
263
|
it('surfaces the unified conflict bar when a write fails with a 409', async () => {
|
|
175
264
|
const conflict = {
|
|
176
265
|
status: 409,
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import {
|
|
6
|
+
registerComponent,
|
|
7
|
+
registerComponentOverrides,
|
|
8
|
+
} from '@open-mercato/shared/modules/widgets/component-registry'
|
|
9
|
+
import type { ComponentOverride } from '@open-mercato/shared/modules/widgets/component-registry'
|
|
10
|
+
import { ComponentOverrideProvider, useOverrideUserFeatures } from '../injection/ComponentOverrideProvider'
|
|
11
|
+
import { useRegisteredComponent } from '../injection/useRegisteredComponent'
|
|
12
|
+
|
|
13
|
+
jest.mock('../utils/apiCall', () => ({
|
|
14
|
+
apiCall: jest.fn(async () => ({ ok: true, result: { granted: ['acme.override'] } })),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
type FieldProps = { label?: string }
|
|
18
|
+
|
|
19
|
+
const mounts = { count: 0 }
|
|
20
|
+
|
|
21
|
+
function StatefulField({ label }: FieldProps) {
|
|
22
|
+
React.useEffect(() => {
|
|
23
|
+
mounts.count += 1
|
|
24
|
+
}, [])
|
|
25
|
+
return (
|
|
26
|
+
<label>
|
|
27
|
+
{label ?? 'field'}
|
|
28
|
+
<input data-testid="field" defaultValue="" />
|
|
29
|
+
</label>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function FeaturesProbe() {
|
|
34
|
+
const features = useOverrideUserFeatures()
|
|
35
|
+
return <span data-testid="features">{features.join(',')}</span>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function typeIntoField(value: string) {
|
|
39
|
+
fireEvent.change(screen.getByTestId('field'), { target: { value } })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('useRegisteredComponent identity', () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
mounts.count = 0
|
|
45
|
+
registerComponentOverrides([])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
registerComponentOverrides([])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('keeps the subtree mounted when override user features resolve after first paint', async () => {
|
|
53
|
+
const componentId = 'test.identity.features'
|
|
54
|
+
registerComponent({ id: componentId, component: StatefulField, metadata: { module: 'test' } })
|
|
55
|
+
|
|
56
|
+
function Consumer() {
|
|
57
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId)
|
|
58
|
+
return <Resolved />
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The override targets a different component, so the resolution result for
|
|
62
|
+
// `componentId` is identical before and after the feature grant arrives.
|
|
63
|
+
const overrides: ComponentOverride[] = [
|
|
64
|
+
{
|
|
65
|
+
target: { componentId: 'test.identity.other' },
|
|
66
|
+
priority: 10,
|
|
67
|
+
features: ['acme.override'],
|
|
68
|
+
metadata: { module: 'test' },
|
|
69
|
+
propsTransform: (props: unknown) => props,
|
|
70
|
+
} as ComponentOverride,
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
render(
|
|
74
|
+
<ComponentOverrideProvider overrides={overrides}>
|
|
75
|
+
<FeaturesProbe />
|
|
76
|
+
<Consumer />
|
|
77
|
+
</ComponentOverrideProvider>,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
typeIntoField('admin@acme.com')
|
|
81
|
+
expect(mounts.count).toBe(1)
|
|
82
|
+
|
|
83
|
+
await waitFor(() => expect(screen.getByTestId('features')).toHaveTextContent('acme.override'))
|
|
84
|
+
|
|
85
|
+
expect(mounts.count).toBe(1)
|
|
86
|
+
expect(screen.getByTestId('field')).toHaveValue('admin@acme.com')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('keeps the subtree mounted when overrides are registered late', async () => {
|
|
90
|
+
const componentId = 'test.identity.late'
|
|
91
|
+
registerComponent({ id: componentId, component: StatefulField, metadata: { module: 'test' } })
|
|
92
|
+
|
|
93
|
+
function Consumer() {
|
|
94
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId)
|
|
95
|
+
return <Resolved />
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const lateOverrides: ComponentOverride[] = [
|
|
99
|
+
{
|
|
100
|
+
target: { componentId: 'test.identity.late.other' },
|
|
101
|
+
priority: 10,
|
|
102
|
+
metadata: { module: 'test' },
|
|
103
|
+
propsTransform: (props: unknown) => props,
|
|
104
|
+
} as ComponentOverride,
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
const { rerender } = render(
|
|
108
|
+
<ComponentOverrideProvider overrides={[]}>
|
|
109
|
+
<Consumer />
|
|
110
|
+
</ComponentOverrideProvider>,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
typeIntoField('typed before the registry settled')
|
|
114
|
+
expect(mounts.count).toBe(1)
|
|
115
|
+
|
|
116
|
+
await act(async () => {
|
|
117
|
+
rerender(
|
|
118
|
+
<ComponentOverrideProvider overrides={lateOverrides}>
|
|
119
|
+
<Consumer />
|
|
120
|
+
</ComponentOverrideProvider>,
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
expect(mounts.count).toBe(1)
|
|
125
|
+
expect(screen.getByTestId('field')).toHaveValue('typed before the registry settled')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('keeps a wrapper-wrapped subtree mounted across override re-registrations', async () => {
|
|
129
|
+
const componentId = 'test.identity.wrapped'
|
|
130
|
+
registerComponent({ id: componentId, component: StatefulField, metadata: { module: 'test' } })
|
|
131
|
+
|
|
132
|
+
const wrapper = (Original: React.ComponentType<FieldProps>) => {
|
|
133
|
+
const Wrapped = (props: FieldProps) => (
|
|
134
|
+
<div data-testid="wrapped">
|
|
135
|
+
<Original {...props} />
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
Wrapped.displayName = 'Wrapped'
|
|
139
|
+
return Wrapped
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const buildOverrides = (): ComponentOverride[] => [
|
|
143
|
+
{ target: { componentId }, priority: 10, metadata: { module: 'test' }, wrapper } as unknown as ComponentOverride,
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
function Consumer() {
|
|
147
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId)
|
|
148
|
+
return <Resolved />
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const { rerender } = render(
|
|
152
|
+
<ComponentOverrideProvider overrides={buildOverrides()}>
|
|
153
|
+
<Consumer />
|
|
154
|
+
</ComponentOverrideProvider>,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
expect(screen.getByTestId('wrapped')).toBeInTheDocument()
|
|
158
|
+
typeIntoField('typed inside a wrapped section')
|
|
159
|
+
expect(mounts.count).toBe(1)
|
|
160
|
+
|
|
161
|
+
// A new array with the same wrapper function — what a provider re-render looks like.
|
|
162
|
+
await act(async () => {
|
|
163
|
+
rerender(
|
|
164
|
+
<ComponentOverrideProvider overrides={buildOverrides()}>
|
|
165
|
+
<Consumer />
|
|
166
|
+
</ComponentOverrideProvider>,
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
expect(mounts.count).toBe(1)
|
|
171
|
+
expect(screen.getByTestId('field')).toHaveValue('typed inside a wrapped section')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('composes a wrapper at most once per (wrapper, wrapped component) pair', async () => {
|
|
175
|
+
// The user-visible consequence is covered by the test above; this one pins the
|
|
176
|
+
// cache's own contract, so that dropping the memoization fails loudly and the
|
|
177
|
+
// purity requirement documented on the `wrapper` override member stays executable.
|
|
178
|
+
const componentId = 'test.identity.wrapper-composition'
|
|
179
|
+
const Base = (props: FieldProps) => <StatefulField {...props} />
|
|
180
|
+
Base.displayName = 'Base'
|
|
181
|
+
registerComponent({ id: componentId, component: Base, metadata: { module: 'test' } })
|
|
182
|
+
|
|
183
|
+
let compositions = 0
|
|
184
|
+
const wrapper = (Original: React.ComponentType<FieldProps>) => {
|
|
185
|
+
compositions += 1
|
|
186
|
+
const Wrapped = (props: FieldProps) => (
|
|
187
|
+
<div data-testid="wrapped">
|
|
188
|
+
<Original {...props} />
|
|
189
|
+
</div>
|
|
190
|
+
)
|
|
191
|
+
Wrapped.displayName = 'Wrapped'
|
|
192
|
+
return Wrapped
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const buildOverrides = (): ComponentOverride[] => [
|
|
196
|
+
{ target: { componentId }, priority: 10, metadata: { module: 'test' }, wrapper } as unknown as ComponentOverride,
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
function Consumer() {
|
|
200
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId)
|
|
201
|
+
return <Resolved />
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const { rerender } = render(
|
|
205
|
+
<ComponentOverrideProvider overrides={buildOverrides()}>
|
|
206
|
+
<Consumer />
|
|
207
|
+
</ComponentOverrideProvider>,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
expect(screen.getByTestId('wrapped')).toBeInTheDocument()
|
|
211
|
+
expect(compositions).toBe(1)
|
|
212
|
+
|
|
213
|
+
for (let pass = 0; pass < 2; pass += 1) {
|
|
214
|
+
await act(async () => {
|
|
215
|
+
rerender(
|
|
216
|
+
<ComponentOverrideProvider overrides={buildOverrides()}>
|
|
217
|
+
<Consumer />
|
|
218
|
+
</ComponentOverrideProvider>,
|
|
219
|
+
)
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
expect(compositions).toBe(1)
|
|
224
|
+
expect(mounts.count).toBe(1)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('still applies a replacement that becomes active after the first render', async () => {
|
|
228
|
+
const componentId = 'test.identity.replacement'
|
|
229
|
+
const Original = () => <span data-testid="rendered">original</span>
|
|
230
|
+
const Replacement = () => <span data-testid="rendered">replacement</span>
|
|
231
|
+
registerComponent({ id: componentId, component: Original, metadata: { module: 'test' } })
|
|
232
|
+
|
|
233
|
+
function Consumer() {
|
|
234
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId)
|
|
235
|
+
return <Resolved />
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const lateOverrides: ComponentOverride[] = [
|
|
239
|
+
{
|
|
240
|
+
target: { componentId },
|
|
241
|
+
priority: 100,
|
|
242
|
+
metadata: { module: 'test' },
|
|
243
|
+
replacement: Replacement,
|
|
244
|
+
propsSchema: z.object({}).passthrough(),
|
|
245
|
+
} as unknown as ComponentOverride,
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
const { rerender } = render(
|
|
249
|
+
<ComponentOverrideProvider overrides={[]}>
|
|
250
|
+
<Consumer />
|
|
251
|
+
</ComponentOverrideProvider>,
|
|
252
|
+
)
|
|
253
|
+
expect(screen.getByTestId('rendered')).toHaveTextContent('original')
|
|
254
|
+
|
|
255
|
+
await act(async () => {
|
|
256
|
+
rerender(
|
|
257
|
+
<ComponentOverrideProvider overrides={lateOverrides}>
|
|
258
|
+
<Consumer />
|
|
259
|
+
</ComponentOverrideProvider>,
|
|
260
|
+
)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
expect(screen.getByTestId('rendered')).toHaveTextContent('replacement')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('cannot save a subtree whose host re-creates the fallback on every render', () => {
|
|
267
|
+
const componentId = 'test.identity.inline-fallback'
|
|
268
|
+
|
|
269
|
+
function Consumer({ label }: { label: string }) {
|
|
270
|
+
// A fallback declared inside the host's render body is a new function on
|
|
271
|
+
// every render, and with no component registered under this id it *is*
|
|
272
|
+
// the component being rendered. The hook keeps its own identity stable,
|
|
273
|
+
// but it cannot make the caller's fallback stable — React sees a new
|
|
274
|
+
// element type below and must remount. Hosts that care about the state
|
|
275
|
+
// under an unregistered id have to hoist the fallback out of render.
|
|
276
|
+
const Fallback = (props: FieldProps) => <StatefulField {...props} />
|
|
277
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId, Fallback)
|
|
278
|
+
return <Resolved label={label} />
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const { rerender } = render(<Consumer label="first" />)
|
|
282
|
+
typeIntoField('typed against an inline fallback')
|
|
283
|
+
expect(mounts.count).toBe(1)
|
|
284
|
+
|
|
285
|
+
rerender(<Consumer label="second" />)
|
|
286
|
+
|
|
287
|
+
expect(mounts.count).toBe(2)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('remounts when the host asks for a different component id', () => {
|
|
291
|
+
function Consumer({ componentId }: { componentId: string }) {
|
|
292
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId, StatefulField)
|
|
293
|
+
return <Resolved />
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const { rerender } = render(<Consumer componentId="test.identity.provider-a" />)
|
|
297
|
+
typeIntoField('provider a input')
|
|
298
|
+
expect(mounts.count).toBe(1)
|
|
299
|
+
|
|
300
|
+
rerender(<Consumer componentId="test.identity.provider-b" />)
|
|
301
|
+
|
|
302
|
+
expect(mounts.count).toBe(2)
|
|
303
|
+
expect(screen.getByTestId('field')).toHaveValue('')
|
|
304
|
+
})
|
|
305
|
+
})
|