@open-mercato/ui 0.6.5-develop.4384.1.ce2ec6eaaa → 0.6.5-develop.4397.1.9a65481757
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/dist/backend/CrudForm.js +27 -7
- package/dist/backend/CrudForm.js.map +3 -3
- package/dist/backend/DataTable.js +11 -4
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +4 -0
- package/dist/backend/icons/lucideRegistry.generated.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/package.json +3 -3
- package/src/backend/CrudForm.tsx +33 -7
- package/src/backend/DataTable.tsx +34 -4
- package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +51 -2
- package/src/backend/icons/lucideRegistry.generated.tsx +4 -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
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
subscribeNotificationNew,
|
|
6
6
|
emitNotificationCountChanged
|
|
7
7
|
} from "@open-mercato/shared/lib/frontend/notificationEvents";
|
|
8
|
+
import { useOptionalT } from "@open-mercato/shared/lib/i18n/context";
|
|
8
9
|
import { useAppEvent } from "../injection/useAppEvent.js";
|
|
9
10
|
import {
|
|
10
11
|
dispatchNotificationHandlers,
|
|
@@ -18,6 +19,11 @@ function useNotificationsSse() {
|
|
|
18
19
|
const [isLoading, setIsLoading] = React.useState(true);
|
|
19
20
|
const [error, setError] = React.useState(null);
|
|
20
21
|
const grantedFeaturesRef = React.useRef([]);
|
|
22
|
+
const translate = useOptionalT();
|
|
23
|
+
const translateRef = React.useRef(translate);
|
|
24
|
+
React.useEffect(() => {
|
|
25
|
+
translateRef.current = translate;
|
|
26
|
+
}, [translate]);
|
|
21
27
|
const lastIdRef = React.useRef(null);
|
|
22
28
|
const prevUnreadRef = React.useRef(0);
|
|
23
29
|
const {
|
|
@@ -43,6 +49,7 @@ function useNotificationsSse() {
|
|
|
43
49
|
lastIdRef.current = fetched[0].id;
|
|
44
50
|
dispatchNotificationHandlers(fetched, {
|
|
45
51
|
features: grantedFeaturesRef.current,
|
|
52
|
+
t: translateRef.current,
|
|
46
53
|
currentPath: typeof window === "undefined" ? "/" : `${window.location.pathname}${window.location.search}`,
|
|
47
54
|
refreshNotifications: () => {
|
|
48
55
|
},
|
|
@@ -120,6 +127,7 @@ function useNotificationsSse() {
|
|
|
120
127
|
window.setTimeout(() => setHasNew(false), 3e3);
|
|
121
128
|
dispatchNotificationHandlers([notification], {
|
|
122
129
|
features: grantedFeaturesRef.current,
|
|
130
|
+
t: translateRef.current,
|
|
123
131
|
currentPath: typeof window === "undefined" ? "/" : `${window.location.pathname}${window.location.search}`,
|
|
124
132
|
refreshNotifications: () => {
|
|
125
133
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/notifications/useNotificationsSse.ts"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { apiCall } from '../utils/apiCall'\nimport {\n subscribeNotificationNew,\n emitNotificationCountChanged,\n} from '@open-mercato/shared/lib/frontend/notificationEvents'\nimport type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'\nimport type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'\nimport { useAppEvent } from '../injection/useAppEvent'\nimport {\n dispatchNotificationHandlers,\n getRequiredNotificationHandlerFeatures,\n} from './NotificationDispatcher'\nimport { useNotificationActions } from './useNotificationActions'\n\ntype NotificationCreatedPayload = {\n recipientUserId?: string\n notification?: NotificationDto\n}\n\ntype NotificationBatchCreatedPayload = {\n recipientUserIds?: string[]\n count?: number\n}\n\nexport type UseNotificationsSseResult = {\n notifications: NotificationDto[]\n unreadCount: number\n hasNew: boolean\n isLoading: boolean\n error: string | null\n refresh: () => void\n markAsRead: (id: string) => Promise<void>\n executeAction: (id: string, actionId: string) => Promise<{ href?: string }>\n dismiss: (id: string) => Promise<void>\n dismissUndo: { notification: NotificationDto; previousStatus: 'read' | 'unread' } | null\n undoDismiss: () => Promise<void>\n markAllRead: () => Promise<void>\n}\n\nexport function useNotificationsSse(): UseNotificationsSseResult {\n const [notifications, setNotifications] = React.useState<NotificationDto[]>([])\n const [unreadCount, setUnreadCount] = React.useState(0)\n const [hasNew, setHasNew] = React.useState(false)\n const [isLoading, setIsLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n const grantedFeaturesRef = React.useRef<string[]>([])\n const lastIdRef = React.useRef<string | null>(null)\n const prevUnreadRef = React.useRef(0)\n const {\n markAsRead,\n executeAction,\n dismiss,\n dismissUndo,\n undoDismiss,\n markAllRead,\n markAsReadRef,\n dismissRef,\n } = useNotificationActions(notifications, setNotifications, setUnreadCount)\n\n const fetchNotifications = React.useCallback(async () => {\n try {\n const [notifResult, countResult] = await Promise.all([\n apiCall<{ items: NotificationDto[] }>('/api/notifications?pageSize=50'),\n apiCall<{ unreadCount: number }>('/api/notifications/unread-count'),\n ])\n\n if (notifResult.ok && notifResult.result) {\n const fetched = notifResult.result.items\n setNotifications(fetched)\n if (fetched.length > 0) {\n lastIdRef.current = fetched[0].id\n dispatchNotificationHandlers(fetched, {\n features: grantedFeaturesRef.current,\n currentPath:\n typeof window === 'undefined'\n ? '/'\n : `${window.location.pathname}${window.location.search}`,\n refreshNotifications: () => {\n // No-op: data was just fetched \u2014 avoid redundant refetch loop.\n },\n navigate: (href) => {\n if (typeof window === 'undefined' || !href.startsWith('/')) return\n window.location.assign(href)\n },\n markAsRead: async (notificationId) => markAsReadRef.current(notificationId),\n dismiss: async (notificationId) => dismissRef.current(notificationId),\n })\n }\n }\n\n if (countResult.ok && countResult.result) {\n setUnreadCount(countResult.result.unreadCount)\n }\n\n setError(null)\n } catch (err) {\n setError(err instanceof Error ? err.message : 'Failed to fetch notifications')\n } finally {\n setIsLoading(false)\n }\n }, [])\n\n const refresh = React.useCallback(() => {\n void fetchNotifications()\n }, [fetchNotifications])\n\n React.useEffect(() => {\n let mounted = true\n const run = async () => {\n const requiredFeatures = getRequiredNotificationHandlerFeatures()\n if (requiredFeatures.length === 0) {\n grantedFeaturesRef.current = []\n return\n }\n const response = await apiCall<{ granted?: string[] }>('/api/auth/feature-check', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ features: requiredFeatures }),\n })\n if (!mounted) return\n grantedFeaturesRef.current = response.ok ? (response.result?.granted ?? []) : []\n }\n void run()\n return () => {\n mounted = false\n }\n }, [])\n\n React.useEffect(() => {\n void fetchNotifications()\n }, [fetchNotifications])\n\n React.useEffect(() => {\n const unsub = subscribeNotificationNew(() => refresh())\n return unsub\n }, [refresh])\n\n React.useEffect(() => {\n if (unreadCount === prevUnreadRef.current) return\n prevUnreadRef.current = unreadCount\n emitNotificationCountChanged(unreadCount)\n }, [unreadCount])\n\n useAppEvent(\n 'notifications.notification.created',\n (event: AppEventPayload) => {\n const payload = event.payload as NotificationCreatedPayload\n const notification = payload?.notification\n if (!notification || !notification.id) {\n void fetchNotifications()\n return\n }\n if (notification.id === lastIdRef.current) return\n lastIdRef.current = notification.id\n setNotifications((prev) => [notification, ...prev.filter((item) => item.id !== notification.id)].slice(0, 50))\n if (notification.status === 'unread') {\n setUnreadCount((prev) => prev + 1)\n }\n setHasNew(true)\n window.setTimeout(() => setHasNew(false), 3000)\n dispatchNotificationHandlers([notification], {\n features: grantedFeaturesRef.current,\n currentPath:\n typeof window === 'undefined'\n ? '/'\n : `${window.location.pathname}${window.location.search}`,\n refreshNotifications: () => {\n // No-op: notification was already applied optimistically from the SSE payload.\n // Full refetch is only needed for batch_created, reconnect, or missing payload.\n },\n navigate: (href) => {\n if (typeof window === 'undefined' || !href.startsWith('/')) return\n window.location.assign(href)\n },\n markAsRead: async (notificationId) => markAsReadRef.current(notificationId),\n dismiss: async (notificationId) => dismissRef.current(notificationId),\n })\n },\n [fetchNotifications],\n )\n\n useAppEvent(\n 'notifications.notification.batch_created',\n (event: AppEventPayload) => {\n const payload = event.payload as NotificationBatchCreatedPayload\n if (!payload || typeof payload !== 'object') return\n void fetchNotifications()\n },\n [fetchNotifications],\n )\n\n useAppEvent('om:bridge:reconnected', () => {\n void fetchNotifications()\n }, [fetchNotifications])\n\n React.useEffect(() => {\n const onFocus = () => {\n void fetchNotifications()\n }\n window.addEventListener('focus', onFocus)\n return () => window.removeEventListener('focus', onFocus)\n }, [fetchNotifications])\n\n return {\n notifications,\n unreadCount,\n hasNew,\n isLoading,\n error,\n refresh,\n markAsRead,\n executeAction,\n dismiss,\n dismissUndo,\n undoDismiss,\n markAllRead,\n }\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { apiCall } from '../utils/apiCall'\nimport {\n subscribeNotificationNew,\n emitNotificationCountChanged,\n} from '@open-mercato/shared/lib/frontend/notificationEvents'\nimport type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'\nimport type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'\nimport { useOptionalT } from '@open-mercato/shared/lib/i18n/context'\nimport { useAppEvent } from '../injection/useAppEvent'\nimport {\n dispatchNotificationHandlers,\n getRequiredNotificationHandlerFeatures,\n} from './NotificationDispatcher'\nimport { useNotificationActions } from './useNotificationActions'\n\ntype NotificationCreatedPayload = {\n recipientUserId?: string\n notification?: NotificationDto\n}\n\ntype NotificationBatchCreatedPayload = {\n recipientUserIds?: string[]\n count?: number\n}\n\nexport type UseNotificationsSseResult = {\n notifications: NotificationDto[]\n unreadCount: number\n hasNew: boolean\n isLoading: boolean\n error: string | null\n refresh: () => void\n markAsRead: (id: string) => Promise<void>\n executeAction: (id: string, actionId: string) => Promise<{ href?: string }>\n dismiss: (id: string) => Promise<void>\n dismissUndo: { notification: NotificationDto; previousStatus: 'read' | 'unread' } | null\n undoDismiss: () => Promise<void>\n markAllRead: () => Promise<void>\n}\n\nexport function useNotificationsSse(): UseNotificationsSseResult {\n const [notifications, setNotifications] = React.useState<NotificationDto[]>([])\n const [unreadCount, setUnreadCount] = React.useState(0)\n const [hasNew, setHasNew] = React.useState(false)\n const [isLoading, setIsLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n const grantedFeaturesRef = React.useRef<string[]>([])\n const translate = useOptionalT()\n const translateRef = React.useRef(translate)\n React.useEffect(() => { translateRef.current = translate }, [translate])\n const lastIdRef = React.useRef<string | null>(null)\n const prevUnreadRef = React.useRef(0)\n const {\n markAsRead,\n executeAction,\n dismiss,\n dismissUndo,\n undoDismiss,\n markAllRead,\n markAsReadRef,\n dismissRef,\n } = useNotificationActions(notifications, setNotifications, setUnreadCount)\n\n const fetchNotifications = React.useCallback(async () => {\n try {\n const [notifResult, countResult] = await Promise.all([\n apiCall<{ items: NotificationDto[] }>('/api/notifications?pageSize=50'),\n apiCall<{ unreadCount: number }>('/api/notifications/unread-count'),\n ])\n\n if (notifResult.ok && notifResult.result) {\n const fetched = notifResult.result.items\n setNotifications(fetched)\n if (fetched.length > 0) {\n lastIdRef.current = fetched[0].id\n dispatchNotificationHandlers(fetched, {\n features: grantedFeaturesRef.current,\n t: translateRef.current,\n currentPath:\n typeof window === 'undefined'\n ? '/'\n : `${window.location.pathname}${window.location.search}`,\n refreshNotifications: () => {\n // No-op: data was just fetched \u2014 avoid redundant refetch loop.\n },\n navigate: (href) => {\n if (typeof window === 'undefined' || !href.startsWith('/')) return\n window.location.assign(href)\n },\n markAsRead: async (notificationId) => markAsReadRef.current(notificationId),\n dismiss: async (notificationId) => dismissRef.current(notificationId),\n })\n }\n }\n\n if (countResult.ok && countResult.result) {\n setUnreadCount(countResult.result.unreadCount)\n }\n\n setError(null)\n } catch (err) {\n setError(err instanceof Error ? err.message : 'Failed to fetch notifications')\n } finally {\n setIsLoading(false)\n }\n }, [])\n\n const refresh = React.useCallback(() => {\n void fetchNotifications()\n }, [fetchNotifications])\n\n React.useEffect(() => {\n let mounted = true\n const run = async () => {\n const requiredFeatures = getRequiredNotificationHandlerFeatures()\n if (requiredFeatures.length === 0) {\n grantedFeaturesRef.current = []\n return\n }\n const response = await apiCall<{ granted?: string[] }>('/api/auth/feature-check', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ features: requiredFeatures }),\n })\n if (!mounted) return\n grantedFeaturesRef.current = response.ok ? (response.result?.granted ?? []) : []\n }\n void run()\n return () => {\n mounted = false\n }\n }, [])\n\n React.useEffect(() => {\n void fetchNotifications()\n }, [fetchNotifications])\n\n React.useEffect(() => {\n const unsub = subscribeNotificationNew(() => refresh())\n return unsub\n }, [refresh])\n\n React.useEffect(() => {\n if (unreadCount === prevUnreadRef.current) return\n prevUnreadRef.current = unreadCount\n emitNotificationCountChanged(unreadCount)\n }, [unreadCount])\n\n useAppEvent(\n 'notifications.notification.created',\n (event: AppEventPayload) => {\n const payload = event.payload as NotificationCreatedPayload\n const notification = payload?.notification\n if (!notification || !notification.id) {\n void fetchNotifications()\n return\n }\n if (notification.id === lastIdRef.current) return\n lastIdRef.current = notification.id\n setNotifications((prev) => [notification, ...prev.filter((item) => item.id !== notification.id)].slice(0, 50))\n if (notification.status === 'unread') {\n setUnreadCount((prev) => prev + 1)\n }\n setHasNew(true)\n window.setTimeout(() => setHasNew(false), 3000)\n dispatchNotificationHandlers([notification], {\n features: grantedFeaturesRef.current,\n t: translateRef.current,\n currentPath:\n typeof window === 'undefined'\n ? '/'\n : `${window.location.pathname}${window.location.search}`,\n refreshNotifications: () => {\n // No-op: notification was already applied optimistically from the SSE payload.\n // Full refetch is only needed for batch_created, reconnect, or missing payload.\n },\n navigate: (href) => {\n if (typeof window === 'undefined' || !href.startsWith('/')) return\n window.location.assign(href)\n },\n markAsRead: async (notificationId) => markAsReadRef.current(notificationId),\n dismiss: async (notificationId) => dismissRef.current(notificationId),\n })\n },\n [fetchNotifications],\n )\n\n useAppEvent(\n 'notifications.notification.batch_created',\n (event: AppEventPayload) => {\n const payload = event.payload as NotificationBatchCreatedPayload\n if (!payload || typeof payload !== 'object') return\n void fetchNotifications()\n },\n [fetchNotifications],\n )\n\n useAppEvent('om:bridge:reconnected', () => {\n void fetchNotifications()\n }, [fetchNotifications])\n\n React.useEffect(() => {\n const onFocus = () => {\n void fetchNotifications()\n }\n window.addEventListener('focus', onFocus)\n return () => window.removeEventListener('focus', onFocus)\n }, [fetchNotifications])\n\n return {\n notifications,\n unreadCount,\n hasNew,\n isLoading,\n error,\n refresh,\n markAsRead,\n executeAction,\n dismiss,\n dismissUndo,\n undoDismiss,\n markAllRead,\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
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.5-develop.
|
|
3
|
+
"version": "0.6.5-develop.4397.1.9a65481757",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -145,13 +145,13 @@
|
|
|
145
145
|
"remark-gfm": "^4.0.1"
|
|
146
146
|
},
|
|
147
147
|
"peerDependencies": {
|
|
148
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
148
|
+
"@open-mercato/shared": "0.6.5-develop.4397.1.9a65481757",
|
|
149
149
|
"react": ">=18.0.0",
|
|
150
150
|
"react-dom": ">=18.0.0",
|
|
151
151
|
"react-is": ">=18.0.0"
|
|
152
152
|
},
|
|
153
153
|
"devDependencies": {
|
|
154
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
154
|
+
"@open-mercato/shared": "0.6.5-develop.4397.1.9a65481757",
|
|
155
155
|
"@testing-library/dom": "^10.4.1",
|
|
156
156
|
"@testing-library/jest-dom": "^6.9.1",
|
|
157
157
|
"@testing-library/react": "^16.3.1",
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -451,6 +451,32 @@ function serializeIssuePath(path: ReadonlyArray<string | number | symbol>): stri
|
|
|
451
451
|
return segments.length > 0 ? segments.join('.') : null
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
+
function normalizeDirtySnapshotValue(value: unknown): unknown {
|
|
455
|
+
if (value === undefined || value === null || value === '') return undefined
|
|
456
|
+
if (Array.isArray(value)) {
|
|
457
|
+
const normalized = value
|
|
458
|
+
.map((entry) => normalizeDirtySnapshotValue(entry))
|
|
459
|
+
.filter((entry) => entry !== undefined)
|
|
460
|
+
return normalized.length ? normalized : undefined
|
|
461
|
+
}
|
|
462
|
+
if (typeof value !== 'object') return value
|
|
463
|
+
|
|
464
|
+
const prototype = Object.getPrototypeOf(value)
|
|
465
|
+
if (prototype !== Object.prototype && prototype !== null) return value
|
|
466
|
+
|
|
467
|
+
const normalized: Record<string, unknown> = {}
|
|
468
|
+
const record = value as Record<string, unknown>
|
|
469
|
+
for (const key of Object.keys(record).sort()) {
|
|
470
|
+
const nextValue = normalizeDirtySnapshotValue(record[key])
|
|
471
|
+
if (nextValue !== undefined) normalized[key] = nextValue
|
|
472
|
+
}
|
|
473
|
+
return Object.keys(normalized).length ? normalized : undefined
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function createDirtySnapshot(source: Record<string, unknown>): string {
|
|
477
|
+
return JSON.stringify(normalizeDirtySnapshotValue(source) ?? {})
|
|
478
|
+
}
|
|
479
|
+
|
|
454
480
|
const FIELDSET_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
|
455
481
|
layers: Layers,
|
|
456
482
|
tag: Tag,
|
|
@@ -750,7 +776,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
750
776
|
setHasUnsavedChanges(false)
|
|
751
777
|
return
|
|
752
778
|
}
|
|
753
|
-
const currentSnapshot =
|
|
779
|
+
const currentSnapshot = createDirtySnapshot(values as Record<string, unknown>)
|
|
754
780
|
const dirty = currentSnapshot !== snapshot
|
|
755
781
|
isDirtyRef.current = dirty
|
|
756
782
|
setHasUnsavedChanges(dirty)
|
|
@@ -797,7 +823,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
797
823
|
|
|
798
824
|
const clearDirtyState = React.useCallback((snapshotSource?: Record<string, unknown>) => {
|
|
799
825
|
const source = snapshotSource ?? (valuesRef.current as Record<string, unknown>)
|
|
800
|
-
dirtyBaselineSnapshotRef.current =
|
|
826
|
+
dirtyBaselineSnapshotRef.current = createDirtySnapshot(source)
|
|
801
827
|
isDirtyRef.current = false
|
|
802
828
|
setHasUnsavedChanges(false)
|
|
803
829
|
}, [])
|
|
@@ -842,7 +868,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
842
868
|
if (!target) return
|
|
843
869
|
if (shouldBypassUnsavedChangesGuardRef.current?.(target)) return
|
|
844
870
|
const baselineSnapshot = dirtyBaselineSnapshotRef.current
|
|
845
|
-
if (baselineSnapshot &&
|
|
871
|
+
if (baselineSnapshot && createDirtySnapshot(valuesRef.current as Record<string, unknown>) === baselineSnapshot) {
|
|
846
872
|
isDirtyRef.current = false
|
|
847
873
|
setHasUnsavedChanges(false)
|
|
848
874
|
return
|
|
@@ -874,7 +900,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
874
900
|
return original(data, unused, url)
|
|
875
901
|
}
|
|
876
902
|
const baselineSnapshot = dirtyBaselineSnapshotRef.current
|
|
877
|
-
if (baselineSnapshot &&
|
|
903
|
+
if (baselineSnapshot && createDirtySnapshot(valuesRef.current as Record<string, unknown>) === baselineSnapshot) {
|
|
878
904
|
isDirtyRef.current = false
|
|
879
905
|
setHasUnsavedChanges(false)
|
|
880
906
|
return original(data, unused, url)
|
|
@@ -2177,7 +2203,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2177
2203
|
return mergedValues
|
|
2178
2204
|
})
|
|
2179
2205
|
if (mergedValues) {
|
|
2180
|
-
dirtyBaselineSnapshotRef.current =
|
|
2206
|
+
dirtyBaselineSnapshotRef.current = createDirtySnapshot(mergedValues as Record<string, unknown>)
|
|
2181
2207
|
}
|
|
2182
2208
|
if (!extendedInjectionEventsEnabled || !mergedValues) return
|
|
2183
2209
|
let cancelled = false
|
|
@@ -2190,7 +2216,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2190
2216
|
)
|
|
2191
2217
|
const transformed = result.data
|
|
2192
2218
|
if (cancelled || !transformed) return
|
|
2193
|
-
dirtyBaselineSnapshotRef.current =
|
|
2219
|
+
dirtyBaselineSnapshotRef.current = createDirtySnapshot(transformed as Record<string, unknown>)
|
|
2194
2220
|
setValues(transformed as CrudFormValues<TValues>)
|
|
2195
2221
|
} catch (err) {
|
|
2196
2222
|
console.error('[CrudForm] Error in transformDisplayData:', err)
|
|
@@ -2259,7 +2285,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2259
2285
|
|
|
2260
2286
|
// Update the dirty baseline so the form doesn't appear dirty from defaults
|
|
2261
2287
|
if (mergedValues) {
|
|
2262
|
-
dirtyBaselineSnapshotRef.current =
|
|
2288
|
+
dirtyBaselineSnapshotRef.current = createDirtySnapshot(mergedValues as Record<string, unknown>)
|
|
2263
2289
|
}
|
|
2264
2290
|
}, [isLoading, initialValuesHasId, cfDefinitions, customEntity])
|
|
2265
2291
|
|
|
@@ -250,6 +250,15 @@ export type DataTableProps<T> = {
|
|
|
250
250
|
injectionSpotId?: string
|
|
251
251
|
injectionContext?: Record<string, unknown>
|
|
252
252
|
replacementHandle?: string
|
|
253
|
+
/**
|
|
254
|
+
* Stable id used to derive the `data-table:<id>:*` widget injection spots
|
|
255
|
+
* (`:columns`, `:row-actions`, `:bulk-actions`, `:filters`, `:toolbar`,
|
|
256
|
+
* `:search-trailing`, `:header`, `:footer`) when the host does not need a
|
|
257
|
+
* full perspective config. Cheaper than wiring a perspective just to enable
|
|
258
|
+
* widget injection. Falls back to `perspective?.tableId` and `injectionSpotId`
|
|
259
|
+
* if not provided.
|
|
260
|
+
*/
|
|
261
|
+
extensionTableId?: string
|
|
253
262
|
stickyFirstColumn?: boolean
|
|
254
263
|
stickyActionsColumn?: boolean
|
|
255
264
|
virtualized?: boolean
|
|
@@ -985,6 +994,7 @@ export function DataTable<T>({
|
|
|
985
994
|
injectionSpotId,
|
|
986
995
|
injectionContext,
|
|
987
996
|
replacementHandle,
|
|
997
|
+
extensionTableId: extensionTableIdProp,
|
|
988
998
|
stickyFirstColumn = false,
|
|
989
999
|
stickyActionsColumn = false,
|
|
990
1000
|
virtualized = false,
|
|
@@ -1181,14 +1191,34 @@ export function DataTable<T>({
|
|
|
1181
1191
|
|
|
1182
1192
|
const extensionTableId = React.useMemo(() => {
|
|
1183
1193
|
if (perspective?.tableId) return perspective.tableId
|
|
1194
|
+
if (extensionTableIdProp) return extensionTableIdProp
|
|
1184
1195
|
if (injectionSpotId?.startsWith('data-table:')) return injectionSpotId.slice('data-table:'.length)
|
|
1185
1196
|
return null
|
|
1186
|
-
}, [injectionSpotId, perspective?.tableId])
|
|
1187
|
-
const resolvedInjectionSpotId =
|
|
1197
|
+
}, [injectionSpotId, perspective?.tableId, extensionTableIdProp])
|
|
1198
|
+
const resolvedInjectionSpotId =
|
|
1199
|
+
injectionSpotId
|
|
1200
|
+
?? (perspective?.tableId ? `data-table:${perspective.tableId}` : null)
|
|
1201
|
+
?? (extensionTableIdProp ? `data-table:${extensionTableIdProp}` : null)
|
|
1188
1202
|
const resolvedReplacementHandle = replacementHandle ?? ComponentReplacementHandles.dataTable(extensionTableId ?? 'unknown')
|
|
1189
1203
|
const baseInjectionContext = React.useMemo(
|
|
1190
|
-
() =>
|
|
1191
|
-
|
|
1204
|
+
() => {
|
|
1205
|
+
// R2-M2 / F9 (2026-05-26): the default injection context now derives
|
|
1206
|
+
// `tableId` from `extensionTableId ?? perspective?.tableId` (was
|
|
1207
|
+
// `perspective?.tableId` only). Note `extensionTableId` itself now falls
|
|
1208
|
+
// back to the `data-table:` suffix of `injectionSpotId` (see the memo
|
|
1209
|
+
// above), so a caller passing only `injectionSpotId="data-table:foo"`
|
|
1210
|
+
// (no `injectionContext`/`perspective`) now receives
|
|
1211
|
+
// `context.tableId = "foo"` instead of the previous `null`. This only
|
|
1212
|
+
// populates a field that was null before, so toolbar/header/footer/
|
|
1213
|
+
// search-trailing widgets that read `tableId` get a value while widgets
|
|
1214
|
+
// that ignore it are unaffected. Explicit `injectionContext` from the
|
|
1215
|
+
// caller still wins as-is — preserves the existing public contract.
|
|
1216
|
+
if (injectionContext) return injectionContext
|
|
1217
|
+
const resolvedTableId = extensionTableId ?? perspective?.tableId ?? null
|
|
1218
|
+
const baseTitle = typeof title === 'string' ? title : undefined
|
|
1219
|
+
return { tableId: resolvedTableId, title: baseTitle }
|
|
1220
|
+
},
|
|
1221
|
+
[injectionContext, perspective?.tableId, extensionTableId, title]
|
|
1192
1222
|
)
|
|
1193
1223
|
const headerInjectionSpotId = React.useMemo(
|
|
1194
1224
|
() => (resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:header` : null),
|
|
@@ -3,6 +3,9 @@ jest.setTimeout(15000)
|
|
|
3
3
|
|
|
4
4
|
const pushMock = jest.fn()
|
|
5
5
|
const confirmDialogMock = jest.fn()
|
|
6
|
+
const triggerEventMock = jest.fn()
|
|
7
|
+
let mockInjectionSpotDataChange: (() => unknown) | null = null
|
|
8
|
+
let mockInjectionSpotDataChangeCalled = false
|
|
6
9
|
|
|
7
10
|
jest.mock('next/navigation', () => ({
|
|
8
11
|
useRouter: () => ({ push: pushMock }),
|
|
@@ -19,9 +22,17 @@ jest.mock('../confirm-dialog', () => ({
|
|
|
19
22
|
}))
|
|
20
23
|
jest.mock('../injection/InjectionSpot', () => ({
|
|
21
24
|
__esModule: true,
|
|
22
|
-
InjectionSpot: () =>
|
|
25
|
+
InjectionSpot: ({ onDataChange }: { onDataChange?: (next: unknown) => void }) => {
|
|
26
|
+
const React = require('react') as typeof import('react')
|
|
27
|
+
React.useEffect(() => {
|
|
28
|
+
if (!mockInjectionSpotDataChange || mockInjectionSpotDataChangeCalled) return
|
|
29
|
+
mockInjectionSpotDataChangeCalled = true
|
|
30
|
+
onDataChange?.(mockInjectionSpotDataChange())
|
|
31
|
+
}, [onDataChange])
|
|
32
|
+
return null
|
|
33
|
+
},
|
|
23
34
|
useInjectionWidgets: () => ({ widgets: [], loading: false, error: null }),
|
|
24
|
-
useInjectionSpotEvents: () => ({ triggerEvent:
|
|
35
|
+
useInjectionSpotEvents: () => ({ triggerEvent: triggerEventMock }),
|
|
25
36
|
}))
|
|
26
37
|
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
27
38
|
__esModule: true,
|
|
@@ -40,6 +51,10 @@ describe('CrudForm unsaved navigation guard', () => {
|
|
|
40
51
|
pushMock.mockReset()
|
|
41
52
|
confirmDialogMock.mockReset()
|
|
42
53
|
confirmDialogMock.mockResolvedValue(true)
|
|
54
|
+
triggerEventMock.mockReset()
|
|
55
|
+
triggerEventMock.mockImplementation(async (_event: string, data: Record<string, unknown>) => ({ ok: true, data }))
|
|
56
|
+
mockInjectionSpotDataChange = null
|
|
57
|
+
mockInjectionSpotDataChangeCalled = false
|
|
43
58
|
window.history.replaceState({}, '', '/current')
|
|
44
59
|
})
|
|
45
60
|
|
|
@@ -265,4 +280,38 @@ describe('CrudForm unsaved navigation guard', () => {
|
|
|
265
280
|
|
|
266
281
|
anchor.remove()
|
|
267
282
|
})
|
|
283
|
+
|
|
284
|
+
it('does not prompt when initialization normalizes empty values without user edits', async () => {
|
|
285
|
+
mockInjectionSpotDataChange = () => ({ name: undefined, tags: [], meta: { note: null } })
|
|
286
|
+
|
|
287
|
+
renderWithProviders(
|
|
288
|
+
<CrudForm
|
|
289
|
+
title="Form"
|
|
290
|
+
fields={fields}
|
|
291
|
+
initialValues={{ name: '', tags: [], meta: { note: '' } }}
|
|
292
|
+
injectionSpotId="customers.person"
|
|
293
|
+
onSubmit={() => {}}
|
|
294
|
+
/>,
|
|
295
|
+
{
|
|
296
|
+
dict: {
|
|
297
|
+
'ui.forms.actions.save': 'Save',
|
|
298
|
+
'ui.forms.confirmUnsavedChanges': 'You have unsaved changes. Are you sure you want to leave?',
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
await act(async () => {
|
|
304
|
+
await Promise.resolve()
|
|
305
|
+
await Promise.resolve()
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
await act(async () => {
|
|
309
|
+
window.history.pushState({}, '', '/backend/customers/people')
|
|
310
|
+
await Promise.resolve()
|
|
311
|
+
await Promise.resolve()
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
expect(confirmDialogMock).not.toHaveBeenCalled()
|
|
315
|
+
expect(window.location.pathname).toBe('/backend/customers/people')
|
|
316
|
+
})
|
|
268
317
|
})
|
|
@@ -83,6 +83,7 @@ import {
|
|
|
83
83
|
Mail,
|
|
84
84
|
MailOpen,
|
|
85
85
|
MapPin,
|
|
86
|
+
MessageCircle,
|
|
86
87
|
MinusCircle,
|
|
87
88
|
Notebook,
|
|
88
89
|
Package,
|
|
@@ -98,6 +99,7 @@ import {
|
|
|
98
99
|
Receipt,
|
|
99
100
|
ReceiptText,
|
|
100
101
|
RefreshCcw,
|
|
102
|
+
RefreshCw,
|
|
101
103
|
Reply,
|
|
102
104
|
RotateCcw,
|
|
103
105
|
Ruler,
|
|
@@ -223,6 +225,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
223
225
|
'mail': Mail,
|
|
224
226
|
'mail-open': MailOpen,
|
|
225
227
|
'map-pin': MapPin,
|
|
228
|
+
'message-circle': MessageCircle,
|
|
226
229
|
'minus-circle': MinusCircle,
|
|
227
230
|
'notebook': Notebook,
|
|
228
231
|
'package': Package,
|
|
@@ -238,6 +241,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
238
241
|
'receipt': Receipt,
|
|
239
242
|
'receipt-text': ReceiptText,
|
|
240
243
|
'refresh-ccw': RefreshCcw,
|
|
244
|
+
'refresh-cw': RefreshCw,
|
|
241
245
|
'reply': Reply,
|
|
242
246
|
'rotate-ccw': RotateCcw,
|
|
243
247
|
'ruler': Ruler,
|