@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
|
@@ -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 =
|
|
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
|
-
() =>
|
|
1191
|
-
|
|
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
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
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={
|
|
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="
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
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')
|
|
152
|
+
const type = normalizeFlashKind(url.searchParams.get('type'))
|
|
129
153
|
if (message) {
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
onChange({})
|
|
53
|
-
}
|
|
54
|
-
setRawString('{}')
|
|
55
|
-
setParseError(null)
|
|
56
|
-
return
|
|
70
|
+
if (value === null && !disabled) {
|
|
71
|
+
onChange({})
|
|
57
72
|
}
|
|
58
|
-
|
|
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,
|
|
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={
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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(
|
|
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(
|
|
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),
|