@open-mercato/ui 0.6.6-develop.6229.1.ebe6706732 → 0.6.6-develop.6245.1.2be3b151f8
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/dist/backend/DataTable.js +68 -38
- package/dist/backend/DataTable.js.map +3 -3
- package/dist/backend/custom-fields/FieldDefinitionsEditor.js +336 -385
- package/dist/backend/custom-fields/FieldDefinitionsEditor.js.map +2 -2
- package/dist/primitives/icon-button.js +2 -0
- package/dist/primitives/icon-button.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/DataTable.tsx +79 -39
- package/src/backend/__tests__/DataTable.perspectiveGuardedMutation.test.tsx +263 -0
- package/src/backend/__tests__/FieldDefinitionsEditor.test.tsx +17 -0
- package/src/backend/custom-fields/FieldDefinitionsEditor.tsx +172 -181
- package/src/primitives/__tests__/icon-button.test.tsx +40 -0
- package/src/primitives/icon-button.tsx +4 -0
|
@@ -34,7 +34,7 @@ import { resolveInjectedIcon } from './injection/resolveInjectedIcon'
|
|
|
34
34
|
import { serializeExport, defaultExportFilename, type PreparedExport } from '@open-mercato/shared/lib/crud/exporters'
|
|
35
35
|
import { apiCall, withScopedApiRequestHeaders } from './utils/apiCall'
|
|
36
36
|
import { buildOptimisticLockHeader } from './utils/optimisticLock'
|
|
37
|
-
import {
|
|
37
|
+
import { useGuardedMutation } from './injection/useGuardedMutation'
|
|
38
38
|
import { raiseCrudError } from './utils/serverErrors'
|
|
39
39
|
import { PerspectiveSidebar } from './PerspectiveSidebar'
|
|
40
40
|
import { Popover, PopoverTrigger, PopoverContent } from '../primitives/popover'
|
|
@@ -1716,6 +1716,27 @@ export function DataTable<T>({
|
|
|
1716
1716
|
}
|
|
1717
1717
|
|
|
1718
1718
|
const perspectiveQueryKey: [string, string | null] = ['table-perspectives', perspectiveTableId]
|
|
1719
|
+
|
|
1720
|
+
type PerspectiveMutationContext = {
|
|
1721
|
+
formId: string
|
|
1722
|
+
resourceKind: 'perspective'
|
|
1723
|
+
retryLastMutation: () => Promise<boolean>
|
|
1724
|
+
}
|
|
1725
|
+
const perspectiveMutationContextId = `data-table-perspectives:${perspectiveTableId ?? 'unknown'}`
|
|
1726
|
+
const { runMutation: runPerspectiveMutation, retryLastMutation: retryPerspectiveMutation } =
|
|
1727
|
+
useGuardedMutation<PerspectiveMutationContext>({
|
|
1728
|
+
contextId: perspectiveMutationContextId,
|
|
1729
|
+
blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
|
|
1730
|
+
})
|
|
1731
|
+
const perspectiveMutationContext = React.useMemo<PerspectiveMutationContext>(
|
|
1732
|
+
() => ({
|
|
1733
|
+
formId: perspectiveMutationContextId,
|
|
1734
|
+
resourceKind: 'perspective',
|
|
1735
|
+
retryLastMutation: retryPerspectiveMutation,
|
|
1736
|
+
}),
|
|
1737
|
+
[perspectiveMutationContextId, retryPerspectiveMutation],
|
|
1738
|
+
)
|
|
1739
|
+
|
|
1719
1740
|
const savePerspectiveMutation = useMutation<PerspectiveSaveResponse, Error, SavePerspectivePayload>({
|
|
1720
1741
|
mutationFn: async (input) => {
|
|
1721
1742
|
if (!perspectiveTableId) throw new Error('Missing table id')
|
|
@@ -1734,26 +1755,32 @@ export function DataTable<T>({
|
|
|
1734
1755
|
const existing = input.perspectiveId
|
|
1735
1756
|
? perspectiveData?.perspectives.find((p) => p.id === input.perspectiveId) ?? null
|
|
1736
1757
|
: null
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1758
|
+
return runPerspectiveMutation({
|
|
1759
|
+
operation: async () => {
|
|
1760
|
+
const call = await withScopedApiRequestHeaders(
|
|
1761
|
+
buildOptimisticLockHeader(existing?.updatedAt ?? null),
|
|
1762
|
+
() => apiCall<PerspectiveSaveResponse>(
|
|
1763
|
+
`/api/perspectives/${encodeURIComponent(perspectiveTableId)}`,
|
|
1764
|
+
{
|
|
1765
|
+
method: 'POST',
|
|
1766
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1767
|
+
body: JSON.stringify(payload),
|
|
1768
|
+
},
|
|
1769
|
+
),
|
|
1770
|
+
)
|
|
1771
|
+
if (call.status === 404) {
|
|
1772
|
+
throw new Error(t('ui.dataTable.perspectives.error.apiUnavailable', 'Perspectives API is not available. Run `yarn generate` to regenerate module routes and restart the dev server.'))
|
|
1773
|
+
}
|
|
1774
|
+
if (!call.ok) {
|
|
1775
|
+
await raiseCrudError(call.response, t('ui.dataTable.perspectives.error.save', 'Failed to save perspective'))
|
|
1776
|
+
}
|
|
1777
|
+
const result = call.result
|
|
1778
|
+
if (!result) throw new Error(t('ui.dataTable.perspectives.error.save', 'Failed to save perspective'))
|
|
1779
|
+
return result
|
|
1780
|
+
},
|
|
1781
|
+
context: perspectiveMutationContext,
|
|
1782
|
+
mutationPayload: payload,
|
|
1783
|
+
})
|
|
1757
1784
|
},
|
|
1758
1785
|
onSuccess: (data) => {
|
|
1759
1786
|
if (perspectiveTableId) {
|
|
@@ -1763,11 +1790,12 @@ export function DataTable<T>({
|
|
|
1763
1790
|
applyPerspectiveSettings(data.perspective.settings, data.perspective.id)
|
|
1764
1791
|
}
|
|
1765
1792
|
},
|
|
1766
|
-
onError: (
|
|
1793
|
+
onError: () => {
|
|
1794
|
+
// Conflict surfacing is handled inside `runPerspectiveMutation`
|
|
1795
|
+
// (surfaceRecordConflict); only the cache refresh remains here.
|
|
1767
1796
|
if (perspectiveTableId) {
|
|
1768
1797
|
void queryClient.invalidateQueries({ queryKey: perspectiveQueryKey })
|
|
1769
1798
|
}
|
|
1770
|
-
surfaceRecordConflict(error, t)
|
|
1771
1799
|
},
|
|
1772
1800
|
})
|
|
1773
1801
|
|
|
@@ -1791,14 +1819,20 @@ export function DataTable<T>({
|
|
|
1791
1819
|
const deletePerspectiveMutation = useMutation<void, Error, { perspectiveId: string }>({
|
|
1792
1820
|
mutationFn: async ({ perspectiveId }) => {
|
|
1793
1821
|
if (!perspectiveTableId) throw new Error('Missing table id')
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1822
|
+
await runPerspectiveMutation({
|
|
1823
|
+
operation: async () => {
|
|
1824
|
+
const call = await apiCall(
|
|
1825
|
+
`/api/perspectives/${encodeURIComponent(perspectiveTableId)}/${encodeURIComponent(perspectiveId)}`,
|
|
1826
|
+
{ method: 'DELETE' },
|
|
1827
|
+
)
|
|
1828
|
+
if (call.status === 404) throw new Error(t('ui.dataTable.perspectives.error.apiUnavailable', 'Perspectives API is not available. Run `yarn generate` and restart the dev server.'))
|
|
1829
|
+
if (!call.ok) {
|
|
1830
|
+
await raiseCrudError(call.response, t('ui.dataTable.perspectives.error.delete', 'Failed to delete perspective'))
|
|
1831
|
+
}
|
|
1832
|
+
},
|
|
1833
|
+
context: perspectiveMutationContext,
|
|
1834
|
+
mutationPayload: { perspectiveId },
|
|
1835
|
+
})
|
|
1802
1836
|
},
|
|
1803
1837
|
onMutate: ({ perspectiveId }) => {
|
|
1804
1838
|
setDeletingIds((prev) => prev.includes(perspectiveId) ? prev : [...prev, perspectiveId])
|
|
@@ -1827,14 +1861,20 @@ export function DataTable<T>({
|
|
|
1827
1861
|
const clearRoleMutation = useMutation<void, Error, { roleId: string }>({
|
|
1828
1862
|
mutationFn: async ({ roleId }) => {
|
|
1829
1863
|
if (!perspectiveTableId) throw new Error('Missing table id')
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1864
|
+
await runPerspectiveMutation({
|
|
1865
|
+
operation: async () => {
|
|
1866
|
+
const call = await apiCall(
|
|
1867
|
+
`/api/perspectives/${encodeURIComponent(perspectiveTableId)}/roles/${encodeURIComponent(roleId)}`,
|
|
1868
|
+
{ method: 'DELETE' },
|
|
1869
|
+
)
|
|
1870
|
+
if (call.status === 404) throw new Error(t('ui.dataTable.perspectives.error.apiUnavailable', 'Perspectives API is not available. Run `yarn generate` and restart the dev server.'))
|
|
1871
|
+
if (!call.ok) {
|
|
1872
|
+
await raiseCrudError(call.response, t('ui.dataTable.perspectives.error.clearRoles', 'Failed to clear role perspectives'))
|
|
1873
|
+
}
|
|
1874
|
+
},
|
|
1875
|
+
context: perspectiveMutationContext,
|
|
1876
|
+
mutationPayload: { roleId },
|
|
1877
|
+
})
|
|
1838
1878
|
},
|
|
1839
1879
|
onMutate: ({ roleId }) => {
|
|
1840
1880
|
setRoleClearingIds((prev) => prev.includes(roleId) ? prev : [...prev, roleId])
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { DataTable } from '../DataTable'
|
|
4
|
+
import type { ColumnDef } from '@tanstack/react-table'
|
|
5
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
6
|
+
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import { render, fireEvent, waitFor, screen } from '@testing-library/react'
|
|
8
|
+
import { OPTIMISTIC_LOCK_HEADER_NAME } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
9
|
+
import type { PerspectivesIndexResponse } from '@open-mercato/shared/modules/perspectives/types'
|
|
10
|
+
|
|
11
|
+
jest.mock('next/navigation', () => ({
|
|
12
|
+
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
16
|
+
useInjectionDataWidgets: () => ({ widgets: [], isLoading: false }),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
// The guarded-mutation hook is the contract under test: every perspective write
|
|
20
|
+
// must be routed through `runMutation` so global mutation injections, record
|
|
21
|
+
// locks, and conflict handling participate consistently.
|
|
22
|
+
const mockRunMutation = jest.fn(
|
|
23
|
+
async ({ operation }: { operation: () => Promise<unknown> }) => operation(),
|
|
24
|
+
)
|
|
25
|
+
const mockRetryLastMutation = jest.fn(async () => false)
|
|
26
|
+
jest.mock('../injection/useGuardedMutation', () => ({
|
|
27
|
+
useGuardedMutation: () => ({
|
|
28
|
+
runMutation: mockRunMutation,
|
|
29
|
+
retryLastMutation: mockRetryLastMutation,
|
|
30
|
+
}),
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
// Capture the optimistic-lock headers attached to writes while keeping the call
|
|
34
|
+
// flow real: `apiCall` is recorded, `withScopedApiRequestHeaders` records the
|
|
35
|
+
// header bag and still runs the wrapped operation.
|
|
36
|
+
const mockScopedHeaderCalls: Array<Record<string, string>> = []
|
|
37
|
+
const mockApiCall = jest.fn(async (input: unknown, init?: { method?: string }) => {
|
|
38
|
+
const url = String(input)
|
|
39
|
+
const method = (init?.method ?? 'GET').toUpperCase()
|
|
40
|
+
const ok = (result: unknown) => ({
|
|
41
|
+
ok: true,
|
|
42
|
+
status: 200,
|
|
43
|
+
result,
|
|
44
|
+
response: { ok: true, status: 200 } as Response,
|
|
45
|
+
cacheStatus: null as const,
|
|
46
|
+
})
|
|
47
|
+
if (url.includes('/api/perspectives/') && method === 'POST') {
|
|
48
|
+
return ok({
|
|
49
|
+
perspective: {
|
|
50
|
+
id: 'persp-1',
|
|
51
|
+
name: 'My view',
|
|
52
|
+
tableId: 'test-table',
|
|
53
|
+
settings: {},
|
|
54
|
+
isDefault: false,
|
|
55
|
+
createdAt: 'now',
|
|
56
|
+
updatedAt: '2026-06-19T00:00:00.000Z',
|
|
57
|
+
},
|
|
58
|
+
rolePerspectives: [],
|
|
59
|
+
clearedRoleIds: [],
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
return ok(undefined)
|
|
63
|
+
})
|
|
64
|
+
jest.mock('../utils/apiCall', () => ({
|
|
65
|
+
apiCall: (...args: unknown[]) => mockApiCall(...(args as [unknown, { method?: string }?])),
|
|
66
|
+
withScopedApiRequestHeaders: async (
|
|
67
|
+
headers: Record<string, string>,
|
|
68
|
+
run: () => Promise<unknown>,
|
|
69
|
+
) => {
|
|
70
|
+
mockScopedHeaderCalls.push(headers)
|
|
71
|
+
return run()
|
|
72
|
+
},
|
|
73
|
+
}))
|
|
74
|
+
|
|
75
|
+
type SidebarProps = {
|
|
76
|
+
onSave: (input: {
|
|
77
|
+
name: string
|
|
78
|
+
isDefault: boolean
|
|
79
|
+
applyToRoles: string[]
|
|
80
|
+
setRoleDefault: boolean
|
|
81
|
+
perspectiveId?: string | null
|
|
82
|
+
settings?: unknown
|
|
83
|
+
}) => void | Promise<void>
|
|
84
|
+
onDeletePerspective: (perspectiveId: string) => void | Promise<void>
|
|
85
|
+
onClearRole: (roleId: string) => void | Promise<void>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Replace the heavy drawer UI with a stub exposing the three write handlers as
|
|
89
|
+
// buttons, so the test drives the mutations without rendering the full sidebar.
|
|
90
|
+
jest.mock('../PerspectiveSidebar', () => ({
|
|
91
|
+
PerspectiveSidebar: (props: SidebarProps) => (
|
|
92
|
+
<div>
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
data-testid="save-perspective"
|
|
96
|
+
onClick={() => {
|
|
97
|
+
void props.onSave({
|
|
98
|
+
name: 'My view',
|
|
99
|
+
isDefault: false,
|
|
100
|
+
applyToRoles: [],
|
|
101
|
+
setRoleDefault: false,
|
|
102
|
+
perspectiveId: 'persp-1',
|
|
103
|
+
settings: {
|
|
104
|
+
columnOrder: [],
|
|
105
|
+
columnVisibility: {},
|
|
106
|
+
filters: {},
|
|
107
|
+
sorting: [],
|
|
108
|
+
pageSize: 20,
|
|
109
|
+
searchValue: '',
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
save
|
|
115
|
+
</button>
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
data-testid="delete-perspective"
|
|
119
|
+
onClick={() => {
|
|
120
|
+
void props.onDeletePerspective('persp-1')
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
delete
|
|
124
|
+
</button>
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
data-testid="clear-role"
|
|
128
|
+
onClick={() => {
|
|
129
|
+
void props.onClearRole('role-1')
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
clear
|
|
133
|
+
</button>
|
|
134
|
+
</div>
|
|
135
|
+
),
|
|
136
|
+
}))
|
|
137
|
+
|
|
138
|
+
type Row = { id: string; name: string }
|
|
139
|
+
|
|
140
|
+
const INDEX_RESPONSE: PerspectivesIndexResponse = {
|
|
141
|
+
tableId: 'test-table',
|
|
142
|
+
perspectives: [
|
|
143
|
+
{
|
|
144
|
+
id: 'persp-1',
|
|
145
|
+
name: 'My view',
|
|
146
|
+
tableId: 'test-table',
|
|
147
|
+
settings: {},
|
|
148
|
+
isDefault: false,
|
|
149
|
+
createdAt: 'now',
|
|
150
|
+
updatedAt: '2026-06-19T00:00:00.000Z',
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
defaultPerspectiveId: null,
|
|
154
|
+
rolePerspectives: [],
|
|
155
|
+
roles: [{ id: 'role-1', name: 'Role 1', hasPerspective: false, hasDefault: false }],
|
|
156
|
+
canApplyToRoles: true,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function renderTable() {
|
|
160
|
+
const columns: ColumnDef<Row>[] = [{ accessorKey: 'name', header: 'Name' }]
|
|
161
|
+
const queryClient = new QueryClient({
|
|
162
|
+
defaultOptions: {
|
|
163
|
+
queries: { staleTime: Infinity, gcTime: Infinity, retry: false },
|
|
164
|
+
mutations: { retry: false },
|
|
165
|
+
},
|
|
166
|
+
})
|
|
167
|
+
// Seed both perspective queries so the component renders the (mocked) sidebar
|
|
168
|
+
// without issuing any network fetch for them.
|
|
169
|
+
queryClient.setQueryData(['feature-check', 'perspectives'], { use: true, roleDefaults: true })
|
|
170
|
+
queryClient.setQueryData(['table-perspectives', 'test-table'], INDEX_RESPONSE)
|
|
171
|
+
const utils = render(
|
|
172
|
+
<QueryClientProvider client={queryClient}>
|
|
173
|
+
<I18nProvider locale="en" dict={{}}>
|
|
174
|
+
<DataTable
|
|
175
|
+
columns={columns}
|
|
176
|
+
data={[]}
|
|
177
|
+
perspective={{ tableId: 'test-table' }}
|
|
178
|
+
/>
|
|
179
|
+
</I18nProvider>
|
|
180
|
+
</QueryClientProvider>,
|
|
181
|
+
)
|
|
182
|
+
return { ...utils, queryClient }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
describe('DataTable perspective writes go through useGuardedMutation', () => {
|
|
186
|
+
beforeEach(() => {
|
|
187
|
+
mockRunMutation.mockClear()
|
|
188
|
+
mockRetryLastMutation.mockClear()
|
|
189
|
+
mockApiCall.mockClear()
|
|
190
|
+
mockScopedHeaderCalls.length = 0
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('routes perspective save through runMutation and keeps the optimistic-lock header', async () => {
|
|
194
|
+
const { queryClient } = renderTable()
|
|
195
|
+
try {
|
|
196
|
+
fireEvent.click(screen.getByTestId('save-perspective'))
|
|
197
|
+
await waitFor(() => expect(mockRunMutation).toHaveBeenCalledTimes(1))
|
|
198
|
+
|
|
199
|
+
const call = mockRunMutation.mock.calls[0][0] as {
|
|
200
|
+
operation: () => Promise<unknown>
|
|
201
|
+
context: Record<string, unknown>
|
|
202
|
+
mutationPayload?: Record<string, unknown>
|
|
203
|
+
}
|
|
204
|
+
expect(call.context.resourceKind).toBe('perspective')
|
|
205
|
+
expect(typeof call.context.retryLastMutation).toBe('function')
|
|
206
|
+
|
|
207
|
+
await waitFor(() =>
|
|
208
|
+
expect(mockApiCall).toHaveBeenCalledWith(
|
|
209
|
+
expect.stringContaining('/api/perspectives/test-table'),
|
|
210
|
+
expect.objectContaining({ method: 'POST' }),
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
// The save still attaches the optimistic-lock header for the edited record.
|
|
214
|
+
expect(mockScopedHeaderCalls).toContainEqual({
|
|
215
|
+
[OPTIMISTIC_LOCK_HEADER_NAME]: '2026-06-19T00:00:00.000Z',
|
|
216
|
+
})
|
|
217
|
+
} finally {
|
|
218
|
+
queryClient.clear()
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('routes perspective delete through runMutation', async () => {
|
|
223
|
+
const { queryClient } = renderTable()
|
|
224
|
+
try {
|
|
225
|
+
fireEvent.click(screen.getByTestId('delete-perspective'))
|
|
226
|
+
await waitFor(() => expect(mockRunMutation).toHaveBeenCalledTimes(1))
|
|
227
|
+
|
|
228
|
+
const call = mockRunMutation.mock.calls[0][0] as { context: Record<string, unknown> }
|
|
229
|
+
expect(call.context.resourceKind).toBe('perspective')
|
|
230
|
+
expect(typeof call.context.retryLastMutation).toBe('function')
|
|
231
|
+
|
|
232
|
+
await waitFor(() =>
|
|
233
|
+
expect(mockApiCall).toHaveBeenCalledWith(
|
|
234
|
+
expect.stringContaining('/api/perspectives/test-table/persp-1'),
|
|
235
|
+
expect.objectContaining({ method: 'DELETE' }),
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
} finally {
|
|
239
|
+
queryClient.clear()
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('routes role-clear through runMutation', async () => {
|
|
244
|
+
const { queryClient } = renderTable()
|
|
245
|
+
try {
|
|
246
|
+
fireEvent.click(screen.getByTestId('clear-role'))
|
|
247
|
+
await waitFor(() => expect(mockRunMutation).toHaveBeenCalledTimes(1))
|
|
248
|
+
|
|
249
|
+
const call = mockRunMutation.mock.calls[0][0] as { context: Record<string, unknown> }
|
|
250
|
+
expect(call.context.resourceKind).toBe('perspective')
|
|
251
|
+
expect(typeof call.context.retryLastMutation).toBe('function')
|
|
252
|
+
|
|
253
|
+
await waitFor(() =>
|
|
254
|
+
expect(mockApiCall).toHaveBeenCalledWith(
|
|
255
|
+
expect.stringContaining('/api/perspectives/test-table/roles/role-1'),
|
|
256
|
+
expect.objectContaining({ method: 'DELETE' }),
|
|
257
|
+
),
|
|
258
|
+
)
|
|
259
|
+
} finally {
|
|
260
|
+
queryClient.clear()
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
})
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @jest-environment jsdom
|
|
3
3
|
*/
|
|
4
|
+
import * as fs from 'fs'
|
|
5
|
+
import * as path from 'path'
|
|
4
6
|
import { fireEvent, screen } from '@testing-library/react'
|
|
5
7
|
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
6
8
|
import { FieldDefinitionsEditor, type FieldDefinition } from '../custom-fields/FieldDefinitionsEditor'
|
|
@@ -19,7 +21,22 @@ if (typeof window !== 'undefined') {
|
|
|
19
21
|
}
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
const repoRoot = path.resolve(__dirname, '../../../../..')
|
|
25
|
+
const readEditorSource = () =>
|
|
26
|
+
fs.readFileSync(path.join(repoRoot, 'packages/ui/src/backend/custom-fields/FieldDefinitionsEditor.tsx'), 'utf8')
|
|
27
|
+
|
|
22
28
|
describe('FieldDefinitionsEditor', () => {
|
|
29
|
+
it('uses DS primitives and semantic status tokens for editor controls', () => {
|
|
30
|
+
const source = readEditorSource()
|
|
31
|
+
|
|
32
|
+
expect(source).not.toMatch(/<input\b/)
|
|
33
|
+
expect(source).not.toMatch(/<select\b/)
|
|
34
|
+
expect(source).not.toMatch(/<textarea\b/)
|
|
35
|
+
expect(source).not.toMatch(/\b(?:border-red|text-red|bg-amber|text-amber|text-blue)-\d{2,3}\b/)
|
|
36
|
+
expect(source).toMatch(/from ['"]\.\.\/\.\.\/primitives\/input['"]/)
|
|
37
|
+
expect(source).toMatch(/from ['"]\.\.\/\.\.\/primitives\/checkbox-field['"]/)
|
|
38
|
+
})
|
|
39
|
+
|
|
23
40
|
it('assigns a field to a fieldset without triggering a render-time parent update warning', () => {
|
|
24
41
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
25
42
|
const handleDefinitionChange = jest.fn()
|