@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.
@@ -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 { surfaceRecordConflict } from './conflicts'
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
- const call = await withScopedApiRequestHeaders(
1738
- buildOptimisticLockHeader(existing?.updatedAt ?? null),
1739
- () => apiCall<PerspectiveSaveResponse>(
1740
- `/api/perspectives/${encodeURIComponent(perspectiveTableId)}`,
1741
- {
1742
- method: 'POST',
1743
- headers: { 'Content-Type': 'application/json' },
1744
- body: JSON.stringify(payload),
1745
- },
1746
- ),
1747
- )
1748
- if (call.status === 404) {
1749
- 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.'))
1750
- }
1751
- if (!call.ok) {
1752
- await raiseCrudError(call.response, t('ui.dataTable.perspectives.error.save', 'Failed to save perspective'))
1753
- }
1754
- const result = call.result
1755
- if (!result) throw new Error(t('ui.dataTable.perspectives.error.save', 'Failed to save perspective'))
1756
- return result
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: (error) => {
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
- const call = await apiCall(
1795
- `/api/perspectives/${encodeURIComponent(perspectiveTableId)}/${encodeURIComponent(perspectiveId)}`,
1796
- { method: 'DELETE' },
1797
- )
1798
- 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.'))
1799
- if (!call.ok) {
1800
- await raiseCrudError(call.response, t('ui.dataTable.perspectives.error.delete', 'Failed to delete perspective'))
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
- const call = await apiCall(
1831
- `/api/perspectives/${encodeURIComponent(perspectiveTableId)}/roles/${encodeURIComponent(roleId)}`,
1832
- { method: 'DELETE' },
1833
- )
1834
- 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.'))
1835
- if (!call.ok) {
1836
- await raiseCrudError(call.response, t('ui.dataTable.perspectives.error.clearRoles', 'Failed to clear role perspectives'))
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()