@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
@@ -3,7 +3,7 @@ import * as React from 'react'
3
3
  import { useRouter } from 'next/navigation'
4
4
  import { useReactTable, getCoreRowModel, getSortedRowModel, flexRender, type ColumnDef, type SortingState, type Column as TableColumn, type VisibilityState, type RowSelectionState } from '@tanstack/react-table'
5
5
  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
6
- import { RefreshCw, Loader2, SlidersHorizontal, MoreHorizontal, Circle, Filter, Columns3, ChevronUp, ChevronDown, ChevronsUpDown, Check } from 'lucide-react'
6
+ import { RefreshCw, Loader2, SlidersHorizontal, MoreHorizontal, Circle, Filter, Columns3, ChevronUp, ChevronDown, ChevronsUpDown, Check, Inbox } from 'lucide-react'
7
7
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../primitives/table'
8
8
  import { Button } from '../primitives/button'
9
9
  import { Checkbox } from '../primitives/checkbox'
@@ -22,6 +22,7 @@ import { TooltipProvider } from '../primitives/tooltip'
22
22
  import { TruncatedCell } from './TruncatedCell'
23
23
  import { FilterBar, type FilterDef, type FilterValues } from './FilterBar'
24
24
  import { FilteredEmptyResults } from './filters/FilteredEmptyResults'
25
+ import { SearchEmptyResults } from './filters/SearchEmptyResults'
25
26
  import { useCustomFieldFilterDefs } from './utils/customFieldFilters'
26
27
  import { fetchCustomFieldDefinitionsPayload, type CustomFieldsetDto } from './utils/customFieldDefs'
27
28
  import { RowActions, type RowActionItem } from './RowActions'
@@ -31,7 +32,9 @@ import { useAppEvent } from './injection/useAppEvent'
31
32
  import { useInjectionDataWidgets } from './injection/useInjectionDataWidgets'
32
33
  import { resolveInjectedIcon } from './injection/resolveInjectedIcon'
33
34
  import { serializeExport, defaultExportFilename, type PreparedExport } from '@open-mercato/shared/lib/crud/exporters'
34
- import { apiCall } from './utils/apiCall'
35
+ import { apiCall, withScopedApiRequestHeaders } from './utils/apiCall'
36
+ import { buildOptimisticLockHeader } from './utils/optimisticLock'
37
+ import { surfaceRecordConflict } from './conflicts'
35
38
  import { raiseCrudError } from './utils/serverErrors'
36
39
  import { PerspectiveSidebar } from './PerspectiveSidebar'
37
40
  import { Popover, PopoverTrigger, PopoverContent } from '../primitives/popover'
