@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.
Files changed (31) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/backend/CrudForm.js +27 -7
  3. package/dist/backend/CrudForm.js.map +3 -3
  4. package/dist/backend/DataTable.js +11 -4
  5. package/dist/backend/DataTable.js.map +2 -2
  6. package/dist/backend/icons/lucideRegistry.generated.js +4 -0
  7. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  8. package/dist/backend/messages/EmailThreadsPanel.js +212 -0
  9. package/dist/backend/messages/EmailThreadsPanel.js.map +7 -0
  10. package/dist/backend/messages/index.js +4 -0
  11. package/dist/backend/messages/index.js.map +2 -2
  12. package/dist/backend/messages/mergeOptimisticEmailThreads.js +55 -0
  13. package/dist/backend/messages/mergeOptimisticEmailThreads.js.map +7 -0
  14. package/dist/backend/notifications/NotificationDispatcher.js +1 -0
  15. package/dist/backend/notifications/NotificationDispatcher.js.map +2 -2
  16. package/dist/backend/notifications/useNotificationsPoll.js +7 -0
  17. package/dist/backend/notifications/useNotificationsPoll.js.map +2 -2
  18. package/dist/backend/notifications/useNotificationsSse.js +8 -0
  19. package/dist/backend/notifications/useNotificationsSse.js.map +2 -2
  20. package/package.json +3 -3
  21. package/src/backend/CrudForm.tsx +33 -7
  22. package/src/backend/DataTable.tsx +34 -4
  23. package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +51 -2
  24. package/src/backend/icons/lucideRegistry.generated.tsx +4 -0
  25. package/src/backend/messages/EmailThreadsPanel.tsx +351 -0
  26. package/src/backend/messages/__tests__/mergeOptimisticEmailThreads.test.ts +126 -0
  27. package/src/backend/messages/index.ts +12 -0
  28. package/src/backend/messages/mergeOptimisticEmailThreads.ts +84 -0
  29. package/src/backend/notifications/NotificationDispatcher.ts +2 -0
  30. package/src/backend/notifications/useNotificationsPoll.ts +5 -0
  31. 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.4384.1.ce2ec6eaaa",
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.4384.1.ce2ec6eaaa",
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.4384.1.ce2ec6eaaa",
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",
@@ -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 = JSON.stringify(values)
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 = JSON.stringify(source)
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 && JSON.stringify(valuesRef.current) === 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 && JSON.stringify(valuesRef.current) === 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 = JSON.stringify(mergedValues)
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 = JSON.stringify(transformed as Record<string, unknown>)
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 = JSON.stringify(mergedValues)
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 = injectionSpotId ?? (perspective?.tableId ? `data-table:${perspective.tableId}` : null)
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
- () => injectionContext ?? { tableId: perspective?.tableId ?? null, title: typeof title === 'string' ? title : undefined },
1191
- [injectionContext, perspective?.tableId, title]
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: () => null,
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: jest.fn() }),
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,