@open-mercato/ui 0.6.6-develop.6317.1.b3be10ab84 → 0.6.6-develop.6331.1.a33b8e99b7
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 +1 -1
- package/dist/backend/AppShell.js +13 -2
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +2 -1
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/OrganizationScopeBoundary.js +33 -0
- package/dist/backend/OrganizationScopeBoundary.js.map +7 -0
- package/dist/backend/conflicts/index.js +50 -13
- package/dist/backend/conflicts/index.js.map +2 -2
- package/dist/backend/detail/AddressesSection.js +9 -5
- package/dist/backend/detail/AddressesSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +11 -6
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/injection/recordContext.js +40 -0
- package/dist/backend/injection/recordContext.js.map +7 -0
- package/dist/backend/notifications/NotificationPanel.js +14 -28
- package/dist/backend/notifications/NotificationPanel.js.map +2 -2
- package/dist/backend/utils/optimisticLock.js +19 -1
- package/dist/backend/utils/optimisticLock.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +26 -2
- package/src/backend/CrudForm.tsx +4 -1
- package/src/backend/OrganizationScopeBoundary.tsx +54 -0
- package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +59 -0
- package/src/backend/__tests__/NotesSection.test.tsx +71 -0
- package/src/backend/__tests__/OrganizationScopeBoundary.test.tsx +74 -0
- package/src/backend/conflicts/__tests__/recordLockDeferral.test.ts +99 -0
- package/src/backend/conflicts/index.ts +96 -19
- package/src/backend/detail/AddressesSection.tsx +12 -7
- package/src/backend/detail/NotesSection.tsx +19 -8
- package/src/backend/injection/recordContext.tsx +101 -0
- package/src/backend/notifications/NotificationPanel.tsx +35 -38
- package/src/backend/notifications/__tests__/NotificationPanel.test.tsx +124 -0
- package/src/backend/utils/optimisticLock.ts +36 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/notifications/NotificationPanel.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { ArrowDown, ArrowUp, Bell, Loader2, RotateCcw, Settings2, X } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { IconButton } from '../../primitives/icon-button'\nimport { Sheet, SheetContent, SheetTitle } from '../../primitives/sheet'\nimport { NotificationItem } from './NotificationItem'\nimport type { NotificationDto, NotificationRendererProps } from '@open-mercato/shared/modules/notifications/types'\nimport type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'\nimport type { ComponentType } from 'react'\n\n/**\n * Map of notification type to custom renderer component.\n * Used to provide custom rendering for specific notification types.\n *\n * @example\n * ```tsx\n * const customRenderers = {\n * 'sales.order.created': SalesOrderCreatedRenderer,\n * 'sales.quote.created': SalesQuoteCreatedRenderer,\n * }\n * ```\n */\nexport type NotificationRenderers = Record<string, ComponentType<NotificationRendererProps>>\n\nexport type NotificationPanelProps = {\n open: boolean\n onOpenChange: (open: boolean) => void\n notifications: NotificationDto[]\n unreadCount: number\n onMarkAsRead: (id: string) => Promise<void>\n onExecuteAction: (id: string, actionId: string) => Promise<{ href?: string }>\n onDismiss: (id: string) => Promise<void>\n dismissUndo?: { notification: NotificationDto; previousStatus: 'read' | 'unread' } | null\n onUndoDismiss?: () => Promise<void>\n onMarkAllRead: () => Promise<void>\n t: TranslateFn\n /**\n * Optional map of notification type to custom renderer component.\n * When a notification's type matches a key in this map, the corresponding\n * renderer will be used instead of the default NotificationItem rendering.\n *\n * @example\n * ```tsx\n * import { salesNotificationTypes } from '@open-mercato/core/modules/sales/notifications.client'\n *\n * // Build renderers map from notification types\n * const renderers = Object.fromEntries(\n * salesNotificationTypes\n * .filter(t => t.Renderer)\n * .map(t => [t.type, t.Renderer!])\n * )\n *\n * <NotificationPanel customRenderers={renderers} ... />\n * ```\n */\n customRenderers?: NotificationRenderers\n}\n\nexport function NotificationPanel({\n open,\n onOpenChange,\n notifications,\n unreadCount,\n onMarkAsRead,\n onExecuteAction,\n onDismiss,\n dismissUndo,\n onUndoDismiss,\n onMarkAllRead,\n t,\n customRenderers,\n}: NotificationPanelProps) {\n const [filter, setFilter] = React.useState<'all' | 'unread' | 'action'>('all')\n const [markingAllRead, setMarkingAllRead] = React.useState(false)\n\n const filteredNotifications = React.useMemo(() => {\n switch (filter) {\n case 'unread':\n return notifications.filter((n) => n.status === 'unread')\n case 'action':\n return notifications.filter(\n (n) => n.actions && n.actions.length > 0 && n.status !== 'actioned'\n )\n default:\n return notifications\n }\n }, [notifications, filter])\n\n const handleMarkAllRead = async () => {\n setMarkingAllRead(true)\n try {\n await onMarkAllRead()\n } finally {\n setMarkingAllRead(false)\n }\n }\n\n // Preserve the body scroll lock contract that consumers (and integration\n // tests) rely on. Radix Dialog locks scroll via react-remove-scroll which\n // does not set `document.body.style.overflow` \u2014 we set it ourselves so the\n // contract is observable.\n React.useEffect(() => {\n if (!open || typeof document === 'undefined') return\n const previous = document.body.style.overflow\n document.body.style.overflow = 'hidden'\n return () => {\n document.body.style.overflow = previous\n }\n }, [open])\n\n return (\n <Sheet open={open} onOpenChange={onOpenChange}>\n <SheetContent\n side=\"right\"\n className=\"gap-0 p-0\"\n hideClose\n aria-label={t('notifications.title', 'Notifications')}\n >\n <div className=\"flex items-center justify-between gap-3 border-b px-5 py-4\">\n <SheetTitle className=\"text-base font-medium leading-6 tracking-tight text-foreground\">\n {t('notifications.title', 'Notifications')}\n </SheetTitle>\n <div className=\"flex items-center gap-1\">\n {unreadCount > 0 ? (\n <button\n type=\"button\"\n onClick={handleMarkAllRead}\n disabled={markingAllRead}\n className=\"inline-flex items-center gap-1 rounded-md px-1 text-sm font-medium leading-5 text-accent-indigo transition-colors hover:text-accent-indigo/80 disabled:opacity-50\"\n >\n {markingAllRead ? <Loader2 className=\"size-3.5 animate-spin\" /> : null}\n {t('notifications.markAllRead', 'Mark all read')}\n </button>\n ) : null}\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => onOpenChange(false)}\n aria-label={t('notifications.close', 'Close notifications')}\n >\n <X className=\"size-4\" />\n </IconButton>\n </div>\n </div>\n\n <div role=\"tablist\" className=\"flex items-center gap-5 border-b px-5 py-3.5\">\n {(['all', 'unread', 'action'] as const).map((value) => {\n const isActive = filter === value\n const label =\n value === 'all'\n ? t('notifications.filters.all', 'All')\n : value === 'unread'\n ? t('notifications.filters.unread', 'Unread')\n : t('notifications.filters.actionRequired', 'Action Required')\n return (\n <button\n key={value}\n type=\"button\"\n role=\"tab\"\n aria-selected={isActive}\n onClick={() => setFilter(value)}\n className=\"relative inline-flex items-center gap-1.5 pb-2 text-sm font-medium leading-5 tracking-tight transition-colors focus:outline-none focus-visible:text-foreground data-[active=true]:text-foreground data-[active=false]:text-muted-foreground hover:text-foreground\"\n data-active={isActive}\n >\n <span>{label}</span>\n {value === 'unread' && unreadCount > 0 ? (\n <span className=\"inline-flex min-w-4 items-center justify-center rounded-full bg-accent-indigo px-1 text-overline font-medium text-accent-indigo-foreground\">\n {unreadCount > 99 ? '99+' : unreadCount}\n </span>\n ) : null}\n {isActive ? (\n <span\n className=\"absolute bottom-[-14px] left-0 right-0 h-0.5 bg-foreground\"\n aria-hidden=\"true\"\n />\n ) : null}\n </button>\n )\n })}\n </div>\n\n {dismissUndo && onUndoDismiss && (\n <div className=\"border-b bg-muted/50 px-4 py-2 text-sm\">\n <div className=\"flex items-center justify-between gap-3\">\n <span>\n {t('notifications.toast.dismissed', 'Notification dismissed')}\n </span>\n <Button variant=\"ghost\" size=\"sm\" onClick={() => onUndoDismiss()}>\n <RotateCcw className=\"mr-1 h-3 w-3\" />\n {t('notifications.actions.undo', 'Undo')}\n </Button>\n </div>\n </div>\n )}\n\n <div className=\"flex-1 overflow-y-auto overflow-x-hidden overscroll-contain\">\n {filteredNotifications.length === 0 ? (\n <div className=\"flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground\">\n <div className=\"flex size-12 items-center justify-center rounded-full bg-muted/50\">\n <Bell className=\"size-5\" aria-hidden=\"true\" />\n </div>\n <p className=\"text-sm\">{t('notifications.empty', 'No notifications')}</p>\n </div>\n ) : (\n <div className=\"flex flex-col gap-1 p-2\">\n {filteredNotifications.map((notification, idx) => (\n <React.Fragment key={notification.id}>\n {idx > 0 ? <div className=\"my-px h-px bg-border\" aria-hidden=\"true\" /> : null}\n <NotificationItem\n notification={notification}\n onMarkAsRead={() => onMarkAsRead(notification.id)}\n onExecuteAction={(actionId) => onExecuteAction(notification.id, actionId)}\n onDismiss={() => onDismiss(notification.id)}\n t={t}\n customRenderer={customRenderers?.[notification.type]}\n />\n </React.Fragment>\n ))}\n </div>\n )}\n </div>\n\n <div className=\"flex items-center justify-between gap-3 border-t px-5 py-3.5 text-xs leading-4 text-muted-foreground\">\n <div className=\"flex items-center gap-2\">\n <span>{t('notifications.footer.useHint', 'Use')}</span>\n <span className=\"inline-flex items-center rounded border bg-muted/50 px-1 py-0.5\">\n <ArrowUp className=\"size-3\" aria-hidden=\"true\" />\n </span>\n <span className=\"inline-flex items-center rounded border bg-muted/50 px-1 py-0.5\">\n <ArrowDown className=\"size-3\" aria-hidden=\"true\" />\n </span>\n <span>{t('notifications.footer.toNavigate', 'to navigate')}</span>\n </div>\n <Link\n href=\"/backend/config/notifications\"\n onClick={() => onOpenChange(false)}\n className=\"inline-flex items-center gap-1.5 font-medium text-muted-foreground transition-colors hover:text-foreground\"\n >\n <Settings2 className=\"size-3.5\" aria-hidden=\"true\" />\n {t('notifications.footer.manage', 'Manage notifications')}\n </Link>\n </div>\n </SheetContent>\n </Sheet>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { ArrowDown, ArrowUp, Bell, Loader2, RotateCcw, Settings2, X } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { IconButton } from '../../primitives/icon-button'\nimport { Sheet, SheetContent, SheetTitle } from '../../primitives/sheet'\nimport { Tabs, TabsList, TabsTrigger } from '../../primitives/tabs'\nimport { NotificationItem } from './NotificationItem'\nimport type { NotificationDto, NotificationRendererProps } from '@open-mercato/shared/modules/notifications/types'\nimport type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'\nimport type { ComponentType } from 'react'\n\n/**\n * Map of notification type to custom renderer component.\n * Used to provide custom rendering for specific notification types.\n *\n * @example\n * ```tsx\n * const customRenderers = {\n * 'sales.order.created': SalesOrderCreatedRenderer,\n * 'sales.quote.created': SalesQuoteCreatedRenderer,\n * }\n * ```\n */\nexport type NotificationRenderers = Record<string, ComponentType<NotificationRendererProps>>\n\nexport type NotificationPanelProps = {\n open: boolean\n onOpenChange: (open: boolean) => void\n notifications: NotificationDto[]\n unreadCount: number\n onMarkAsRead: (id: string) => Promise<void>\n onExecuteAction: (id: string, actionId: string) => Promise<{ href?: string }>\n onDismiss: (id: string) => Promise<void>\n dismissUndo?: { notification: NotificationDto; previousStatus: 'read' | 'unread' } | null\n onUndoDismiss?: () => Promise<void>\n onMarkAllRead: () => Promise<void>\n t: TranslateFn\n /**\n * Optional map of notification type to custom renderer component.\n * When a notification's type matches a key in this map, the corresponding\n * renderer will be used instead of the default NotificationItem rendering.\n *\n * @example\n * ```tsx\n * import { salesNotificationTypes } from '@open-mercato/core/modules/sales/notifications.client'\n *\n * // Build renderers map from notification types\n * const renderers = Object.fromEntries(\n * salesNotificationTypes\n * .filter(t => t.Renderer)\n * .map(t => [t.type, t.Renderer!])\n * )\n *\n * <NotificationPanel customRenderers={renderers} ... />\n * ```\n */\n customRenderers?: NotificationRenderers\n}\n\nexport function NotificationPanel({\n open,\n onOpenChange,\n notifications,\n unreadCount,\n onMarkAsRead,\n onExecuteAction,\n onDismiss,\n dismissUndo,\n onUndoDismiss,\n onMarkAllRead,\n t,\n customRenderers,\n}: NotificationPanelProps) {\n const [filter, setFilter] = React.useState<'all' | 'unread' | 'action'>('all')\n const [markingAllRead, setMarkingAllRead] = React.useState(false)\n\n const filteredNotifications = React.useMemo(() => {\n switch (filter) {\n case 'unread':\n return notifications.filter((n) => n.status === 'unread')\n case 'action':\n return notifications.filter(\n (n) => n.actions && n.actions.length > 0 && n.status !== 'actioned'\n )\n default:\n return notifications\n }\n }, [notifications, filter])\n\n const handleMarkAllRead = async () => {\n setMarkingAllRead(true)\n try {\n await onMarkAllRead()\n } finally {\n setMarkingAllRead(false)\n }\n }\n\n const handleFilterChange = React.useCallback((next: string) => {\n if (next === 'all' || next === 'unread' || next === 'action') {\n setFilter(next)\n }\n }, [])\n\n // Preserve the body scroll lock contract that consumers (and integration\n // tests) rely on. Radix Dialog locks scroll via react-remove-scroll which\n // does not set `document.body.style.overflow` \u2014 we set it ourselves so the\n // contract is observable.\n React.useEffect(() => {\n if (!open || typeof document === 'undefined') return\n const previous = document.body.style.overflow\n document.body.style.overflow = 'hidden'\n return () => {\n document.body.style.overflow = previous\n }\n }, [open])\n\n return (\n <Sheet open={open} onOpenChange={onOpenChange}>\n <SheetContent\n side=\"right\"\n className=\"gap-0 p-0\"\n hideClose\n aria-label={t('notifications.title', 'Notifications')}\n >\n <div className=\"flex items-center justify-between gap-3 border-b px-5 py-4\">\n <SheetTitle className=\"text-base font-medium leading-6 tracking-tight text-foreground\">\n {t('notifications.title', 'Notifications')}\n </SheetTitle>\n <div className=\"flex items-center gap-1\">\n {unreadCount > 0 ? (\n <Button\n type=\"button\"\n variant=\"link\"\n size=\"sm\"\n onClick={handleMarkAllRead}\n disabled={markingAllRead}\n className=\"gap-1 px-1 text-accent-indigo hover:text-accent-indigo/80 hover:no-underline\"\n >\n {markingAllRead ? <Loader2 className=\"size-3.5 animate-spin\" /> : null}\n {t('notifications.markAllRead', 'Mark all read')}\n </Button>\n ) : null}\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => onOpenChange(false)}\n aria-label={t('notifications.close', 'Close notifications')}\n >\n <X className=\"size-4\" />\n </IconButton>\n </div>\n </div>\n\n <Tabs value={filter} onValueChange={handleFilterChange} variant=\"underline\">\n <TabsList className=\"flex w-full px-5\">\n {(['all', 'unread', 'action'] as const).map((value) => {\n const label =\n value === 'all'\n ? t('notifications.filters.all', 'All')\n : value === 'unread'\n ? t('notifications.filters.unread', 'Unread')\n : t('notifications.filters.actionRequired', 'Action Required')\n const count =\n value === 'unread' && unreadCount > 0\n ? unreadCount > 99\n ? '99+'\n : unreadCount\n : undefined\n return (\n <TabsTrigger key={value} value={value} count={count}>\n {label}\n </TabsTrigger>\n )\n })}\n </TabsList>\n </Tabs>\n\n {dismissUndo && onUndoDismiss && (\n <div className=\"border-b bg-muted/50 px-4 py-2 text-sm\">\n <div className=\"flex items-center justify-between gap-3\">\n <span>\n {t('notifications.toast.dismissed', 'Notification dismissed')}\n </span>\n <Button variant=\"ghost\" size=\"sm\" onClick={() => onUndoDismiss()}>\n <RotateCcw className=\"mr-1 h-3 w-3\" />\n {t('notifications.actions.undo', 'Undo')}\n </Button>\n </div>\n </div>\n )}\n\n <div className=\"flex-1 overflow-y-auto overflow-x-hidden overscroll-contain\">\n {filteredNotifications.length === 0 ? (\n <div className=\"flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground\">\n <div className=\"flex size-12 items-center justify-center rounded-full bg-muted/50\">\n <Bell className=\"size-5\" aria-hidden=\"true\" />\n </div>\n <p className=\"text-sm\">{t('notifications.empty', 'No notifications')}</p>\n </div>\n ) : (\n <div className=\"flex flex-col gap-1 p-2\">\n {filteredNotifications.map((notification, idx) => (\n <React.Fragment key={notification.id}>\n {idx > 0 ? <div className=\"my-px h-px bg-border\" aria-hidden=\"true\" /> : null}\n <NotificationItem\n notification={notification}\n onMarkAsRead={() => onMarkAsRead(notification.id)}\n onExecuteAction={(actionId) => onExecuteAction(notification.id, actionId)}\n onDismiss={() => onDismiss(notification.id)}\n t={t}\n customRenderer={customRenderers?.[notification.type]}\n />\n </React.Fragment>\n ))}\n </div>\n )}\n </div>\n\n <div className=\"flex items-center justify-between gap-3 border-t px-5 py-3.5 text-xs leading-4 text-muted-foreground\">\n <div className=\"flex items-center gap-2\">\n <span>{t('notifications.footer.useHint', 'Use')}</span>\n <span className=\"inline-flex items-center rounded border bg-muted/50 px-1 py-0.5\">\n <ArrowUp className=\"size-3\" aria-hidden=\"true\" />\n </span>\n <span className=\"inline-flex items-center rounded border bg-muted/50 px-1 py-0.5\">\n <ArrowDown className=\"size-3\" aria-hidden=\"true\" />\n </span>\n <span>{t('notifications.footer.toNavigate', 'to navigate')}</span>\n </div>\n <Link\n href=\"/backend/config/notifications\"\n onClick={() => onOpenChange(false)}\n className=\"inline-flex items-center gap-1.5 font-medium text-muted-foreground transition-colors hover:text-foreground\"\n >\n <Settings2 className=\"size-3.5\" aria-hidden=\"true\" />\n {t('notifications.footer.manage', 'Manage notifications')}\n </Link>\n </div>\n </SheetContent>\n </Sheet>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -27,8 +27,26 @@ function extractOptimisticLockConflict(err) {
|
|
|
27
27
|
expectedUpdatedAt
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
|
+
const RECORD_LOCK_CONFLICT_CODE = "record_lock_conflict";
|
|
31
|
+
function extractRecordLockConflict(err) {
|
|
32
|
+
if (!err || typeof err !== "object") return null;
|
|
33
|
+
const candidate = err;
|
|
34
|
+
if (candidate.status !== 409) return null;
|
|
35
|
+
const body = candidate.body && typeof candidate.body === "object" ? candidate.body : candidate;
|
|
36
|
+
if (!body || typeof body !== "object") return null;
|
|
37
|
+
const bodyRecord = body;
|
|
38
|
+
if (bodyRecord.code !== RECORD_LOCK_CONFLICT_CODE) return null;
|
|
39
|
+
return {
|
|
40
|
+
code: RECORD_LOCK_CONFLICT_CODE,
|
|
41
|
+
error: typeof bodyRecord.error === "string" ? bodyRecord.error : void 0,
|
|
42
|
+
lock: bodyRecord.lock ?? null,
|
|
43
|
+
conflict: bodyRecord.conflict ?? null
|
|
44
|
+
};
|
|
45
|
+
}
|
|
30
46
|
export {
|
|
47
|
+
RECORD_LOCK_CONFLICT_CODE,
|
|
31
48
|
buildOptimisticLockHeader,
|
|
32
|
-
extractOptimisticLockConflict
|
|
49
|
+
extractOptimisticLockConflict,
|
|
50
|
+
extractRecordLockConflict
|
|
33
51
|
};
|
|
34
52
|
//# sourceMappingURL=optimisticLock.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/utils/optimisticLock.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Client-side helpers for the OSS opt-in optimistic-locking guard\n * (spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md).\n *\n * These are deliberately small and dependency-light so they can be wired\n * into any backend page without touching the shared CrudForm / useGuardedMutation\n * components. A future PR may pull them into CrudForm directly once the\n * reference rollout is broader.\n */\nimport {\n OPTIMISTIC_LOCK_CONFLICT_CODE,\n OPTIMISTIC_LOCK_HEADER_NAME,\n type OptimisticLockConflictBody,\n} from '@open-mercato/shared/lib/crud/optimistic-lock-headers'\n\n/**\n * Build the extension-header bag for an `apiCall` request. Pass into\n * `withScopedApiRequestHeaders(buildOptimisticLockHeader(updatedAt), ...)`\n * when issuing a `PUT`/`PATCH`/`DELETE` for a record that exposes\n * `updatedAt`. Returns an empty object when the input is missing/empty so\n * the call site stays unconditional:\n *\n * ```ts\n * await withScopedApiRequestHeaders(\n * buildOptimisticLockHeader(record.updatedAt),\n * () => updateCrud('customers/companies', id, { ... }),\n * )\n * ```\n */\nexport function buildOptimisticLockHeader(\n updatedAt: string | null | undefined,\n): Record<string, string> {\n if (typeof updatedAt !== 'string') return {}\n const trimmed = updatedAt.trim()\n if (!trimmed) return {}\n return { [OPTIMISTIC_LOCK_HEADER_NAME]: trimmed }\n}\n\n/**\n * Detect whether an error thrown by `raiseCrudError`/`apiCallOrThrow` is an\n * optimistic-lock conflict (HTTP 409 with `code: 'optimistic_lock_conflict'`).\n *\n * Returns the typed conflict body when matched so the caller can show a\n * UI that includes the server's `currentUpdatedAt`, or `null` otherwise.\n */\nexport function extractOptimisticLockConflict(\n err: unknown,\n): OptimisticLockConflictBody | null {\n if (!err || typeof err !== 'object') return null\n const candidate = err as Record<string, unknown>\n const status = candidate.status\n if (status !== 409) return null\n const body = candidate.body && typeof candidate.body === 'object'\n ? candidate.body\n : candidate\n if (!body || typeof body !== 'object') return null\n const bodyRecord = body as Record<string, unknown>\n if (bodyRecord.code !== OPTIMISTIC_LOCK_CONFLICT_CODE) return null\n const currentUpdatedAt = bodyRecord.currentUpdatedAt\n const expectedUpdatedAt = bodyRecord.expectedUpdatedAt\n if (typeof currentUpdatedAt !== 'string' || typeof expectedUpdatedAt !== 'string') return null\n return {\n error: typeof bodyRecord.error === 'string'\n ? (bodyRecord.error as OptimisticLockConflictBody['error'])\n : 'record_modified',\n code: OPTIMISTIC_LOCK_CONFLICT_CODE,\n currentUpdatedAt,\n expectedUpdatedAt,\n }\n}\n"],
|
|
5
|
-
"mappings": "AASA;AAAA,EACE;AAAA,EACA;AAAA,OAEK;AAgBA,SAAS,0BACd,WACwB;AACxB,MAAI,OAAO,cAAc,SAAU,QAAO,CAAC;AAC3C,QAAM,UAAU,UAAU,KAAK;AAC/B,MAAI,CAAC,QAAS,QAAO,CAAC;AACtB,SAAO,EAAE,CAAC,2BAA2B,GAAG,QAAQ;AAClD;AASO,SAAS,8BACd,KACmC;AACnC,MAAI,CAAC,OAAO,OAAO,QAAQ,SAAU,QAAO;AAC5C,QAAM,YAAY;AAClB,QAAM,SAAS,UAAU;AACzB,MAAI,WAAW,IAAK,QAAO;AAC3B,QAAM,OAAO,UAAU,QAAQ,OAAO,UAAU,SAAS,WACrD,UAAU,OACV;AACJ,MAAI,CAAC,QAAQ,OAAO,SAAS,SAAU,QAAO;AAC9C,QAAM,aAAa;AACnB,MAAI,WAAW,SAAS,8BAA+B,QAAO;AAC9D,QAAM,mBAAmB,WAAW;AACpC,QAAM,oBAAoB,WAAW;AACrC,MAAI,OAAO,qBAAqB,YAAY,OAAO,sBAAsB,SAAU,QAAO;AAC1F,SAAO;AAAA,IACL,OAAO,OAAO,WAAW,UAAU,WAC9B,WAAW,QACZ;AAAA,IACJ,MAAM;AAAA,IACN;AAAA,IACA;AAAA,EACF;AACF;",
|
|
4
|
+
"sourcesContent": ["/**\n * Client-side helpers for the OSS opt-in optimistic-locking guard\n * (spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md).\n *\n * These are deliberately small and dependency-light so they can be wired\n * into any backend page without touching the shared CrudForm / useGuardedMutation\n * components. A future PR may pull them into CrudForm directly once the\n * reference rollout is broader.\n */\nimport {\n OPTIMISTIC_LOCK_CONFLICT_CODE,\n OPTIMISTIC_LOCK_HEADER_NAME,\n type OptimisticLockConflictBody,\n} from '@open-mercato/shared/lib/crud/optimistic-lock-headers'\n\n/**\n * Build the extension-header bag for an `apiCall` request. Pass into\n * `withScopedApiRequestHeaders(buildOptimisticLockHeader(updatedAt), ...)`\n * when issuing a `PUT`/`PATCH`/`DELETE` for a record that exposes\n * `updatedAt`. Returns an empty object when the input is missing/empty so\n * the call site stays unconditional:\n *\n * ```ts\n * await withScopedApiRequestHeaders(\n * buildOptimisticLockHeader(record.updatedAt),\n * () => updateCrud('customers/companies', id, { ... }),\n * )\n * ```\n */\nexport function buildOptimisticLockHeader(\n updatedAt: string | null | undefined,\n): Record<string, string> {\n if (typeof updatedAt !== 'string') return {}\n const trimmed = updatedAt.trim()\n if (!trimmed) return {}\n return { [OPTIMISTIC_LOCK_HEADER_NAME]: trimmed }\n}\n\n/**\n * Detect whether an error thrown by `raiseCrudError`/`apiCallOrThrow` is an\n * optimistic-lock conflict (HTTP 409 with `code: 'optimistic_lock_conflict'`).\n *\n * Returns the typed conflict body when matched so the caller can show a\n * UI that includes the server's `currentUpdatedAt`, or `null` otherwise.\n */\nexport function extractOptimisticLockConflict(\n err: unknown,\n): OptimisticLockConflictBody | null {\n if (!err || typeof err !== 'object') return null\n const candidate = err as Record<string, unknown>\n const status = candidate.status\n if (status !== 409) return null\n const body = candidate.body && typeof candidate.body === 'object'\n ? candidate.body\n : candidate\n if (!body || typeof body !== 'object') return null\n const bodyRecord = body as Record<string, unknown>\n if (bodyRecord.code !== OPTIMISTIC_LOCK_CONFLICT_CODE) return null\n const currentUpdatedAt = bodyRecord.currentUpdatedAt\n const expectedUpdatedAt = bodyRecord.expectedUpdatedAt\n if (typeof currentUpdatedAt !== 'string' || typeof expectedUpdatedAt !== 'string') return null\n return {\n error: typeof bodyRecord.error === 'string'\n ? (bodyRecord.error as OptimisticLockConflictBody['error'])\n : 'record_modified',\n code: OPTIMISTIC_LOCK_CONFLICT_CODE,\n currentUpdatedAt,\n expectedUpdatedAt,\n }\n}\n\n/**\n * The enterprise `record_locks` 409 conflict code. Kept as a literal here so\n * core/UI stays enterprise-free (no import from `@open-mercato/enterprise`).\n */\nexport const RECORD_LOCK_CONFLICT_CODE = 'record_lock_conflict' as const\n\nexport type RecordLockConflictBody = {\n code: typeof RECORD_LOCK_CONFLICT_CODE\n error?: string\n lock?: unknown\n conflict?: unknown\n}\n\n/**\n * Detect whether an error is an enterprise `record_locks` conflict (HTTP 409\n * with `code: 'record_lock_conflict'`). Returns the conflict body when matched\n * so `surfaceRecordConflict` can defer to the merge-dialog widget, or `null`.\n */\nexport function extractRecordLockConflict(err: unknown): RecordLockConflictBody | null {\n if (!err || typeof err !== 'object') return null\n const candidate = err as Record<string, unknown>\n if (candidate.status !== 409) return null\n const body = candidate.body && typeof candidate.body === 'object'\n ? candidate.body\n : candidate\n if (!body || typeof body !== 'object') return null\n const bodyRecord = body as Record<string, unknown>\n if (bodyRecord.code !== RECORD_LOCK_CONFLICT_CODE) return null\n return {\n code: RECORD_LOCK_CONFLICT_CODE,\n error: typeof bodyRecord.error === 'string' ? bodyRecord.error : undefined,\n lock: bodyRecord.lock ?? null,\n conflict: bodyRecord.conflict ?? null,\n }\n}\n"],
|
|
5
|
+
"mappings": "AASA;AAAA,EACE;AAAA,EACA;AAAA,OAEK;AAgBA,SAAS,0BACd,WACwB;AACxB,MAAI,OAAO,cAAc,SAAU,QAAO,CAAC;AAC3C,QAAM,UAAU,UAAU,KAAK;AAC/B,MAAI,CAAC,QAAS,QAAO,CAAC;AACtB,SAAO,EAAE,CAAC,2BAA2B,GAAG,QAAQ;AAClD;AASO,SAAS,8BACd,KACmC;AACnC,MAAI,CAAC,OAAO,OAAO,QAAQ,SAAU,QAAO;AAC5C,QAAM,YAAY;AAClB,QAAM,SAAS,UAAU;AACzB,MAAI,WAAW,IAAK,QAAO;AAC3B,QAAM,OAAO,UAAU,QAAQ,OAAO,UAAU,SAAS,WACrD,UAAU,OACV;AACJ,MAAI,CAAC,QAAQ,OAAO,SAAS,SAAU,QAAO;AAC9C,QAAM,aAAa;AACnB,MAAI,WAAW,SAAS,8BAA+B,QAAO;AAC9D,QAAM,mBAAmB,WAAW;AACpC,QAAM,oBAAoB,WAAW;AACrC,MAAI,OAAO,qBAAqB,YAAY,OAAO,sBAAsB,SAAU,QAAO;AAC1F,SAAO;AAAA,IACL,OAAO,OAAO,WAAW,UAAU,WAC9B,WAAW,QACZ;AAAA,IACJ,MAAM;AAAA,IACN;AAAA,IACA;AAAA,EACF;AACF;AAMO,MAAM,4BAA4B;AAclC,SAAS,0BAA0B,KAA6C;AACrF,MAAI,CAAC,OAAO,OAAO,QAAQ,SAAU,QAAO;AAC5C,QAAM,YAAY;AAClB,MAAI,UAAU,WAAW,IAAK,QAAO;AACrC,QAAM,OAAO,UAAU,QAAQ,OAAO,UAAU,SAAS,WACrD,UAAU,OACV;AACJ,MAAI,CAAC,QAAQ,OAAO,SAAS,SAAU,QAAO;AAC9C,QAAM,aAAa;AACnB,MAAI,WAAW,SAAS,0BAA2B,QAAO;AAC1D,SAAO;AAAA,IACL,MAAM;AAAA,IACN,OAAO,OAAO,WAAW,UAAU,WAAW,WAAW,QAAQ;AAAA,IACjE,MAAM,WAAW,QAAQ;AAAA,IACzB,UAAU,WAAW,YAAY;AAAA,EACnC;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6331.1.a33b8e99b7",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,13 +155,13 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
158
|
+
"@open-mercato/shared": "0.6.6-develop.6331.1.a33b8e99b7",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
164
|
+
"@open-mercato/shared": "0.6.6-develop.6331.1.a33b8e99b7",
|
|
165
165
|
"@testing-library/dom": "^10.4.1",
|
|
166
166
|
"@testing-library/jest-dom": "^6.9.1",
|
|
167
167
|
"@testing-library/react": "^16.3.1",
|
package/src/backend/AppShell.tsx
CHANGED
|
@@ -29,12 +29,17 @@ import { dismissRecordConflict } from './conflicts/store'
|
|
|
29
29
|
import { ProgressTopBar } from './progress/ProgressTopBar'
|
|
30
30
|
import { UpgradeActionBanner } from './upgrades/UpgradeActionBanner'
|
|
31
31
|
import { PartialIndexBanner } from './indexes/PartialIndexBanner'
|
|
32
|
+
import { OrganizationScopeBoundary } from './OrganizationScopeBoundary'
|
|
32
33
|
import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
|
|
33
34
|
import { slugifySidebarId } from '@open-mercato/shared/modules/navigation/sidebarPreferences'
|
|
34
35
|
import { readVersionedPreference, writeVersionedPreference } from '@open-mercato/shared/lib/browser/versionedPreference'
|
|
35
36
|
import { cloneSidebarGroups } from './sidebar/customization-helpers'
|
|
36
37
|
import type { SectionNavGroup } from './section-page/types'
|
|
37
38
|
import { InjectionSpot } from './injection/InjectionSpot'
|
|
39
|
+
import {
|
|
40
|
+
BackendRecordInjectionContextProvider,
|
|
41
|
+
type RecordInjectionContext,
|
|
42
|
+
} from './injection/recordContext'
|
|
38
43
|
import type { InjectionMenuItem } from '@open-mercato/shared/modules/widgets/injection'
|
|
39
44
|
import { LEGACY_GLOBAL_MUTATION_INJECTION_SPOT_ID } from './injection/mutationEvents'
|
|
40
45
|
import { mergeMenuItems } from './injection/mergeMenuItems'
|
|
@@ -592,6 +597,21 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
592
597
|
[pathname, searchParams],
|
|
593
598
|
)
|
|
594
599
|
|
|
600
|
+
// AppShell-owned transport for the current detail record (Phase 0 / S2).
|
|
601
|
+
// Detail pages publish here; the merged context feeds the global
|
|
602
|
+
// `backend:record:current` mount so the record_locks widget can resolve the
|
|
603
|
+
// resource without a hardcoded path allowlist. Stale context (published for a
|
|
604
|
+
// different path) is ignored so it never leaks across route transitions.
|
|
605
|
+
const [currentRecordInjectionContext, setCurrentRecordInjectionContext] =
|
|
606
|
+
React.useState<RecordInjectionContext | null>(null)
|
|
607
|
+
|
|
608
|
+
const recordInjectionContext = React.useMemo(() => {
|
|
609
|
+
if (!currentRecordInjectionContext) return injectionContext
|
|
610
|
+
const publishedPath = currentRecordInjectionContext.path
|
|
611
|
+
if (publishedPath && pathname && publishedPath !== pathname) return injectionContext
|
|
612
|
+
return { ...injectionContext, ...currentRecordInjectionContext }
|
|
613
|
+
}, [injectionContext, currentRecordInjectionContext, pathname])
|
|
614
|
+
|
|
595
615
|
const isOnSettingsPath = React.useMemo(() => {
|
|
596
616
|
if (!pathname) return false
|
|
597
617
|
if (pathname === '/backend/settings') return true
|
|
@@ -1393,13 +1413,17 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
1393
1413
|
{canManageUpgradeActions ? <UpgradeActionBanner /> : null}
|
|
1394
1414
|
<LastOperationBanner />
|
|
1395
1415
|
<RecordConflictBanner />
|
|
1396
|
-
<InjectionSpot spotId={BACKEND_RECORD_CURRENT_INJECTION_SPOT_ID} context={
|
|
1416
|
+
<InjectionSpot spotId={BACKEND_RECORD_CURRENT_INJECTION_SPOT_ID} context={recordInjectionContext} />
|
|
1397
1417
|
<InjectionSpot
|
|
1398
1418
|
spotId={LEGACY_GLOBAL_MUTATION_INJECTION_SPOT_ID}
|
|
1399
1419
|
context={injectionContext}
|
|
1400
1420
|
/>
|
|
1401
1421
|
<div id="om-top-banners" className="mb-3 space-y-2" />
|
|
1402
|
-
{
|
|
1422
|
+
<OrganizationScopeBoundary active={isOnSettingsPath}>
|
|
1423
|
+
<BackendRecordInjectionContextProvider setCurrentRecordInjectionContext={setCurrentRecordInjectionContext}>
|
|
1424
|
+
{children}
|
|
1425
|
+
</BackendRecordInjectionContextProvider>
|
|
1426
|
+
</OrganizationScopeBoundary>
|
|
1403
1427
|
<InjectionSpot spotId={BACKEND_LAYOUT_FOOTER_INJECTION_SPOT_ID} context={injectionContext} />
|
|
1404
1428
|
</main>
|
|
1405
1429
|
<footer className="border-t bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/80 px-4 py-3 flex flex-wrap items-center justify-end gap-4">
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -73,6 +73,7 @@ import {
|
|
|
73
73
|
} from 'lucide-react'
|
|
74
74
|
import { loadGeneratedFieldRegistrations } from './fields/registry'
|
|
75
75
|
import type { CustomFieldDefDto, CustomFieldDefinitionsPayload, CustomFieldsetDto } from './utils/customFieldDefs'
|
|
76
|
+
import { isDefVisible } from './utils/customFieldDefs'
|
|
76
77
|
import { buildFormFieldsFromCustomFields, buildFormFieldFromCustomFieldDef } from './utils/customFieldForms'
|
|
77
78
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
78
79
|
import { TagsInput } from './inputs/TagsInput'
|
|
@@ -1553,7 +1554,9 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1553
1554
|
fieldsetGroupMap.set(group.code, { code: group.code, title: group.title, hint: group.hint })
|
|
1554
1555
|
})
|
|
1555
1556
|
}
|
|
1556
|
-
const sortedDefs = [...defList]
|
|
1557
|
+
const sortedDefs = [...defList]
|
|
1558
|
+
.filter((definition) => isDefVisible(definition, 'form'))
|
|
1559
|
+
.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
|
|
1557
1560
|
const ensureBucket = (code: string | null, def: CustomFieldDefDto): CustomFieldGroupLayout => {
|
|
1558
1561
|
const key = code ?? '__default__'
|
|
1559
1562
|
let bucket = groupsMap.get(key)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import {
|
|
4
|
+
subscribeOrganizationScopeChanged,
|
|
5
|
+
type OrganizationScopeChangedDetail,
|
|
6
|
+
} from '@open-mercato/shared/lib/frontend/organizationEvents'
|
|
7
|
+
|
|
8
|
+
export type OrganizationScopeBoundaryProps = {
|
|
9
|
+
active: boolean
|
|
10
|
+
children: React.ReactNode
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Remounts its children whenever the active organization/tenant scope changes.
|
|
15
|
+
*
|
|
16
|
+
* The org/tenant switcher already calls `router.refresh()` on a scope change,
|
|
17
|
+
* which re-runs server components but does NOT remount client components — so
|
|
18
|
+
* settings pages that fetch their data once in a mount `useEffect` keep showing
|
|
19
|
+
* the previous scope's values until a manual reload. Wrapping those pages here
|
|
20
|
+
* forces a fresh mount (and therefore a refetch) on a real scope change.
|
|
21
|
+
*
|
|
22
|
+
* Gated by `active` so only the settings surface remounts; list/CRUD pages keep
|
|
23
|
+
* relying on the smoother `router.refresh()` path that preserves table state.
|
|
24
|
+
*/
|
|
25
|
+
export function OrganizationScopeBoundary({ active, children }: OrganizationScopeBoundaryProps) {
|
|
26
|
+
const [scopeKey, setScopeKey] = React.useState(0)
|
|
27
|
+
const lastScopeRef = React.useRef<OrganizationScopeChangedDetail | null>(null)
|
|
28
|
+
const hasInitializedScopeRef = React.useRef(false)
|
|
29
|
+
|
|
30
|
+
React.useEffect(() => {
|
|
31
|
+
return subscribeOrganizationScopeChanged((detail) => {
|
|
32
|
+
const prev = lastScopeRef.current
|
|
33
|
+
lastScopeRef.current = detail
|
|
34
|
+
if (!hasInitializedScopeRef.current) {
|
|
35
|
+
hasInitializedScopeRef.current = true
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
if (
|
|
39
|
+
prev &&
|
|
40
|
+
prev.organizationId === detail.organizationId &&
|
|
41
|
+
prev.tenantId === detail.tenantId
|
|
42
|
+
) {
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
setScopeKey((current) => current + 1)
|
|
46
|
+
})
|
|
47
|
+
}, [])
|
|
48
|
+
|
|
49
|
+
if (!active) {
|
|
50
|
+
return <>{children}</>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return <React.Fragment key={scopeKey}>{children}</React.Fragment>
|
|
54
|
+
}
|
|
@@ -275,6 +275,65 @@ describe('CrudForm custom field loading', () => {
|
|
|
275
275
|
expect(container.querySelector('[data-crud-field-id="cf_renewal_quarter"]')?.textContent).toContain('Q3')
|
|
276
276
|
})
|
|
277
277
|
|
|
278
|
+
it('omits custom fields with formEditable:false from the generated custom-fields section', async () => {
|
|
279
|
+
buildFormFieldFromCustomFieldDefMock.mockImplementation((definition: any) => ({
|
|
280
|
+
id: `cf_${definition.key}`,
|
|
281
|
+
label: definition.label ?? definition.key,
|
|
282
|
+
type: 'text',
|
|
283
|
+
}))
|
|
284
|
+
fetchCustomFieldFormStructureMock.mockResolvedValue({
|
|
285
|
+
fields: [],
|
|
286
|
+
definitions: [
|
|
287
|
+
{
|
|
288
|
+
entityId: 'customers:customer_company_profile',
|
|
289
|
+
key: 'editable_note',
|
|
290
|
+
label: 'Editable note',
|
|
291
|
+
kind: 'text',
|
|
292
|
+
formEditable: true,
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
entityId: 'customers:customer_company_profile',
|
|
296
|
+
key: 'readonly_note',
|
|
297
|
+
label: 'Readonly note',
|
|
298
|
+
kind: 'text',
|
|
299
|
+
formEditable: false,
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
metadata: {
|
|
303
|
+
items: [],
|
|
304
|
+
fieldsetsByEntity: {},
|
|
305
|
+
entitySettings: {},
|
|
306
|
+
},
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
const { container } = renderWithProviders(
|
|
310
|
+
<CrudForm
|
|
311
|
+
embedded
|
|
312
|
+
title="Form"
|
|
313
|
+
entityId="customers:customer_company_profile"
|
|
314
|
+
fields={fields}
|
|
315
|
+
groups={groups}
|
|
316
|
+
initialValues={{ name: 'Acme', editable_note: 'x', readonly_note: 'y' }}
|
|
317
|
+
onSubmit={() => {}}
|
|
318
|
+
/>,
|
|
319
|
+
{
|
|
320
|
+
dict: {
|
|
321
|
+
'ui.forms.actions.save': 'Save',
|
|
322
|
+
'entities.customFields.manageFieldset': 'Manage fields',
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
await waitFor(() => {
|
|
328
|
+
expect(container.querySelector('[data-crud-field-id="cf_editable_note"]')).not.toBeNull()
|
|
329
|
+
})
|
|
330
|
+
expect(container.querySelector('[data-crud-field-id="cf_readonly_note"]')).toBeNull()
|
|
331
|
+
expect(buildFormFieldFromCustomFieldDefMock).not.toHaveBeenCalledWith(
|
|
332
|
+
expect.objectContaining({ key: 'readonly_note' }),
|
|
333
|
+
expect.anything(),
|
|
334
|
+
)
|
|
335
|
+
})
|
|
336
|
+
|
|
278
337
|
it('submits custom entity values without loaded record metadata', async () => {
|
|
279
338
|
const handleSubmit = jest.fn().mockResolvedValue(undefined)
|
|
280
339
|
|
|
@@ -4,9 +4,12 @@ import * as React from 'react'
|
|
|
4
4
|
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
|
5
5
|
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
6
6
|
import { NotesSection, type NotesDataAdapter } from '../detail/NotesSection'
|
|
7
|
+
import { dismissRecordConflict, getRecordConflictForTest } from '../conflicts'
|
|
8
|
+
import { OPTIMISTIC_LOCK_CONFLICT_CODE } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
7
9
|
|
|
8
10
|
describe('NotesSection', () => {
|
|
9
11
|
beforeEach(() => {
|
|
12
|
+
dismissRecordConflict()
|
|
10
13
|
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
|
11
14
|
configurable: true,
|
|
12
15
|
writable: true,
|
|
@@ -22,6 +25,10 @@ describe('NotesSection', () => {
|
|
|
22
25
|
})
|
|
23
26
|
})
|
|
24
27
|
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
dismissRecordConflict()
|
|
30
|
+
})
|
|
31
|
+
|
|
25
32
|
it('keeps an add-note action visible after notes already exist', async () => {
|
|
26
33
|
const dataAdapter: NotesDataAdapter = {
|
|
27
34
|
list: jest.fn(async () => [
|
|
@@ -60,4 +67,68 @@ describe('NotesSection', () => {
|
|
|
60
67
|
expect(container.querySelector('textarea')).not.toBeNull()
|
|
61
68
|
})
|
|
62
69
|
})
|
|
70
|
+
|
|
71
|
+
it('surfaces the unified conflict bar when a write fails with a 409', async () => {
|
|
72
|
+
const conflict = {
|
|
73
|
+
status: 409,
|
|
74
|
+
body: {
|
|
75
|
+
error: 'optimistic_lock_conflict',
|
|
76
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
77
|
+
currentUpdatedAt: '2026-06-02T00:00:00.000Z',
|
|
78
|
+
expectedUpdatedAt: '2026-06-01T00:00:00.000Z',
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
const dataAdapter: NotesDataAdapter = {
|
|
82
|
+
list: jest.fn(async () => [
|
|
83
|
+
{
|
|
84
|
+
id: 'note-1',
|
|
85
|
+
body: 'Existing note',
|
|
86
|
+
createdAt: '2026-04-10T08:00:00.000Z',
|
|
87
|
+
authorName: 'Ada Lovelace',
|
|
88
|
+
},
|
|
89
|
+
]),
|
|
90
|
+
create: jest.fn(async () => {
|
|
91
|
+
throw conflict
|
|
92
|
+
}),
|
|
93
|
+
update: jest.fn(async () => undefined),
|
|
94
|
+
delete: jest.fn(async () => undefined),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const { container } = renderWithProviders(
|
|
98
|
+
<NotesSection
|
|
99
|
+
entityId="person-1"
|
|
100
|
+
emptyLabel="—"
|
|
101
|
+
viewerUserId="user-1"
|
|
102
|
+
viewerName="Ada Lovelace"
|
|
103
|
+
addActionLabel="Add note"
|
|
104
|
+
emptyState={{
|
|
105
|
+
title: 'No notes yet',
|
|
106
|
+
actionLabel: 'Add note',
|
|
107
|
+
}}
|
|
108
|
+
dataAdapter={dataAdapter}
|
|
109
|
+
disableMarkdown
|
|
110
|
+
/>,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
await screen.findByText('Existing note')
|
|
114
|
+
fireEvent.click(screen.getByRole('button', { name: 'Add note' }))
|
|
115
|
+
|
|
116
|
+
const textarea = await waitFor(() => {
|
|
117
|
+
const el = container.querySelector('textarea')
|
|
118
|
+
if (!el) throw new Error('composer not open')
|
|
119
|
+
return el as HTMLTextAreaElement
|
|
120
|
+
})
|
|
121
|
+
fireEvent.change(textarea, { target: { value: 'Conflicting note' } })
|
|
122
|
+
|
|
123
|
+
const form = container.querySelector('form')
|
|
124
|
+
expect(form).not.toBeNull()
|
|
125
|
+
fireEvent.submit(form as HTMLFormElement)
|
|
126
|
+
|
|
127
|
+
await waitFor(() => {
|
|
128
|
+
expect(dataAdapter.create).toHaveBeenCalledTimes(1)
|
|
129
|
+
const entry = getRecordConflictForTest()
|
|
130
|
+
expect(entry).not.toBeNull()
|
|
131
|
+
expect(entry?.currentUpdatedAt).toBe('2026-06-02T00:00:00.000Z')
|
|
132
|
+
})
|
|
133
|
+
})
|
|
63
134
|
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { act, render } from '@testing-library/react'
|
|
7
|
+
import { emitOrganizationScopeChanged } from '@open-mercato/shared/lib/frontend/organizationEvents'
|
|
8
|
+
import { OrganizationScopeBoundary } from '../OrganizationScopeBoundary'
|
|
9
|
+
|
|
10
|
+
let mountCount = 0
|
|
11
|
+
|
|
12
|
+
function MountCounter() {
|
|
13
|
+
React.useEffect(() => {
|
|
14
|
+
mountCount += 1
|
|
15
|
+
}, [])
|
|
16
|
+
return <span data-testid="child">child</span>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('<OrganizationScopeBoundary>', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
mountCount = 0
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('remounts children on a real scope change when active', () => {
|
|
25
|
+
render(
|
|
26
|
+
<OrganizationScopeBoundary active>
|
|
27
|
+
<MountCounter />
|
|
28
|
+
</OrganizationScopeBoundary>,
|
|
29
|
+
)
|
|
30
|
+
expect(mountCount).toBe(1)
|
|
31
|
+
|
|
32
|
+
// First scope event after mount only establishes the baseline.
|
|
33
|
+
act(() => {
|
|
34
|
+
emitOrganizationScopeChanged({ organizationId: 'org-a', tenantId: 'tenant-a' })
|
|
35
|
+
})
|
|
36
|
+
expect(mountCount).toBe(1)
|
|
37
|
+
|
|
38
|
+
// A genuine change remounts the subtree, re-running mount effects.
|
|
39
|
+
act(() => {
|
|
40
|
+
emitOrganizationScopeChanged({ organizationId: 'org-b', tenantId: 'tenant-a' })
|
|
41
|
+
})
|
|
42
|
+
expect(mountCount).toBe(2)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('does not remount children when the scope is unchanged', () => {
|
|
46
|
+
render(
|
|
47
|
+
<OrganizationScopeBoundary active>
|
|
48
|
+
<MountCounter />
|
|
49
|
+
</OrganizationScopeBoundary>,
|
|
50
|
+
)
|
|
51
|
+
act(() => {
|
|
52
|
+
emitOrganizationScopeChanged({ organizationId: 'org-c', tenantId: 'tenant-c' })
|
|
53
|
+
})
|
|
54
|
+
act(() => {
|
|
55
|
+
emitOrganizationScopeChanged({ organizationId: 'org-c', tenantId: 'tenant-c' })
|
|
56
|
+
})
|
|
57
|
+
expect(mountCount).toBe(1)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('does not remount children on a scope change when inactive', () => {
|
|
61
|
+
render(
|
|
62
|
+
<OrganizationScopeBoundary active={false}>
|
|
63
|
+
<MountCounter />
|
|
64
|
+
</OrganizationScopeBoundary>,
|
|
65
|
+
)
|
|
66
|
+
act(() => {
|
|
67
|
+
emitOrganizationScopeChanged({ organizationId: 'org-d', tenantId: 'tenant-d' })
|
|
68
|
+
})
|
|
69
|
+
act(() => {
|
|
70
|
+
emitOrganizationScopeChanged({ organizationId: 'org-e', tenantId: 'tenant-e' })
|
|
71
|
+
})
|
|
72
|
+
expect(mountCount).toBe(1)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
registerRecordLockConflictHandler,
|
|
8
|
+
resetRecordLockConflictHandlerForTest,
|
|
9
|
+
surfaceRecordConflict,
|
|
10
|
+
} from '..'
|
|
11
|
+
|
|
12
|
+
const t = (key: string, fallback?: string) => fallback ?? key
|
|
13
|
+
|
|
14
|
+
function recordLockConflict() {
|
|
15
|
+
return new CrudHttpError(409, {
|
|
16
|
+
error: 'Record conflict detected',
|
|
17
|
+
code: 'record_lock_conflict',
|
|
18
|
+
lock: null,
|
|
19
|
+
conflict: { id: 'c1' },
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ossConflict() {
|
|
24
|
+
return new CrudHttpError(409, {
|
|
25
|
+
error: 'record_modified',
|
|
26
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
27
|
+
currentUpdatedAt: '2026-06-01T00:00:01.000Z',
|
|
28
|
+
expectedUpdatedAt: '2026-06-01T00:00:00.000Z',
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('surfaceRecordConflict — single surface (S3)', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
dismissRecordConflict()
|
|
35
|
+
resetRecordLockConflictHandlerForTest()
|
|
36
|
+
})
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
dismissRecordConflict()
|
|
39
|
+
resetRecordLockConflictHandlerForTest()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('defers a record_lock_conflict to the registered merge-dialog handler that owns the record (returns true → no OSS bar)', () => {
|
|
43
|
+
const handler = jest.fn(() => true)
|
|
44
|
+
registerRecordLockConflictHandler(handler)
|
|
45
|
+
|
|
46
|
+
const handled = surfaceRecordConflict(recordLockConflict(), t)
|
|
47
|
+
|
|
48
|
+
expect(handled).toBe(true)
|
|
49
|
+
expect(handler).toHaveBeenCalledTimes(1)
|
|
50
|
+
expect(handler.mock.calls[0][0]).toMatchObject({ code: 'record_lock_conflict' })
|
|
51
|
+
// The OSS conflict bar must NOT render when the dialog handles it.
|
|
52
|
+
expect(getRecordConflictForTest()).toBeNull()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('SAFETY: handler registered but DECLINES the record (returns false) → OSS bar still renders (never swallowed)', () => {
|
|
56
|
+
const handler = jest.fn(() => false)
|
|
57
|
+
registerRecordLockConflictHandler(handler)
|
|
58
|
+
|
|
59
|
+
const handled = surfaceRecordConflict(recordLockConflict(), t)
|
|
60
|
+
|
|
61
|
+
expect(handled).toBe(true)
|
|
62
|
+
expect(handler).toHaveBeenCalledTimes(1)
|
|
63
|
+
expect(getRecordConflictForTest()).not.toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('SAFETY: with NO handler registered, a record_lock_conflict still renders the OSS bar (never swallowed)', () => {
|
|
67
|
+
const handled = surfaceRecordConflict(recordLockConflict(), t)
|
|
68
|
+
expect(handled).toBe(true)
|
|
69
|
+
expect(getRecordConflictForTest()).not.toBeNull()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('renders the OSS conflict bar for an optimistic_lock_conflict regardless of handler registration', () => {
|
|
73
|
+
const handler = jest.fn(() => true)
|
|
74
|
+
registerRecordLockConflictHandler(handler)
|
|
75
|
+
const handled = surfaceRecordConflict(ossConflict(), t)
|
|
76
|
+
expect(handled).toBe(true)
|
|
77
|
+
// The record-lock handler must NOT be consulted for an OSS optimistic-lock conflict.
|
|
78
|
+
expect(handler).not.toHaveBeenCalled()
|
|
79
|
+
const entry = getRecordConflictForTest()
|
|
80
|
+
expect(entry).not.toBeNull()
|
|
81
|
+
expect(entry?.currentUpdatedAt).toBe('2026-06-01T00:00:01.000Z')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('unregister restores the no-handler behavior', () => {
|
|
85
|
+
const handler = jest.fn(() => true)
|
|
86
|
+
const unregister = registerRecordLockConflictHandler(handler)
|
|
87
|
+
unregister()
|
|
88
|
+
|
|
89
|
+
surfaceRecordConflict(recordLockConflict(), t)
|
|
90
|
+
expect(handler).not.toHaveBeenCalled()
|
|
91
|
+
expect(getRecordConflictForTest()).not.toBeNull()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('returns false for a non-conflict error', () => {
|
|
95
|
+
expect(surfaceRecordConflict(new CrudHttpError(422, { error: 'validation_failed' }), t)).toBe(false)
|
|
96
|
+
expect(surfaceRecordConflict(new Error('boom'), t)).toBe(false)
|
|
97
|
+
expect(getRecordConflictForTest()).toBeNull()
|
|
98
|
+
})
|
|
99
|
+
})
|