@@ -250,6 +253,15 @@ export type DataTableProps<T> = {
250
253
  injectionSpotId?: string
251
254
  injectionContext?: Record<string, unknown>
252
255
  replacementHandle?: string
256
+ /**
257
+ * Stable id used to derive the `data-table:<id>:*` widget injection spots
258
+ * (`:columns`, `:row-actions`, `:bulk-actions`, `:filters`, `:toolbar`,
259
+ * `:search-trailing`, `:header`, `:footer`) when the host does not need a
260
+ * full perspective config. Cheaper than wiring a perspective just to enable
261
+ * widget injection. Falls back to `perspective?.tableId` and `injectionSpotId`
262
+ * if not provided.
263
+ */
264
+ extensionTableId?: string
253
265
  stickyFirstColumn?: boolean
254
266
  stickyActionsColumn?: boolean
255
267
  virtualized?: boolean
@@ -985,6 +997,7 @@ export function DataTable<T>({
985
997
  injectionSpotId,
986
998
  injectionContext,
987
999
  replacementHandle,
1000
+ extensionTableId: extensionTableIdProp,
988
1001
  stickyFirstColumn = false,
989
1002
  stickyActionsColumn = false,
990
1003
  virtualized = false,
@@ -1181,14 +1194,34 @@ export function DataTable<T>({
1181
1194
 
1182
1195
  const extensionTableId = React.useMemo(() => {
1183
1196
  if (perspective?.tableId) return perspective.tableId
1197
+ if (extensionTableIdProp) return extensionTableIdProp
1184
1198
  if (injectionSpotId?.startsWith('data-table:')) return injectionSpotId.slice('data-table:'.length)
1185
1199
  return null
1186
- }, [injectionSpotId, perspective?.tableId])
1187
- const resolvedInjectionSpotId = injectionSpotId ?? (perspective?.tableId ? `data-table:${perspective.tableId}` : null)
1200
+ }, [injectionSpotId, perspective?.tableId, extensionTableIdProp])
1201
+ const resolvedInjectionSpotId =
1202
+ injectionSpotId
1203
+ ?? (perspective?.tableId ? `data-table:${perspective.tableId}` : null)
1204
+ ?? (extensionTableIdProp ? `data-table:${extensionTableIdProp}` : null)
1188
1205
  const resolvedReplacementHandle = replacementHandle ?? ComponentReplacementHandles.dataTable(extensionTableId ?? 'unknown')
1189
1206
  const baseInjectionContext = React.useMemo(
1190
- () => injectionContext ?? { tableId: perspective?.tableId ?? null, title: typeof title === 'string' ? title : undefined },
1191
- [injectionContext, perspective?.tableId, title]
1207
+ () => {
1208
+ // R2-M2 / F9 (2026-05-26): the default injection context now derives
1209
+ // `tableId` from `extensionTableId ?? perspective?.tableId` (was
1210
+ // `perspective?.tableId` only). Note `extensionTableId` itself now falls
1211
+ // back to the `data-table:` suffix of `injectionSpotId` (see the memo
1212
+ // above), so a caller passing only `injectionSpotId="data-table:foo"`
1213
+ // (no `injectionContext`/`perspective`) now receives
1214
+ // `context.tableId = "foo"` instead of the previous `null`. This only
1215
+ // populates a field that was null before, so toolbar/header/footer/
1216
+ // search-trailing widgets that read `tableId` get a value while widgets
1217
+ // that ignore it are unaffected. Explicit `injectionContext` from the
1218
+ // caller still wins as-is — preserves the existing public contract.
1219
+ if (injectionContext) return injectionContext
1220
+ const resolvedTableId = extensionTableId ?? perspective?.tableId ?? null
1221
+ const baseTitle = typeof title === 'string' ? title : undefined
1222
+ return { tableId: resolvedTableId, title: baseTitle }
1223
+ },
1224
+ [injectionContext, perspective?.tableId, extensionTableId, title]
1192
1225
  )
1193
1226
  const headerInjectionSpotId = React.useMemo(
1194
1227
  () => (resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:header` : null),
@@ -1686,13 +1719,19 @@ export function DataTable<T>({
1686
1719
  // eslint-disable-next-line no-console
1687
1720
  console.debug('[DataTable] perspective payload', payload)
1688
1721
  }
1689
- const call = await apiCall<PerspectiveSaveResponse>(
1690
- `/api/perspectives/${encodeURIComponent(perspectiveTableId)}`,
1691
- {
1692
- method: 'POST',
1693
- headers: { 'Content-Type': 'application/json' },
1694
- body: JSON.stringify(payload),
1695
- },
1722
+ const existing = input.perspectiveId
1723
+ ? perspectiveData?.perspectives.find((p) => p.id === input.perspectiveId) ?? null
1724
+ : null
1725
+ const call = await withScopedApiRequestHeaders(
1726
+ buildOptimisticLockHeader(existing?.updatedAt ?? null),
1727
+ () => apiCall<PerspectiveSaveResponse>(
1728
+ `/api/perspectives/${encodeURIComponent(perspectiveTableId)}`,
1729
+ {
1730
+ method: 'POST',
1731
+ headers: { 'Content-Type': 'application/json' },
1732
+ body: JSON.stringify(payload),
1733
+ },
1734
+ ),
1696
1735
  )
1697
1736
  if (call.status === 404) {
1698
1737
  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.'))
@@ -1712,6 +1751,12 @@ export function DataTable<T>({
1712
1751
  applyPerspectiveSettings(data.perspective.settings, data.perspective.id)
1713
1752
  }
1714
1753
  },
1754
+ onError: (error) => {
1755
+ if (perspectiveTableId) {
1756
+ void queryClient.invalidateQueries({ queryKey: perspectiveQueryKey })
1757
+ }
1758
+ surfaceRecordConflict(error, t)
1759
+ },
1715
1760
  })
1716
1761
 
1717
1762
  const resolveColumnLabel = React.useCallback((column: TableColumn<T, unknown>): string => {
@@ -2508,6 +2553,22 @@ export function DataTable<T>({
2508
2553
  const tableScrollWrapperClassName = embedded ? '' : 'overflow-auto'
2509
2554
 
2510
2555
  const virtualScrollRef = React.useRef<HTMLDivElement>(null)
2556
+ // Measure the horizontal scroll viewport so the empty state can center within
2557
+ // the visible area instead of within the (often wider, overflowing) table.
2558
+ const [tableScrollEl, setTableScrollEl] = React.useState<HTMLDivElement | null>(null)
2559
+ const [emptyStateViewportWidth, setEmptyStateViewportWidth] = React.useState<number | null>(null)
2560
+ const setTableScrollWrapperRef = React.useCallback((node: HTMLDivElement | null) => {
2561
+ setTableScrollEl(node)
2562
+ virtualScrollRef.current = node
2563
+ }, [])
2564
+ React.useEffect(() => {
2565
+ if (!tableScrollEl || typeof ResizeObserver === 'undefined') return
2566
+ const update = () => setEmptyStateViewportWidth(tableScrollEl.clientWidth)
2567
+ update()
2568
+ const observer = new ResizeObserver(update)
2569
+ observer.observe(tableScrollEl)
2570
+ return () => observer.disconnect()
2571
+ }, [tableScrollEl])
2511
2572
  const allRows = table.getRowModel().rows
2512
2573
  const rowVirtualizer = virtualized
2513
2574
  ? useVirtualizer({
@@ -2624,7 +2685,7 @@ export function DataTable<T>({
2624
2685
  columnIds={headerColumnIds}
2625
2686
  onDragEnd={handleHeaderDragEnd}
2626
2687
  >
2627
- <div ref={virtualized ? virtualScrollRef : undefined} className={tableScrollWrapperClassName} style={virtualMaxHeightStyle}>
2688
+ <div ref={setTableScrollWrapperRef} className={tableScrollWrapperClassName} style={virtualMaxHeightStyle}>
2628
2689
  <Table className="min-w-[640px] md:min-w-0">
2629
2690
  <TableHeader>
2630
2691
  {table.getHeaderGroups().map((hg) => (
@@ -2827,26 +2888,44 @@ export function DataTable<T>({
2827
2888
  </>
2828
2889
  ) : (
2829
2890
  <TableRow>
2830
- <TableCell colSpan={mergedColumns.length + (rowActions || injectedRowActions.length > 0 ? 1 : 0) + (hasInjectedBulkActions ? 1 : 0)} className="py-6">
2831
- {filterAwareEmptyState?.active ? (
2832
- <FilteredEmptyResults
2833
- entityNamePlural={filterAwareEmptyState.entityNamePlural}
2834
- canRemoveLast={filterAwareEmptyState.canRemoveLast}
2835
- onClearAll={filterAwareEmptyState.onClearAll}
2836
- onRemoveLast={filterAwareEmptyState.onRemoveLast}
2837
- />
2838
- ) : emptyState && typeof emptyState !== 'string' ? (
2839
- emptyState
2840
- ) : (
2841
- <EmptyState
2842
- size="sm"
2843
- title={
2844
- typeof emptyState === 'string'
2845
- ? emptyState
2846
- : t('ui.dataTable.emptyState.default', 'No results.')
2847
- }
2848
- />
2849
- )}
2891
+ <TableCell colSpan={mergedColumns.length + (rowActions || injectedRowActions.length > 0 ? 1 : 0) + (hasInjectedBulkActions ? 1 : 0)} className="p-0">
2892
+ <div
2893
+ className={cn('sticky left-0 flex justify-center py-6', emptyStateViewportWidth ? '' : 'w-fit')}
2894
+ style={emptyStateViewportWidth ? { width: emptyStateViewportWidth } : undefined}
2895
+ >
2896
+ {filterAwareEmptyState?.active ? (
2897
+ <FilteredEmptyResults
2898
+ entityNamePlural={filterAwareEmptyState.entityNamePlural}
2899
+ canRemoveLast={filterAwareEmptyState.canRemoveLast}
2900
+ onClearAll={filterAwareEmptyState.onClearAll}
2901
+ onRemoveLast={filterAwareEmptyState.onRemoveLast}
2902
+ onClearSearch={searchValue && searchValue.trim().length > 0 && onSearchChange ? () => onSearchChange('') : undefined}
2903
+ />
2904
+ ) : searchValue && searchValue.trim().length > 0 && onSearchChange ? (
2905
+ <SearchEmptyResults
2906
+ query={searchValue.trim()}
2907
+ entityNamePlural={filterAwareEmptyState?.entityNamePlural}
2908
+ onClearSearch={() => onSearchChange('')}
2909
+ />
2910
+ ) : emptyState && typeof emptyState !== 'string' ? (
2911
+ emptyState
2912
+ ) : (
2913
+ <EmptyState
2914
+ variant="subtle"
2915
+ icon={<Inbox className="size-6" aria-hidden />}
2916
+ title={
2917
+ typeof emptyState === 'string'
2918
+ ? emptyState
2919
+ : t('ui.dataTable.emptyState.default', 'No results.')
2920
+ }
2921
+ description={
2922
+ typeof emptyState === 'string'
2923
+ ? undefined
2924
+ : t('ui.dataTable.emptyState.defaultDescription', 'Items will appear here once added.')
2925
+ }
2926
+ />
2927
+ )}
2928
+ </div>
2850
2929
  </TableCell>
2851
2930
  </TableRow>
2852
2931
  )}
@@ -11,6 +11,30 @@ const flashKindToAlertStatus: Record<FlashKind, AlertStatus> = {
11
11
  info: 'information',
12
12
  }
13
13
 
14
+ function normalizeFlashKind(value: string | null | undefined): FlashKind {
15
+ if (value && Object.prototype.hasOwnProperty.call(flashKindToAlertStatus, value)) {
16
+ return value as FlashKind
17
+ }
18
+ return 'success'
19
+ }
20
+
21
+ // A URL-supplied flash is only trustworthy when the navigation that carried it
22
+ // originated from the same origin (an in-app POST->GET redirect or client-side
23
+ // navigation). Cross-origin links — the phishing/content-spoofing vector — must
24
+ // not be allowed to render attacker-controlled copy inside an authoritative banner.
25
+ function isSameOriginFlashNavigation(): boolean {
26
+ if (typeof window === 'undefined') return false
27
+ const referrer = document.referrer
28
+ // No referrer (direct load, bookmark, or referrer stripped) is ambiguous, not a
29
+ // cross-origin redirect we can attribute to an attacker page; allow it.
30
+ if (!referrer) return true
31
+ try {
32
+ return new URL(referrer).origin === window.location.origin
33
+ } catch {
34
+ return false
35
+ }
36
+ }
37
+
14
38
  // Programmatic API to show a flash message without navigation.
15
39
  // Consumers can import { flash } and call flash('text', 'error').
16
40
  export function flash(message: string, type: FlashKind = 'info') {
@@ -125,12 +149,17 @@ function FlashMessagesInner() {
125
149
  if (typeof window === 'undefined') return
126
150
  const url = new URL(window.location.href)
127
151
  const message = url.searchParams.get('flash')
128
- const type = (url.searchParams.get('type') as FlashKind | null) || 'success'
152
+ const type = normalizeFlashKind(url.searchParams.get('type'))
129
153
  if (message) {
130
- showFlash(message, type)
154
+ // Always strip the params so a spoofed link does not linger in history,
155
+ // but only render the banner for same-origin navigations.
156
+ const trusted = isSameOriginFlashNavigation()
131
157
  url.searchParams.delete('flash')
132
158
  url.searchParams.delete('type')
133
159
  window.history.replaceState({}, '', url.toString())
160
+ if (trusted) {
161
+ showFlash(message, type)
162
+ }
134
163
  }
135
164
  }, [locationKey, showFlash])
136
165
 
@@ -13,6 +13,8 @@ import {
13
13
  SelectValue,
14
14
  } from '../primitives/select'
15
15
  import { Plus, Trash2, ChevronRight, ChevronDown, Code, LayoutList } from 'lucide-react'
16
+ import { useConfirmDialog } from './confirm-dialog'
17
+ import type { ConfirmDialogOptions } from './confirm-dialog'
16
18
 
17
19
  function cn(...classes: (string | undefined | null | false)[]) {
18
20
  return classes.filter(Boolean).join(' ')
@@ -27,12 +29,31 @@ export type JsonBuilderProps = {
27
29
 
28
30
  type JsonNodeType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null'
29
31
 
32
+ type ConfirmFn = (options?: ConfirmDialogOptions) => Promise<boolean>
33
+
30
34
  function getJsonType(value: any): JsonNodeType {
31
35
  if (value === null) return 'null'
32
36
  if (Array.isArray(value)) return 'array'
33
37
  return typeof value as JsonNodeType
34
38
  }
35
39
 
40
+ function defaultValueForType(type: JsonNodeType): any {
41
+ switch (type) {
42
+ case 'string': return ""
43
+ case 'number': return 0
44
+ case 'boolean': return false
45
+ case 'object': return {}
46
+ case 'array': return []
47
+ case 'null': return null
48
+ }
49
+ }
50
+
51
+ function toRawString(value: any): string {
52
+ if (value === null || value === undefined) return '{}'
53
+ if (typeof value === 'object') return JSON.stringify(value, null, 2)
54
+ return String(value)
55
+ }
56
+
36
57
  export function JsonBuilder({
37
58
  value,
38
59
  onChange,
@@ -40,28 +61,32 @@ export function JsonBuilder({
40
61
  error
41
62
  }: JsonBuilderProps) {
42
63
  const [mode, setMode] = React.useState<'raw' | 'builder'>('raw')
43
- const [rawString, setRawString] = React.useState(() => {
44
- if (value === null) return '{}'
45
- return typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value || '{}')
46
- })
64
+ const [rawString, setRawString] = React.useState(() => toRawString(value))
65
+ const [rawDirty, setRawDirty] = React.useState(false)
47
66
  const [parseError, setParseError] = React.useState<string | null>(null)
67
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
48
68
 
49
69
  React.useEffect(() => {
50
- if (value === null) {
51
- if (!disabled) {
52
- onChange({})
53
- }
54
- setRawString('{}')
55
- setParseError(null)
56
- return
70
+ if (value === null && !disabled) {
71
+ onChange({})
57
72
  }
58
- if (typeof value === 'object') {
73
+ }, [value, disabled, onChange])
74
+
75
+ // Mirror external value changes (e.g. async record load, builder edits) into
76
+ // the raw textarea only while the user has NOT started typing in it. Once the
77
+ // textarea is dirty it becomes the source of truth, so re-deriving it from
78
+ // `value` on every keystroke — the parent's onChange identity changes each
79
+ // render — would clobber typing and make Raw JSON uneditable (issue #2817).
80
+ React.useEffect(() => {
81
+ if (rawDirty) return
82
+ if (value !== null && typeof value === 'object') {
59
83
  setRawString(JSON.stringify(value, null, 2))
60
84
  setParseError(null)
61
85
  }
62
- }, [value, disabled, onChange])
86
+ }, [value, rawDirty])
63
87
 
64
88
  const handleRawChange = (str: string) => {
89
+ setRawDirty(true)
65
90
  setRawString(str)
66
91
  try {
67
92
  if (str.trim() === '') {
@@ -78,6 +103,15 @@ export function JsonBuilder({
78
103
  }
79
104
  }
80
105
 
106
+ const switchToRaw = () => {
107
+ // Re-sync the textarea from the current value and let it mirror external
108
+ // changes again until the user edits it.
109
+ setRawDirty(false)
110
+ setRawString(toRawString(value))
111
+ setParseError(null)
112
+ setMode('raw')
113
+ }
114
+
81
115
  const switchToBuilder = () => {
82
116
  try {
83
117
  if (typeof value === 'string') {
@@ -97,7 +131,7 @@ export function JsonBuilder({
97
131
  variant="ghost"
98
132
  size="sm"
99
133
  className={cn(mode === 'raw' && "bg-muted text-foreground")}
100
- onClick={() => setMode('raw')}
134
+ onClick={switchToRaw}
101
135
  >
102
136
  <Code className="w-4 h-4" />
103
137
  Raw JSON
@@ -140,6 +174,7 @@ export function JsonBuilder({
140
174
  data={value}
141
175
  onChange={onChange}
142
176
  readOnly={disabled}
177
+ confirm={confirm}
143
178
  isRoot
144
179
  />
145
180
  ) : (
@@ -151,6 +186,7 @@ export function JsonBuilder({
151
186
  )}
152
187
 
153
188
  {error && <div className="text-xs text-red-600">{error}</div>}
189
+ {ConfirmDialogElement}
154
190
  </div>
155
191
  )
156
192
  }
@@ -162,24 +198,39 @@ interface JsonNodeProps {
162
198
  readOnly?: boolean
163
199
  label?: string
164
200
  isRoot?: boolean
201
+ confirm?: ConfirmFn
165
202
  }
166
203
 
167
- function JsonNode({ data, onChange, onDelete, readOnly, label, isRoot }: JsonNodeProps) {
204
+ function JsonNode({ data, onChange, onDelete, readOnly, label, isRoot, confirm }: JsonNodeProps) {
168
205
  const type = getJsonType(data)
169
206
  const isContainer = type === 'object' || type === 'array'
170
207
  const [collapsed, setCollapsed] = React.useState(false)
171
208
 
172
- const handleTypeChange = (newType: JsonNodeType) => {
173
- let newVal: any = ''
174
- switch (newType) {
175
- case 'string': newVal = ""; break;
176
- case 'number': newVal = 0; break;
177
- case 'boolean': newVal = false; break;
178
- case 'object': newVal = {}; break;
179
- case 'array': newVal = []; break;
180
- case 'null': newVal = null; break;
209
+ const handleTypeChange = async (newType: JsonNodeType) => {
210
+ if (newType === type) return
211
+ // Switching away from a non-empty container discards its contents. Ask
212
+ // for confirmation first so configured properties aren't lost silently
213
+ // (issue #2817). The Select is controlled by the derived type, so when
214
+ // the user cancels we simply leave the data untouched and the control
215
+ // snaps back to its previous value.
216
+ const itemCount = type === 'object'
217
+ ? Object.keys(data).length
218
+ : type === 'array'
219
+ ? (data as any[]).length
220
+ : 0
221
+ if (itemCount > 0 && confirm) {
222
+ const noun = type === 'object'
223
+ ? `${itemCount} ${itemCount === 1 ? 'property' : 'properties'}`
224
+ : `${itemCount} ${itemCount === 1 ? 'item' : 'items'}`
225
+ const confirmed = await confirm({
226
+ title: 'Change value type?',
227
+ text: `Changing the type from ${type} to ${newType} will discard the ${noun} currently configured. This cannot be undone.`,
228
+ confirmText: 'Discard and change',
229
+ variant: 'destructive',
230
+ })
231
+ if (!confirmed) return
181
232
  }
182
- onChange(newVal)
233
+ onChange(defaultValueForType(newType))
183
234
  }
184
235
 
185
236
  const handleAddKey = () => {
@@ -250,7 +301,7 @@ function JsonNode({ data, onChange, onDelete, readOnly, label, isRoot }: JsonNod
250
301
  {!readOnly && (
251
302
  <Select
252
303
  value={type}
253
- onValueChange={(next) => handleTypeChange(next as JsonNodeType)}
304
+ onValueChange={(next) => { void handleTypeChange(next as JsonNodeType) }}
254
305
  >
255
306
  <SelectTrigger size="sm" className="w-auto min-w-[6rem]">
256
307
  <SelectValue />
@@ -342,6 +393,7 @@ function JsonNode({ data, onChange, onDelete, readOnly, label, isRoot }: JsonNod
342
393
  onChange={(v) => handleChildChange(key, v)}
343
394
  onDelete={() => handleChildDelete(key)}
344
395
  readOnly={readOnly}
396
+ confirm={confirm}
345
397
  />
346
398
  </div>
347
399
  </div>
@@ -355,6 +407,7 @@ function JsonNode({ data, onChange, onDelete, readOnly, label, isRoot }: JsonNod
355
407
  onChange={(v) => handleChildChange(idx, v)}
356
408
  onDelete={() => handleChildDelete(idx)}
357
409
  readOnly={readOnly}
410
+ confirm={confirm}
358
411
  />
359
412
  ))}
360
413
 
@@ -7,6 +7,7 @@ import { Checkbox } from '../primitives/checkbox'
7
7
  import { Spinner } from '../primitives/spinner'
8
8
  import { useConfirmDialog } from './confirm-dialog'
9
9
  import { flash } from './FlashMessages'
10
+ import { surfaceRecordConflict } from './conflicts'
10
11
  import { useT } from '@open-mercato/shared/lib/i18n/context'
11
12
  import type {
12
13
  PerspectiveDto,
@@ -133,7 +134,8 @@ export function PerspectiveSidebar({
133
134
  perspectiveId: targetId,
134
135
  })
135
136
  flash(t('ui.perspectives.autosave.success', 'View saved'), 'success')
136
- } catch {
137
+ } catch (err: unknown) {
138
+ if (surfaceRecordConflict(err, t)) return
137
139
  flash(t('ui.perspectives.autosave.error', 'Failed to save view'), 'error')
138
140
  }
139
141
  }, 400)
@@ -199,6 +201,7 @@ export function PerspectiveSidebar({
199
201
  await onSave({ name: name.trim(), isDefault, applyToRoles: [], setRoleDefault: false, perspectiveId: null })
200
202
  resetMode()
201
203
  } catch (err: unknown) {
204
+ if (surfaceRecordConflict(err, t)) return
202
205
  setError(err instanceof Error ? err.message : 'Failed to save view')
203
206
  }
204
207
  }
@@ -237,6 +240,7 @@ export function PerspectiveSidebar({
237
240
  })
238
241
  setRenamingId(null)
239
242
  } catch (err: unknown) {
243
+ if (surfaceRecordConflict(err, t)) return
240
244
  setError(err instanceof Error ? err.message : 'Failed to rename view')
241
245
  }
242
246
  }
@@ -282,6 +286,7 @@ export function PerspectiveSidebar({
282
286
  const msg = errMessage.toLowerCase()
283
287
  const isDuplicate = msg.includes('duplicate key') || msg.includes('unique constraint')
284
288
  if (!isDuplicate) {
289
+ if (surfaceRecordConflict(err, t)) return
285
290
  setError(errMessage || 'Failed to clone view')
286
291
  return
287
292
  }
@@ -289,6 +294,7 @@ export function PerspectiveSidebar({
289
294
  counter += 1
290
295
  }
291
296
  }
297
+ if (surfaceRecordConflict(lastErr, t)) return
292
298
  setError(lastErr instanceof Error ? lastErr.message : 'Failed to clone view')
293
299
  }
294
300
 
@@ -333,6 +339,7 @@ export function PerspectiveSidebar({
333
339
  setSharedIds((prev) => new Set([...prev, mode.perspectiveId]))
334
340
  resetMode()
335
341
  } catch (err: unknown) {
342
+ if (surfaceRecordConflict(err, t)) return
336
343
  setError(err instanceof Error ? err.message : 'Failed to share view')
337
344
  }
338
345
  }
@@ -100,7 +100,7 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
100
100
  variant="ghost"
101
101
  aria-haspopup="menu"
102
102
  aria-expanded={open}
103
- onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); requestAnimationFrame(updatePosition) }}
103
+ onClick={(e) => { e.stopPropagation(); setOpen(true); requestAnimationFrame(updatePosition) }}
104
104
  >
105
105
  <span aria-hidden="true">⋯</span>
106
106
  <span className="sr-only">{t('ui.rowActions.openActions', 'Open actions')}</span>
@@ -14,7 +14,6 @@ jest.mock('next/navigation', () => ({
14
14
  useSearchParams: () => new URLSearchParams(),
15
15
  }))
16
16
  jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
17
- jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
18
17
  jest.mock('../confirm-dialog', () => ({
19
18
  useConfirmDialog: () => ({
20
19
  confirm: jest.fn().mockResolvedValue(true),
@@ -4,7 +4,6 @@ jest.mock('next/navigation', () => ({
4
4
  useSearchParams: () => new URLSearchParams(),
5
5
  }))
6
6
  jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
7
- jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
8
7
 
9
8
  import * as React from 'react'
10
9
  import { renderToString } from 'react-dom/server'
@@ -9,7 +9,6 @@ jest.mock('next/navigation', () => ({
9
9
  useSearchParams: () => new URLSearchParams(),
10
10
  }))
11
11
  jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
12
- jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
13
12
  jest.mock('../confirm-dialog', () => ({
14
13
  useConfirmDialog: () => ({
15
14
  confirm: jest.fn().mockResolvedValue(true),