@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.
Files changed (133) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/AGENTS.md +2 -1
  3. package/dist/backend/AppShell.js +6 -0
  4. package/dist/backend/AppShell.js.map +2 -2
  5. package/dist/backend/CrudForm.js +212 -85
  6. package/dist/backend/CrudForm.js.map +3 -3
  7. package/dist/backend/DataTable.js +77 -26
  8. package/dist/backend/DataTable.js.map +3 -3
  9. package/dist/backend/FlashMessages.js +21 -2
  10. package/dist/backend/FlashMessages.js.map +2 -2
  11. package/dist/backend/JsonBuilder.js +64 -41
  12. package/dist/backend/JsonBuilder.js.map +2 -2
  13. package/dist/backend/PerspectiveSidebar.js +8 -1
  14. package/dist/backend/PerspectiveSidebar.js.map +2 -2
  15. package/dist/backend/RowActions.js +1 -1
  16. package/dist/backend/RowActions.js.map +2 -2
  17. package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
  18. package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
  19. package/dist/backend/conflicts/index.js +32 -0
  20. package/dist/backend/conflicts/index.js.map +7 -0
  21. package/dist/backend/conflicts/store.js +50 -0
  22. package/dist/backend/conflicts/store.js.map +7 -0
  23. package/dist/backend/detail/ActivitiesSection.js +1 -0
  24. package/dist/backend/detail/ActivitiesSection.js.map +2 -2
  25. package/dist/backend/detail/InlineEditors.js +2 -51
  26. package/dist/backend/detail/InlineEditors.js.map +3 -3
  27. package/dist/backend/detail/RecordNotFoundState.js +10 -5
  28. package/dist/backend/detail/RecordNotFoundState.js.map +2 -2
  29. package/dist/backend/filters/AdvancedFilterPanel.js +14 -0
  30. package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
  31. package/dist/backend/filters/FilteredEmptyResults.js +12 -4
  32. package/dist/backend/filters/FilteredEmptyResults.js.map +2 -2
  33. package/dist/backend/filters/ListEmptyState.js +44 -0
  34. package/dist/backend/filters/ListEmptyState.js.map +7 -0
  35. package/dist/backend/filters/SearchEmptyResults.js +28 -0
  36. package/dist/backend/filters/SearchEmptyResults.js.map +7 -0
  37. package/dist/backend/icons/lucideRegistry.generated.js +8 -0
  38. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  39. package/dist/backend/injection/useGuardedMutation.js +8 -1
  40. package/dist/backend/injection/useGuardedMutation.js.map +2 -2
  41. package/dist/backend/inputs/MarkdownField.js +20 -0
  42. package/dist/backend/inputs/MarkdownField.js.map +7 -0
  43. package/dist/backend/inputs/MdxEditorImpl.js +98 -0
  44. package/dist/backend/inputs/MdxEditorImpl.js.map +7 -0
  45. package/dist/backend/inputs/SwitchableMarkdownInput.js +6 -38
  46. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
  47. package/dist/backend/markdown/markdownToPlainText.js +29 -0
  48. package/dist/backend/markdown/markdownToPlainText.js.map +2 -2
  49. package/dist/backend/messages/EmailThreadsPanel.js +212 -0
  50. package/dist/backend/messages/EmailThreadsPanel.js.map +7 -0
  51. package/dist/backend/messages/index.js +4 -0
  52. package/dist/backend/messages/index.js.map +2 -2
  53. package/dist/backend/messages/mergeOptimisticEmailThreads.js +55 -0
  54. package/dist/backend/messages/mergeOptimisticEmailThreads.js.map +7 -0
  55. package/dist/backend/notifications/NotificationDispatcher.js +1 -0
  56. package/dist/backend/notifications/NotificationDispatcher.js.map +2 -2
  57. package/dist/backend/notifications/useNotificationsPoll.js +7 -0
  58. package/dist/backend/notifications/useNotificationsPoll.js.map +2 -2
  59. package/dist/backend/notifications/useNotificationsSse.js +8 -0
  60. package/dist/backend/notifications/useNotificationsSse.js.map +2 -2
  61. package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
  62. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  63. package/dist/backend/utils/optimisticLock.js +34 -0
  64. package/dist/backend/utils/optimisticLock.js.map +7 -0
  65. package/dist/portal/hooks/useTenantContext.js +1 -1
  66. package/dist/portal/hooks/useTenantContext.js.map +2 -2
  67. package/dist/primitives/alert.js.map +1 -1
  68. package/dist/primitives/checkbox.js +2 -1
  69. package/dist/primitives/checkbox.js.map +2 -2
  70. package/dist/types/css.d.js +1 -0
  71. package/dist/types/css.d.js.map +7 -0
  72. package/package.json +17 -13
  73. package/src/backend/AppShell.tsx +10 -0
  74. package/src/backend/CrudForm.tsx +320 -97
  75. package/src/backend/DataTable.tsx +113 -34
  76. package/src/backend/FlashMessages.tsx +31 -2
  77. package/src/backend/JsonBuilder.tsx +79 -26
  78. package/src/backend/PerspectiveSidebar.tsx +8 -1
  79. package/src/backend/RowActions.tsx +1 -1
  80. package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +0 -1
  81. package/src/backend/__tests__/CrudForm.datetime.test.tsx +0 -1
  82. package/src/backend/__tests__/CrudForm.defaults.test.tsx +0 -1
  83. package/src/backend/__tests__/CrudForm.dotPath.test.tsx +311 -0
  84. package/src/backend/__tests__/CrudForm.doubleSubmit.test.tsx +0 -1
  85. package/src/backend/__tests__/CrudForm.navigation.test.tsx +120 -1
  86. package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +287 -0
  87. package/src/backend/__tests__/CrudForm.render.test.tsx +74 -2
  88. package/src/backend/__tests__/CrudForm.sortable.test.tsx +0 -1
  89. package/src/backend/__tests__/CrudForm.transformData.test.tsx +0 -1
  90. package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +51 -3
  91. package/src/backend/__tests__/CrudForm.validation.test.tsx +0 -1
  92. package/src/backend/__tests__/CustomDataSection.test.tsx +0 -1
  93. package/src/backend/__tests__/FlashMessages.test.tsx +47 -0
  94. package/src/backend/__tests__/JsonBuilder.test.tsx +135 -0
  95. package/src/backend/__tests__/lazy-heavy-libraries.test.ts +16 -3
  96. package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
  97. package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
  98. package/src/backend/conflicts/__tests__/store.test.ts +78 -0
  99. package/src/backend/conflicts/index.ts +49 -0
  100. package/src/backend/conflicts/store.ts +94 -0
  101. package/src/backend/detail/ActivitiesSection.tsx +1 -0
  102. package/src/backend/detail/InlineEditors.tsx +14 -76
  103. package/src/backend/detail/RecordNotFoundState.tsx +13 -6
  104. package/src/backend/detail/__tests__/RecordNotFoundState.test.tsx +80 -0
  105. package/src/backend/filters/AdvancedFilterPanel.tsx +27 -0
  106. package/src/backend/filters/FilteredEmptyResults.tsx +23 -10
  107. package/src/backend/filters/ListEmptyState.tsx +69 -0
  108. package/src/backend/filters/SearchEmptyResults.tsx +42 -0
  109. package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +98 -0
  110. package/src/backend/filters/__tests__/ListEmptyState.test.tsx +53 -0
  111. package/src/backend/icons/lucideRegistry.generated.tsx +8 -0
  112. package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
  113. package/src/backend/injection/useGuardedMutation.ts +16 -1
  114. package/src/backend/inputs/MarkdownField.tsx +34 -0
  115. package/src/backend/inputs/MdxEditorImpl.tsx +113 -0
  116. package/src/backend/inputs/SwitchableMarkdownInput.tsx +5 -54
  117. package/src/backend/markdown/__tests__/markdownToPlainText.test.ts +13 -0
  118. package/src/backend/markdown/markdownToPlainText.ts +41 -0
  119. package/src/backend/messages/EmailThreadsPanel.tsx +351 -0
  120. package/src/backend/messages/__tests__/mergeOptimisticEmailThreads.test.ts +126 -0
  121. package/src/backend/messages/index.ts +12 -0
  122. package/src/backend/messages/mergeOptimisticEmailThreads.ts +84 -0
  123. package/src/backend/notifications/NotificationDispatcher.ts +2 -0
  124. package/src/backend/notifications/useNotificationsPoll.ts +5 -0
  125. package/src/backend/notifications/useNotificationsSse.ts +6 -0
  126. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
  127. package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
  128. package/src/backend/utils/optimisticLock.ts +70 -0
  129. package/src/portal/hooks/useTenantContext.ts +7 -2
  130. package/src/primitives/__tests__/checkbox.test.tsx +28 -0
  131. package/src/primitives/alert.tsx +1 -1
  132. package/src/primitives/checkbox.tsx +2 -1
  133. 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 remark-gfm', () => {
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(/^[^\n/]*import\s+[^'\n]+from\s+['"]remark-gfm['"]/m)
30
- expect(source).toMatch(/import\(['"]remark-gfm['"]\)/)
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
+ }
@@ -395,6 +395,7 @@ function ActivityForm({
395
395
  allowInlineCreate
396
396
  appearanceLabels={appearanceLabels}
397
397
  selectClassName="w-full"
398
+ sortOptions="none"
398
399
  manageHref={manageHref}
399
400
  />
400
401
  ),
@@ -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
- {isMarkdownEnabled ? (
678
- <div
679
- ref={markdownEditorRef}
680
- className={cn(
681
- 'w-full rounded-md border border-muted-foreground/30 bg-background p-2',
682
- saving ? 'pointer-events-none opacity-75' : null,
683
- )}
684
- >
685
- <div data-color-mode="light" className="w-full">
686
- <MarkdownEditorComponent
687
- value={draft}
688
- height={220}
689
- onChange={(nextValue) => {
690
- if (error) setError(null)
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}>