@open-mercato/ui 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4
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 +2 -2
- package/AGENTS.md +2 -1
- package/dist/backend/AppShell.js +6 -0
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +212 -85
- package/dist/backend/CrudForm.js.map +3 -3
- package/dist/backend/DataTable.js +77 -26
- package/dist/backend/DataTable.js.map +3 -3
- package/dist/backend/FlashMessages.js +21 -2
- package/dist/backend/FlashMessages.js.map +2 -2
- package/dist/backend/JsonBuilder.js +64 -41
- package/dist/backend/JsonBuilder.js.map +2 -2
- package/dist/backend/PerspectiveSidebar.js +8 -1
- package/dist/backend/PerspectiveSidebar.js.map +2 -2
- package/dist/backend/RowActions.js +1 -1
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
- package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
- package/dist/backend/conflicts/index.js +32 -0
- package/dist/backend/conflicts/index.js.map +7 -0
- package/dist/backend/conflicts/store.js +50 -0
- package/dist/backend/conflicts/store.js.map +7 -0
- package/dist/backend/detail/ActivitiesSection.js +1 -0
- package/dist/backend/detail/ActivitiesSection.js.map +2 -2
- package/dist/backend/detail/InlineEditors.js +2 -51
- package/dist/backend/detail/InlineEditors.js.map +3 -3
- package/dist/backend/detail/RecordNotFoundState.js +10 -5
- package/dist/backend/detail/RecordNotFoundState.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterPanel.js +14 -0
- package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
- package/dist/backend/filters/FilteredEmptyResults.js +12 -4
- package/dist/backend/filters/FilteredEmptyResults.js.map +2 -2
- package/dist/backend/filters/ListEmptyState.js +44 -0
- package/dist/backend/filters/ListEmptyState.js.map +7 -0
- package/dist/backend/filters/SearchEmptyResults.js +28 -0
- package/dist/backend/filters/SearchEmptyResults.js.map +7 -0
- package/dist/backend/icons/lucideRegistry.generated.js +8 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/useGuardedMutation.js +8 -1
- package/dist/backend/injection/useGuardedMutation.js.map +2 -2
- package/dist/backend/inputs/MarkdownField.js +20 -0
- package/dist/backend/inputs/MarkdownField.js.map +7 -0
- package/dist/backend/inputs/MdxEditorImpl.js +98 -0
- package/dist/backend/inputs/MdxEditorImpl.js.map +7 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js +6 -38
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/backend/markdown/markdownToPlainText.js +29 -0
- package/dist/backend/markdown/markdownToPlainText.js.map +2 -2
- package/dist/backend/messages/EmailThreadsPanel.js +212 -0
- package/dist/backend/messages/EmailThreadsPanel.js.map +7 -0
- package/dist/backend/messages/index.js +4 -0
- package/dist/backend/messages/index.js.map +2 -2
- package/dist/backend/messages/mergeOptimisticEmailThreads.js +55 -0
- package/dist/backend/messages/mergeOptimisticEmailThreads.js.map +7 -0
- package/dist/backend/notifications/NotificationDispatcher.js +1 -0
- package/dist/backend/notifications/NotificationDispatcher.js.map +2 -2
- package/dist/backend/notifications/useNotificationsPoll.js +7 -0
- package/dist/backend/notifications/useNotificationsPoll.js.map +2 -2
- package/dist/backend/notifications/useNotificationsSse.js +8 -0
- package/dist/backend/notifications/useNotificationsSse.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/utils/optimisticLock.js +34 -0
- package/dist/backend/utils/optimisticLock.js.map +7 -0
- package/dist/portal/hooks/useTenantContext.js +1 -1
- package/dist/portal/hooks/useTenantContext.js.map +2 -2
- package/dist/primitives/alert.js.map +1 -1
- package/dist/primitives/checkbox.js +2 -1
- package/dist/primitives/checkbox.js.map +2 -2
- package/dist/types/css.d.js +1 -0
- package/dist/types/css.d.js.map +7 -0
- package/package.json +17 -13
- package/src/backend/AppShell.tsx +10 -0
- package/src/backend/CrudForm.tsx +320 -97
- package/src/backend/DataTable.tsx +113 -34
- package/src/backend/FlashMessages.tsx +31 -2
- package/src/backend/JsonBuilder.tsx +79 -26
- package/src/backend/PerspectiveSidebar.tsx +8 -1
- package/src/backend/RowActions.tsx +1 -1
- package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.datetime.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.defaults.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.dotPath.test.tsx +311 -0
- package/src/backend/__tests__/CrudForm.doubleSubmit.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.navigation.test.tsx +120 -1
- package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +287 -0
- package/src/backend/__tests__/CrudForm.render.test.tsx +74 -2
- package/src/backend/__tests__/CrudForm.sortable.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.transformData.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +51 -3
- package/src/backend/__tests__/CrudForm.validation.test.tsx +0 -1
- package/src/backend/__tests__/CustomDataSection.test.tsx +0 -1
- package/src/backend/__tests__/FlashMessages.test.tsx +47 -0
- package/src/backend/__tests__/JsonBuilder.test.tsx +135 -0
- package/src/backend/__tests__/lazy-heavy-libraries.test.ts +16 -3
- package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
- package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
- package/src/backend/conflicts/__tests__/store.test.ts +78 -0
- package/src/backend/conflicts/index.ts +49 -0
- package/src/backend/conflicts/store.ts +94 -0
- package/src/backend/detail/ActivitiesSection.tsx +1 -0
- package/src/backend/detail/InlineEditors.tsx +14 -76
- package/src/backend/detail/RecordNotFoundState.tsx +13 -6
- package/src/backend/detail/__tests__/RecordNotFoundState.test.tsx +80 -0
- package/src/backend/filters/AdvancedFilterPanel.tsx +27 -0
- package/src/backend/filters/FilteredEmptyResults.tsx +23 -10
- package/src/backend/filters/ListEmptyState.tsx +69 -0
- package/src/backend/filters/SearchEmptyResults.tsx +42 -0
- package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +98 -0
- package/src/backend/filters/__tests__/ListEmptyState.test.tsx +53 -0
- package/src/backend/icons/lucideRegistry.generated.tsx +8 -0
- package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
- package/src/backend/injection/useGuardedMutation.ts +16 -1
- package/src/backend/inputs/MarkdownField.tsx +34 -0
- package/src/backend/inputs/MdxEditorImpl.tsx +113 -0
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +5 -54
- package/src/backend/markdown/__tests__/markdownToPlainText.test.ts +13 -0
- package/src/backend/markdown/markdownToPlainText.ts +41 -0
- package/src/backend/messages/EmailThreadsPanel.tsx +351 -0
- package/src/backend/messages/__tests__/mergeOptimisticEmailThreads.test.ts +126 -0
- package/src/backend/messages/index.ts +12 -0
- package/src/backend/messages/mergeOptimisticEmailThreads.ts +84 -0
- package/src/backend/notifications/NotificationDispatcher.ts +2 -0
- package/src/backend/notifications/useNotificationsPoll.ts +5 -0
- package/src/backend/notifications/useNotificationsSse.ts +6 -0
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
- package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
- package/src/backend/utils/optimisticLock.ts +70 -0
- package/src/portal/hooks/useTenantContext.ts +7 -2
- package/src/primitives/__tests__/checkbox.test.tsx +28 -0
- package/src/primitives/alert.tsx +1 -1
- package/src/primitives/checkbox.tsx +2 -1
- package/src/types/css.d.ts +5 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import * as React from 'react'
|
|
5
|
+
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
|
6
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
7
|
+
import { JsonBuilder } from '../JsonBuilder'
|
|
8
|
+
|
|
9
|
+
// Radix Select uses pointer capture / scrollIntoView APIs that jsdom doesn't implement.
|
|
10
|
+
// The confirm dialog uses the native <dialog> showModal/close APIs. Polyfill both so the
|
|
11
|
+
// component can be exercised end to end (issue #2817).
|
|
12
|
+
if (typeof window !== 'undefined') {
|
|
13
|
+
if (!Element.prototype.hasPointerCapture) {
|
|
14
|
+
Element.prototype.hasPointerCapture = () => false
|
|
15
|
+
}
|
|
16
|
+
if (!Element.prototype.releasePointerCapture) {
|
|
17
|
+
Element.prototype.releasePointerCapture = () => undefined
|
|
18
|
+
}
|
|
19
|
+
if (!Element.prototype.scrollIntoView) {
|
|
20
|
+
Element.prototype.scrollIntoView = () => undefined
|
|
21
|
+
}
|
|
22
|
+
Object.defineProperty(HTMLDialogElement.prototype, 'showModal', {
|
|
23
|
+
configurable: true,
|
|
24
|
+
value(this: HTMLDialogElement) {
|
|
25
|
+
this.setAttribute('open', '')
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
Object.defineProperty(HTMLDialogElement.prototype, 'close', {
|
|
29
|
+
configurable: true,
|
|
30
|
+
value(this: HTMLDialogElement) {
|
|
31
|
+
this.removeAttribute('open')
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ControlledHarness({ initial = {} as any }: { initial?: any }) {
|
|
37
|
+
const [value, setValue] = React.useState<any>(initial)
|
|
38
|
+
// Inline onChange recreated each render — mirrors how WebhookCustomHeadersField
|
|
39
|
+
// wires the builder. A stale effect dependency on this identity is what made
|
|
40
|
+
// Raw JSON uneditable before the fix.
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
<JsonBuilder value={value} onChange={(next) => setValue(next)} />
|
|
44
|
+
<div data-testid="value">{JSON.stringify(value)}</div>
|
|
45
|
+
</>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('JsonBuilder', () => {
|
|
50
|
+
it('lets the user type JSON in Raw mode without clobbering the text', () => {
|
|
51
|
+
renderWithProviders(<ControlledHarness />)
|
|
52
|
+
|
|
53
|
+
const textarea = screen.getByPlaceholderText('{"key": "value"}') as HTMLTextAreaElement
|
|
54
|
+
|
|
55
|
+
act(() => {
|
|
56
|
+
fireEvent.change(textarea, { target: { value: '{"a":"1"}' } })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// The exact typed text is preserved (not reformatted/reset by a value echo).
|
|
60
|
+
expect(textarea.value).toBe('{"a":"1"}')
|
|
61
|
+
// ...and the parsed object propagated to the parent.
|
|
62
|
+
expect(screen.getByTestId('value').textContent).toBe('{"a":"1"}')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('mirrors an external value into the Raw textarea until the user edits it', () => {
|
|
66
|
+
function ExternalHarness() {
|
|
67
|
+
const [value, setValue] = React.useState<any>({})
|
|
68
|
+
return (
|
|
69
|
+
<>
|
|
70
|
+
<button type="button" onClick={() => setValue({ injected: 'hello' })}>
|
|
71
|
+
load
|
|
72
|
+
</button>
|
|
73
|
+
<JsonBuilder value={value} onChange={setValue} />
|
|
74
|
+
</>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
renderWithProviders(<ExternalHarness />)
|
|
79
|
+
const textarea = screen.getByPlaceholderText('{"key": "value"}') as HTMLTextAreaElement
|
|
80
|
+
|
|
81
|
+
act(() => {
|
|
82
|
+
fireEvent.click(screen.getByRole('button', { name: 'load' }))
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
expect(textarea.value).toContain('injected')
|
|
86
|
+
expect(textarea.value).toContain('hello')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
async function openBuilderAndChangeRootType() {
|
|
90
|
+
renderWithProviders(<ControlledHarness initial={{ foo: 'bar' }} />)
|
|
91
|
+
|
|
92
|
+
act(() => {
|
|
93
|
+
fireEvent.click(screen.getByRole('button', { name: 'Builder' }))
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const rootTrigger = screen
|
|
97
|
+
.getAllByRole('combobox')
|
|
98
|
+
.find((element) => element.textContent?.trim() === 'Object')
|
|
99
|
+
expect(rootTrigger).toBeDefined()
|
|
100
|
+
|
|
101
|
+
fireEvent.pointerDown(rootTrigger!, { button: 0, ctrlKey: false })
|
|
102
|
+
fireEvent.click(rootTrigger!)
|
|
103
|
+
|
|
104
|
+
const stringOption = screen.getByRole('option', { name: 'String' })
|
|
105
|
+
await act(async () => {
|
|
106
|
+
fireEvent.pointerDown(stringOption)
|
|
107
|
+
fireEvent.click(stringOption)
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
it('asks for confirmation before a destructive root type change and preserves data on cancel', async () => {
|
|
112
|
+
await openBuilderAndChangeRootType()
|
|
113
|
+
|
|
114
|
+
const cancelButton = await screen.findByRole('button', { name: 'Cancel' })
|
|
115
|
+
await act(async () => {
|
|
116
|
+
fireEvent.click(cancelButton)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// Cancelling leaves the configured properties intact.
|
|
120
|
+
expect(screen.getByTestId('value').textContent).toBe('{"foo":"bar"}')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('discards data only after the destructive root type change is confirmed', async () => {
|
|
124
|
+
await openBuilderAndChangeRootType()
|
|
125
|
+
|
|
126
|
+
const confirmButton = await screen.findByRole('button', { name: 'Discard and change' })
|
|
127
|
+
await act(async () => {
|
|
128
|
+
fireEvent.click(confirmButton)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
await waitFor(() => {
|
|
132
|
+
expect(screen.getByTestId('value').textContent).toBe('""')
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -24,10 +24,23 @@ describe('heavy libraries are lazy-loaded', () => {
|
|
|
24
24
|
expect(source).not.toMatch(/next\/dynamic/)
|
|
25
25
|
})
|
|
26
26
|
|
|
27
|
-
it('CrudForm does not statically import
|
|
27
|
+
it('CrudForm does not statically import the heavy markdown editor', () => {
|
|
28
28
|
const source = read('packages/ui/src/backend/CrudForm.tsx')
|
|
29
|
-
expect(source).not.toMatch(
|
|
30
|
-
expect(source).toMatch(/
|
|
29
|
+
expect(source).not.toMatch(/from\s+['"]@mdxeditor\/editor['"]/)
|
|
30
|
+
expect(source).toMatch(/from\s+['"]\.\/inputs\/MarkdownField['"]/)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('MarkdownField lazy-loads MdxEditorImpl and does not statically import it', () => {
|
|
34
|
+
const source = read('packages/ui/src/backend/inputs/MarkdownField.tsx')
|
|
35
|
+
expect(source).not.toMatch(/^[^\n/]*import\s+[^'\n]+from\s+['"]\.\/MdxEditorImpl['"]/m)
|
|
36
|
+
expect(source).toMatch(/import\(['"]\.\/MdxEditorImpl['"]\)/)
|
|
37
|
+
expect(source).toMatch(/next\/dynamic/)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('MdxEditorImpl owns the @mdxeditor/editor runtime import and CSS', () => {
|
|
41
|
+
const source = read('packages/ui/src/backend/inputs/MdxEditorImpl.tsx')
|
|
42
|
+
expect(source).toMatch(/from\s+['"]@mdxeditor\/editor['"]/)
|
|
43
|
+
expect(source).toMatch(/['"]@mdxeditor\/editor\/style\.css['"]/)
|
|
31
44
|
})
|
|
32
45
|
|
|
33
46
|
it('resources resource-types list page does not import markdown libraries', () => {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { RefreshCw, X } from 'lucide-react'
|
|
4
|
+
import { Button } from '../../primitives/button'
|
|
5
|
+
import { IconButton } from '../../primitives/icon-button'
|
|
6
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import { dismissRecordConflict, useRecordConflict } from './store'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Persistent, error-styled bar that surfaces an optimistic-lock conflict
|
|
11
|
+
* ("this record was modified by someone else"). Rendered once in `AppShell`
|
|
12
|
+
* next to the undo `LastOperationBanner`, it is the unified, clearly-visible
|
|
13
|
+
* surface for the conflict across every form (CrudForm, useGuardedMutation,
|
|
14
|
+
* and custom pages all push through `conflicts/store`). Unlike the undo bar it
|
|
15
|
+
* does NOT auto-dismiss — the user resolves it by refreshing or dismissing.
|
|
16
|
+
*/
|
|
17
|
+
export function RecordConflictBanner() {
|
|
18
|
+
const t = useT()
|
|
19
|
+
const conflict = useRecordConflict()
|
|
20
|
+
|
|
21
|
+
const handleRefresh = React.useCallback(() => {
|
|
22
|
+
const onRefresh = conflict?.onRefresh ?? null
|
|
23
|
+
dismissRecordConflict()
|
|
24
|
+
if (onRefresh) {
|
|
25
|
+
onRefresh()
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
if (typeof window !== 'undefined' && typeof window.location?.reload === 'function') {
|
|
29
|
+
try {
|
|
30
|
+
const isJSDOM = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
|
|
31
|
+
? navigator.userAgent.toLowerCase().includes('jsdom')
|
|
32
|
+
: false
|
|
33
|
+
if (!isJSDOM) window.location.reload()
|
|
34
|
+
} catch {
|
|
35
|
+
// noop in non-browser / jsdom environments
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}, [conflict])
|
|
39
|
+
|
|
40
|
+
if (!conflict) return null
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
role="alert"
|
|
45
|
+
data-testid="record-conflict-banner"
|
|
46
|
+
className="mb-4 flex items-center justify-between gap-3 rounded-md border border-status-error-border bg-status-error-bg pl-3 pr-2 py-2 text-sm text-status-error-text shadow-xs sm:pr-3"
|
|
47
|
+
>
|
|
48
|
+
<div className="min-w-0">
|
|
49
|
+
<span className="font-medium text-status-error-text">
|
|
50
|
+
{conflict.title ?? t('ui.forms.conflict.title', 'Record changed')}
|
|
51
|
+
</span>
|
|
52
|
+
<span className="ml-2 text-status-error-text">{conflict.message}</span>
|
|
53
|
+
</div>
|
|
54
|
+
<div className="flex items-center gap-1">
|
|
55
|
+
<Button
|
|
56
|
+
type="button"
|
|
57
|
+
variant="outline"
|
|
58
|
+
size="sm"
|
|
59
|
+
onClick={handleRefresh}
|
|
60
|
+
className="border-status-error-border bg-status-error-bg text-status-error-text hover:bg-status-error-border hover:text-status-error-text px-2.5 sm:px-3"
|
|
61
|
+
>
|
|
62
|
+
<RefreshCw className="mr-1 size-4" aria-hidden="true" />
|
|
63
|
+
{t('ui.forms.conflict.refresh', 'Refresh')}
|
|
64
|
+
</Button>
|
|
65
|
+
<IconButton
|
|
66
|
+
type="button"
|
|
67
|
+
variant="ghost"
|
|
68
|
+
size="sm"
|
|
69
|
+
aria-label={t('ui.forms.conflict.dismiss', 'Dismiss')}
|
|
70
|
+
onClick={() => dismissRecordConflict()}
|
|
71
|
+
>
|
|
72
|
+
<X className="size-4" aria-hidden="true" />
|
|
73
|
+
</IconButton>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { act, render, screen } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
6
|
+
useT: () => (key: string, fallback?: string) => fallback ?? key,
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
import { RecordConflictBanner } from '../RecordConflictBanner'
|
|
10
|
+
import { dismissRecordConflict, showRecordConflict } from '../store'
|
|
11
|
+
|
|
12
|
+
describe('RecordConflictBanner', () => {
|
|
13
|
+
beforeEach(() => dismissRecordConflict())
|
|
14
|
+
afterEach(() => dismissRecordConflict())
|
|
15
|
+
|
|
16
|
+
it('renders nothing when there is no active conflict', () => {
|
|
17
|
+
const { container } = render(<RecordConflictBanner />)
|
|
18
|
+
expect(container.querySelector('[data-testid="record-conflict-banner"]')).toBeNull()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('renders the localized message + Refresh/Dismiss as an alert when a conflict is active', () => {
|
|
22
|
+
render(<RecordConflictBanner />)
|
|
23
|
+
act(() => {
|
|
24
|
+
showRecordConflict({
|
|
25
|
+
message: 'This record was modified by someone else. Refresh and try again.',
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
const banner = screen.getByTestId('record-conflict-banner')
|
|
29
|
+
expect(banner.getAttribute('role')).toBe('alert')
|
|
30
|
+
expect(banner.textContent).toContain('Record changed')
|
|
31
|
+
expect(banner.textContent).toContain('This record was modified by someone else. Refresh and try again.')
|
|
32
|
+
expect(screen.getByRole('button', { name: 'Refresh' })).not.toBeNull()
|
|
33
|
+
expect(screen.getByRole('button', { name: 'Dismiss' })).not.toBeNull()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('invokes a custom onRefresh and clears the bar when Refresh is clicked', () => {
|
|
37
|
+
const onRefresh = jest.fn()
|
|
38
|
+
render(<RecordConflictBanner />)
|
|
39
|
+
act(() => {
|
|
40
|
+
showRecordConflict({ message: 'Changed', onRefresh })
|
|
41
|
+
})
|
|
42
|
+
act(() => {
|
|
43
|
+
screen.getByRole('button', { name: 'Refresh' }).click()
|
|
44
|
+
})
|
|
45
|
+
expect(onRefresh).toHaveBeenCalledTimes(1)
|
|
46
|
+
expect(screen.queryByTestId('record-conflict-banner')).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('Dismiss clears the bar without refreshing', () => {
|
|
50
|
+
const onRefresh = jest.fn()
|
|
51
|
+
render(<RecordConflictBanner />)
|
|
52
|
+
act(() => {
|
|
53
|
+
showRecordConflict({ message: 'Changed', onRefresh })
|
|
54
|
+
})
|
|
55
|
+
act(() => {
|
|
56
|
+
screen.getByRole('button', { name: 'Dismiss' }).click()
|
|
57
|
+
})
|
|
58
|
+
expect(onRefresh).not.toHaveBeenCalled()
|
|
59
|
+
expect(screen.queryByTestId('record-conflict-banner')).toBeNull()
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
3
|
+
import { OPTIMISTIC_LOCK_CONFLICT_CODE } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
4
|
+
import {
|
|
5
|
+
dismissRecordConflict,
|
|
6
|
+
getRecordConflictForTest,
|
|
7
|
+
showRecordConflict,
|
|
8
|
+
surfaceRecordConflict,
|
|
9
|
+
} from '..'
|
|
10
|
+
|
|
11
|
+
const t = (key: string, fallback?: string) => fallback ?? key
|
|
12
|
+
|
|
13
|
+
describe('record conflict store + surfaceRecordConflict', () => {
|
|
14
|
+
beforeEach(() => dismissRecordConflict())
|
|
15
|
+
|
|
16
|
+
it('showRecordConflict sets the active entry; dismiss clears it', () => {
|
|
17
|
+
expect(getRecordConflictForTest()).toBeNull()
|
|
18
|
+
showRecordConflict({ message: 'Changed', currentUpdatedAt: '2026-05-25T08:00:01.000Z' })
|
|
19
|
+
const entry = getRecordConflictForTest()
|
|
20
|
+
expect(entry?.message).toBe('Changed')
|
|
21
|
+
expect(entry?.currentUpdatedAt).toBe('2026-05-25T08:00:01.000Z')
|
|
22
|
+
dismissRecordConflict()
|
|
23
|
+
expect(getRecordConflictForTest()).toBeNull()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('surfaceRecordConflict pushes the localized message for an optimistic-lock 409 and returns true', () => {
|
|
27
|
+
const conflict = new CrudHttpError(409, {
|
|
28
|
+
error: 'record_modified',
|
|
29
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
30
|
+
currentUpdatedAt: '2026-05-25T08:00:01.000Z',
|
|
31
|
+
expectedUpdatedAt: '2026-05-25T08:00:00.000Z',
|
|
32
|
+
})
|
|
33
|
+
const handled = surfaceRecordConflict(conflict, t)
|
|
34
|
+
expect(handled).toBe(true)
|
|
35
|
+
expect(getRecordConflictForTest()?.message).toBe(
|
|
36
|
+
'This record was modified by someone else. Refresh and try again.',
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('surfaceRecordConflict recognizes a plain apiCall-result envelope ({ status, body }) — used by custom admin pages whose apiCall does not throw on 409', () => {
|
|
41
|
+
const apiCallEnvelope = {
|
|
42
|
+
status: 409,
|
|
43
|
+
body: {
|
|
44
|
+
error: 'record_modified',
|
|
45
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
46
|
+
currentUpdatedAt: '2026-05-25T08:00:01.000Z',
|
|
47
|
+
expectedUpdatedAt: '2026-05-25T08:00:00.000Z',
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
expect(surfaceRecordConflict(apiCallEnvelope, t)).toBe(true)
|
|
51
|
+
expect(getRecordConflictForTest()?.currentUpdatedAt).toBe('2026-05-25T08:00:01.000Z')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('surfaceRecordConflict ignores a non-409 apiCall envelope (e.g. 500 with no conflict body)', () => {
|
|
55
|
+
expect(surfaceRecordConflict({ status: 500, body: { error: 'server_error' } }, t)).toBe(false)
|
|
56
|
+
expect(getRecordConflictForTest()).toBeNull()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('surfaceRecordConflict is a no-op (returns false) for non-conflict errors', () => {
|
|
60
|
+
expect(surfaceRecordConflict(new CrudHttpError(422, { error: 'validation_failed' }), t)).toBe(false)
|
|
61
|
+
expect(surfaceRecordConflict(new Error('boom'), t)).toBe(false)
|
|
62
|
+
expect(getRecordConflictForTest()).toBeNull()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('forwards a custom onRefresh callback', () => {
|
|
66
|
+
const onRefresh = jest.fn()
|
|
67
|
+
const conflict = new CrudHttpError(409, {
|
|
68
|
+
error: 'record_modified',
|
|
69
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
70
|
+
currentUpdatedAt: '2026-05-25T08:00:01.000Z',
|
|
71
|
+
expectedUpdatedAt: '2026-05-25T08:00:00.000Z',
|
|
72
|
+
})
|
|
73
|
+
surfaceRecordConflict(conflict, t, { onRefresh, title: 'Order changed' })
|
|
74
|
+
const entry = getRecordConflictForTest()
|
|
75
|
+
expect(entry?.title).toBe('Order changed')
|
|
76
|
+
expect(entry?.onRefresh).toBe(onRefresh)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { extractOptimisticLockConflict } from '../utils/optimisticLock'
|
|
2
|
+
import { showRecordConflict } from './store'
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
showRecordConflict,
|
|
6
|
+
dismissRecordConflict,
|
|
7
|
+
useRecordConflict,
|
|
8
|
+
getRecordConflictForTest,
|
|
9
|
+
type RecordConflictEntry,
|
|
10
|
+
type ShowRecordConflictInput,
|
|
11
|
+
} from './store'
|
|
12
|
+
export { RecordConflictBanner } from './RecordConflictBanner'
|
|
13
|
+
|
|
14
|
+
type Translate = (key: string, fallback?: string) => string
|
|
15
|
+
|
|
16
|
+
export type SurfaceRecordConflictOptions = {
|
|
17
|
+
/** Custom refresh handler. Omit to let the banner reload the page. */
|
|
18
|
+
onRefresh?: (() => void) | null
|
|
19
|
+
/** Localized title override; the banner falls back to a generic title. */
|
|
20
|
+
title?: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* If `error` is an OSS optimistic-lock conflict (HTTP 409 with
|
|
25
|
+
* `code: 'optimistic_lock_conflict'`), push the localized "record modified"
|
|
26
|
+
* message onto the shared conflict bar and return `true`. Otherwise return
|
|
27
|
+
* `false` so callers can fall back to their normal error handling.
|
|
28
|
+
*
|
|
29
|
+
* This is the single entry point every form uses, so the conflict surfaces the
|
|
30
|
+
* same persistent, error-styled bar everywhere.
|
|
31
|
+
*/
|
|
32
|
+
export function surfaceRecordConflict(
|
|
33
|
+
error: unknown,
|
|
34
|
+
t: Translate,
|
|
35
|
+
options: SurfaceRecordConflictOptions = {},
|
|
36
|
+
): boolean {
|
|
37
|
+
const conflict = extractOptimisticLockConflict(error)
|
|
38
|
+
if (!conflict) return false
|
|
39
|
+
showRecordConflict({
|
|
40
|
+
message: t(
|
|
41
|
+
'ui.forms.flash.recordModified',
|
|
42
|
+
'This record was modified by someone else. Refresh and try again.',
|
|
43
|
+
),
|
|
44
|
+
title: options.title ?? null,
|
|
45
|
+
currentUpdatedAt: conflict.currentUpdatedAt,
|
|
46
|
+
onRefresh: options.onRefresh ?? null,
|
|
47
|
+
})
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-memory store for the unified record-conflict bar.
|
|
6
|
+
*
|
|
7
|
+
* When a mutation fails with an OSS optimistic-lock conflict (HTTP 409 with
|
|
8
|
+
* `code: 'optimistic_lock_conflict'`), forms surface it through this store so a
|
|
9
|
+
* persistent, error-styled bar — the `RecordConflictBanner` rendered once in
|
|
10
|
+
* `AppShell` — shows the "this record was modified" message app-wide, instead
|
|
11
|
+
* of a transient toast that scrolls away. Mirrors the undo `LastOperationBanner`
|
|
12
|
+
* pattern but for the error case, and is shared by `CrudForm`,
|
|
13
|
+
* `useGuardedMutation`, and any custom page, so every form behaves the same.
|
|
14
|
+
*
|
|
15
|
+
* Unlike the operations store this is NOT persisted to localStorage — it holds
|
|
16
|
+
* a live `onRefresh` callback and a conflict only matters for the current page
|
|
17
|
+
* session.
|
|
18
|
+
*/
|
|
19
|
+
export type RecordConflictEntry = {
|
|
20
|
+
id: string
|
|
21
|
+
/** Localized, user-facing message (e.g. ui.forms.flash.recordModified). */
|
|
22
|
+
message: string
|
|
23
|
+
/** Optional localized title; the banner falls back to a generic title. */
|
|
24
|
+
title?: string | null
|
|
25
|
+
/** The server's current `updated_at`, for diagnostics / future diffing. */
|
|
26
|
+
currentUpdatedAt?: string | null
|
|
27
|
+
/**
|
|
28
|
+
* Invoked when the user clicks "Refresh". When omitted the banner reloads
|
|
29
|
+
* the page (the safe default — re-fetches the latest server state).
|
|
30
|
+
*/
|
|
31
|
+
onRefresh?: (() => void) | null
|
|
32
|
+
createdAt: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let internalEntry: RecordConflictEntry | null = null
|
|
36
|
+
let sequence = 0
|
|
37
|
+
|
|
38
|
+
const emitter = new EventTarget()
|
|
39
|
+
|
|
40
|
+
function emit(): void {
|
|
41
|
+
emitter.dispatchEvent(new Event('change'))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function subscribe(listener: () => void): () => void {
|
|
45
|
+
const wrapped = () => listener()
|
|
46
|
+
emitter.addEventListener('change', wrapped)
|
|
47
|
+
return () => emitter.removeEventListener('change', wrapped)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function nextId(): string {
|
|
51
|
+
sequence += 1
|
|
52
|
+
return `conflict:${sequence}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type ShowRecordConflictInput = {
|
|
56
|
+
message: string
|
|
57
|
+
title?: string | null
|
|
58
|
+
currentUpdatedAt?: string | null
|
|
59
|
+
onRefresh?: (() => void) | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Show (or replace) the active record-conflict bar. */
|
|
63
|
+
export function showRecordConflict(input: ShowRecordConflictInput): void {
|
|
64
|
+
internalEntry = {
|
|
65
|
+
id: nextId(),
|
|
66
|
+
message: input.message,
|
|
67
|
+
title: input.title ?? null,
|
|
68
|
+
currentUpdatedAt: input.currentUpdatedAt ?? null,
|
|
69
|
+
onRefresh: input.onRefresh ?? null,
|
|
70
|
+
createdAt: typeof Date !== 'undefined' ? Date.now() : 0,
|
|
71
|
+
}
|
|
72
|
+
emit()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Clear the active record-conflict bar. */
|
|
76
|
+
export function dismissRecordConflict(): void {
|
|
77
|
+
if (internalEntry === null) return
|
|
78
|
+
internalEntry = null
|
|
79
|
+
emit()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Read the active conflict (null when none). Subscribes to changes. */
|
|
83
|
+
export function useRecordConflict(): RecordConflictEntry | null {
|
|
84
|
+
return React.useSyncExternalStore(
|
|
85
|
+
subscribe,
|
|
86
|
+
() => internalEntry,
|
|
87
|
+
() => null,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Test-only accessor for the current entry without React. */
|
|
92
|
+
export function getRecordConflictForTest(): RecordConflictEntry | null {
|
|
93
|
+
return internalEntry
|
|
94
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import dynamic from 'next/dynamic'
|
|
5
4
|
import { FileCode, Loader2, Mail, Pencil, Phone, X } from 'lucide-react'
|
|
6
5
|
import type { PluggableList } from 'unified'
|
|
7
6
|
import { PhoneNumberField } from '@open-mercato/ui/backend/inputs/PhoneNumberField'
|
|
@@ -444,40 +443,10 @@ export type InlineMultilineEditorProps = {
|
|
|
444
443
|
renderDisplay?: (params: { value: string | null | undefined; emptyLabel: string }) => React.ReactNode
|
|
445
444
|
}
|
|
446
445
|
|
|
447
|
-
type UiMarkdownEditorProps = {
|
|
448
|
-
value?: string
|
|
449
|
-
height?: number
|
|
450
|
-
onChange?: (value?: string) => void
|
|
451
|
-
previewOptions?: { remarkPlugins?: unknown[] }
|
|
452
|
-
}
|
|
453
|
-
|
|
454
446
|
const isTestEnv =
|
|
455
447
|
typeof process !== 'undefined' &&
|
|
456
448
|
(process.env.NODE_ENV === 'test' || typeof process.env.JEST_WORKER_ID !== 'undefined')
|
|
457
449
|
|
|
458
|
-
function MarkdownEditorFallback() {
|
|
459
|
-
const t = useT()
|
|
460
|
-
return (
|
|
461
|
-
<LoadingMessage label={t('ui.detail.inline.editorLoading', 'Loading editor…')} className="min-h-[200px] justify-center" />
|
|
462
|
-
)
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
const MarkdownEditorTestStub: React.ComponentType<UiMarkdownEditorProps> = ({ value, onChange }) => (
|
|
466
|
-
<Textarea
|
|
467
|
-
data-testid="markdown-editor"
|
|
468
|
-
rows={8}
|
|
469
|
-
value={value ?? ''}
|
|
470
|
-
onChange={(event) => onChange?.(event.target.value)}
|
|
471
|
-
/>
|
|
472
|
-
)
|
|
473
|
-
|
|
474
|
-
const MarkdownEditorComponent: React.ComponentType<UiMarkdownEditorProps> = isTestEnv
|
|
475
|
-
? MarkdownEditorTestStub
|
|
476
|
-
: (dynamic(() => import('@uiw/react-md-editor'), {
|
|
477
|
-
ssr: false,
|
|
478
|
-
loading: () => <MarkdownEditorFallback />,
|
|
479
|
-
}) as unknown as React.ComponentType<UiMarkdownEditorProps>)
|
|
480
|
-
|
|
481
450
|
let markdownPluginsPromise: Promise<PluggableList> | null = null
|
|
482
451
|
|
|
483
452
|
async function loadMarkdownPlugins(): Promise<PluggableList> {
|
|
@@ -510,7 +479,6 @@ export function InlineMultilineEditor({
|
|
|
510
479
|
const [saving, setSaving] = React.useState(false)
|
|
511
480
|
const [isMarkdownEnabled, setIsMarkdownEnabled] = React.useState(true)
|
|
512
481
|
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
|
|
513
|
-
const markdownEditorRef = React.useRef<HTMLDivElement | null>(null)
|
|
514
482
|
const [markdownPlugins, setMarkdownPlugins] = React.useState<PluggableList>([])
|
|
515
483
|
const fallbackError = React.useMemo(
|
|
516
484
|
() => t('ui.detail.inline.error', 'Failed to save value.'),
|
|
@@ -540,14 +508,6 @@ export function InlineMultilineEditor({
|
|
|
540
508
|
|
|
541
509
|
React.useEffect(() => {
|
|
542
510
|
if (!editing) return
|
|
543
|
-
if (isMarkdownEnabled) {
|
|
544
|
-
const element = markdownEditorRef.current?.querySelector('textarea')
|
|
545
|
-
if (!element) return
|
|
546
|
-
window.requestAnimationFrame(() => {
|
|
547
|
-
element.focus()
|
|
548
|
-
})
|
|
549
|
-
return
|
|
550
|
-
}
|
|
551
511
|
const element = textareaRef.current
|
|
552
512
|
if (!element) return
|
|
553
513
|
window.requestAnimationFrame(() => {
|
|
@@ -674,42 +634,20 @@ export function InlineMultilineEditor({
|
|
|
674
634
|
}
|
|
675
635
|
}}
|
|
676
636
|
>
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
setDraft(typeof nextValue === 'string' ? nextValue : '')
|
|
692
|
-
}}
|
|
693
|
-
previewOptions={{ remarkPlugins: markdownPlugins }}
|
|
694
|
-
/>
|
|
695
|
-
</div>
|
|
696
|
-
</div>
|
|
697
|
-
) : (
|
|
698
|
-
<Textarea
|
|
699
|
-
ref={textareaRef}
|
|
700
|
-
rows={3}
|
|
701
|
-
className="w-full resize-none overflow-hidden rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
702
|
-
placeholder={placeholder}
|
|
703
|
-
value={draft}
|
|
704
|
-
onChange={(event) => {
|
|
705
|
-
if (error) setError(null)
|
|
706
|
-
setDraft(event.target.value)
|
|
707
|
-
}}
|
|
708
|
-
onInput={(event) => adjustTextareaSize(event.currentTarget)}
|
|
709
|
-
autoFocus
|
|
710
|
-
disabled={saving}
|
|
711
|
-
/>
|
|
712
|
-
)}
|
|
637
|
+
<Textarea
|
|
638
|
+
ref={textareaRef}
|
|
639
|
+
rows={isMarkdownEnabled ? 8 : 3}
|
|
640
|
+
className="w-full resize-none overflow-hidden rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
641
|
+
placeholder={placeholder}
|
|
642
|
+
value={draft}
|
|
643
|
+
onChange={(event) => {
|
|
644
|
+
if (error) setError(null)
|
|
645
|
+
setDraft(event.target.value)
|
|
646
|
+
}}
|
|
647
|
+
onInput={(event) => adjustTextareaSize(event.currentTarget)}
|
|
648
|
+
autoFocus
|
|
649
|
+
disabled={saving}
|
|
650
|
+
/>
|
|
713
651
|
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
|
714
652
|
<div className="flex items-center gap-2">
|
|
715
653
|
<Button type="submit" size="sm" disabled={saving}>
|