@open-mercato/ui 0.6.6-develop.6184.1.b7e55f8d61 → 0.6.6-develop.6198.1.0822f97cc6

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.
@@ -40,6 +40,7 @@ import { PerspectiveSidebar } from './PerspectiveSidebar'
40
40
  import { Popover, PopoverTrigger, PopoverContent } from '../primitives/popover'
41
41
  import { formatWithPublicDateFormat, normalizeDateFormatPattern } from '../primitives/date-format'
42
42
  import { cn } from '@open-mercato/shared/lib/utils'
43
+ import { readVersionedPreference, writeVersionedPreference, clearVersionedPreference } from '@open-mercato/shared/lib/browser/versionedPreference'
43
44
  import { useT } from '@open-mercato/shared/lib/i18n/context'
44
45
  import { flash } from './FlashMessages'
45
46
  import { useConfirmDialog } from './confirm-dialog'
@@ -484,6 +485,19 @@ type PerspectiveSnapshot = {
484
485
  updatedAt: number
485
486
  }
486
487
 
488
+ // Versioned-envelope discriminator for the persisted perspective snapshot. Bump
489
+ // when the snapshot shape changes incompatibly and add a read-old migration
490
+ // branch; see `@open-mercato/shared/lib/browser/versionedPreference`.
491
+ const PERSPECTIVE_SNAPSHOT_VERSION = 1
492
+
493
+ type StoredPerspectiveSnapshot = { perspectiveId?: unknown; settings?: unknown; updatedAt?: unknown }
494
+
495
+ function isStoredPerspectiveSnapshot(value: unknown): value is StoredPerspectiveSnapshot {
496
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
497
+ const settings = (value as Record<string, unknown>).settings
498
+ return typeof settings === 'object' && settings !== null
499
+ }
500
+
487
501
  function readPerspectiveCookie(tableId: string): string | null {
488
502
  if (typeof document === 'undefined') return null
489
503
  const key = `${PERSPECTIVE_COOKIE_PREFIX}:${tableId}`
@@ -500,40 +514,31 @@ function writePerspectiveCookie(tableId: string, perspectiveId: string | null):
500
514
  document.cookie = `${key}=${value}; Path=/; ${expires}; SameSite=Lax`
501
515
  }
502
516
 
503
- function readPerspectiveSnapshot(tableId: string): PerspectiveSnapshot | null {
504
- if (typeof window === 'undefined') return null
505
- try {
506
- const raw = window.localStorage.getItem(`${PERSPECTIVE_STORAGE_PREFIX}:${tableId}`)
507
- if (!raw) return null
508
- const parsed = JSON.parse(raw)
509
- if (!parsed || typeof parsed !== 'object') return null
510
- const perspectiveId =
511
- typeof parsed.perspectiveId === 'string' && parsed.perspectiveId.trim().length > 0
512
- ? parsed.perspectiveId
513
- : null
514
- const settings = typeof parsed.settings === 'object' && parsed.settings !== null
515
- ? parsed.settings as PerspectiveSettings
517
+ export function readPerspectiveSnapshot(tableId: string): PerspectiveSnapshot | null {
518
+ const parsed = readVersionedPreference<StoredPerspectiveSnapshot | null>(
519
+ `${PERSPECTIVE_STORAGE_PREFIX}:${tableId}`,
520
+ PERSPECTIVE_SNAPSHOT_VERSION,
521
+ (value): value is StoredPerspectiveSnapshot | null => isStoredPerspectiveSnapshot(value),
522
+ null,
523
+ { legacyIsValid: (value): value is StoredPerspectiveSnapshot | null => isStoredPerspectiveSnapshot(value) },
524
+ )
525
+ if (!parsed) return null
526
+ const perspectiveId =
527
+ typeof parsed.perspectiveId === 'string' && parsed.perspectiveId.trim().length > 0
528
+ ? parsed.perspectiveId
516
529
  : null
517
- const updatedAt = typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now()
518
- if (!settings) return null
519
- return { perspectiveId, settings, updatedAt }
520
- } catch {
521
- return null
522
- }
530
+ const settings = parsed.settings as PerspectiveSettings
531
+ const updatedAt = typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now()
532
+ return { perspectiveId, settings, updatedAt }
523
533
  }
524
534
 
525
- function writePerspectiveSnapshot(tableId: string, snapshot: PerspectiveSnapshot | null) {
526
- if (typeof window === 'undefined') return
535
+ export function writePerspectiveSnapshot(tableId: string, snapshot: PerspectiveSnapshot | null) {
527
536
  const key = `${PERSPECTIVE_STORAGE_PREFIX}:${tableId}`
528
- try {
529
- if (!snapshot) {
530
- window.localStorage.removeItem(key)
531
- return
532
- }
533
- window.localStorage.setItem(key, JSON.stringify(snapshot))
534
- } catch {
535
- // ignore storage errors
537
+ if (!snapshot) {
538
+ clearVersionedPreference(key)
539
+ return
536
540
  }
541
+ writeVersionedPreference(key, PERSPECTIVE_SNAPSHOT_VERSION, snapshot)
537
542
  }
538
543
 
539
544
  function sanitizePerspectiveSettings(source?: PerspectiveSettings | null): PerspectiveSettings | null {
@@ -0,0 +1,44 @@
1
+ /** @jest-environment jsdom */
2
+ import { readPerspectiveSnapshot, writePerspectiveSnapshot } from '../DataTable'
3
+
4
+ const PREFIX = 'om_table_perspective_snapshot'
5
+
6
+ describe('DataTable perspective snapshot storage (versioned envelope)', () => {
7
+ beforeEach(() => {
8
+ localStorage.clear()
9
+ })
10
+
11
+ it('writes a versioned envelope and reads it back', () => {
12
+ const snapshot = { perspectiveId: 'p1', settings: { columnOrder: ['a', 'b'] }, updatedAt: 123 }
13
+ writePerspectiveSnapshot('tbl', snapshot)
14
+ const stored = JSON.parse(localStorage.getItem(`${PREFIX}:tbl`)!)
15
+ expect(stored.v).toBe(1)
16
+ expect(stored.data).toEqual(snapshot)
17
+ expect(readPerspectiveSnapshot('tbl')).toEqual(snapshot)
18
+ })
19
+
20
+ it('migrates a legacy bare (pre-envelope) snapshot on read', () => {
21
+ const legacy = { perspectiveId: 'p2', settings: { columnOrder: ['x'] }, updatedAt: 7 }
22
+ localStorage.setItem(`${PREFIX}:tbl2`, JSON.stringify(legacy))
23
+ expect(readPerspectiveSnapshot('tbl2')).toEqual(legacy)
24
+ })
25
+
26
+ it('discards a version-mismatched envelope', () => {
27
+ localStorage.setItem(
28
+ `${PREFIX}:tbl3`,
29
+ JSON.stringify({ v: 99, data: { perspectiveId: null, settings: {}, updatedAt: 1 } }),
30
+ )
31
+ expect(readPerspectiveSnapshot('tbl3')).toBeNull()
32
+ })
33
+
34
+ it('returns null for a malformed value (no settings object)', () => {
35
+ localStorage.setItem(`${PREFIX}:tbl4`, JSON.stringify({ foo: 'bar' }))
36
+ expect(readPerspectiveSnapshot('tbl4')).toBeNull()
37
+ })
38
+
39
+ it('clears the slot when writing null', () => {
40
+ writePerspectiveSnapshot('tbl5', { perspectiveId: null, settings: { columnOrder: ['z'] }, updatedAt: 1 })
41
+ writePerspectiveSnapshot('tbl5', null)
42
+ expect(localStorage.getItem(`${PREFIX}:tbl5`)).toBeNull()
43
+ })
44
+ })
@@ -0,0 +1,119 @@
1
+ jest.mock('../../utils/api', () => ({
2
+ apiFetch: jest.fn(),
3
+ }))
4
+
5
+ import { act, renderHook } from '@testing-library/react'
6
+ import * as React from 'react'
7
+ import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
8
+ import { apiFetch } from '../../utils/api'
9
+ import { useNotificationActions } from '../useNotificationActions'
10
+
11
+ function makeNotification(id: string): NotificationDto {
12
+ return {
13
+ id,
14
+ type: 'example.test',
15
+ title: `title-${id}`,
16
+ severity: 'info',
17
+ status: 'unread',
18
+ actions: [],
19
+ createdAt: new Date('2026-01-01T00:00:00.000Z').toISOString(),
20
+ }
21
+ }
22
+
23
+ function mockResponse(status: number): Response {
24
+ const ok = status >= 200 && status < 300
25
+ const body = ok ? '{}' : JSON.stringify({ error: 'boom' })
26
+ return {
27
+ ok,
28
+ status,
29
+ headers: new Map<string, string>(),
30
+ text: jest.fn(async () => body),
31
+ clone: () => mockResponse(status),
32
+ } as unknown as Response
33
+ }
34
+
35
+ function setFetchResult(status: number) {
36
+ ;(apiFetch as jest.Mock).mockResolvedValue(mockResponse(status))
37
+ }
38
+
39
+ function renderActions(notifications: NotificationDto[]) {
40
+ const setNotifications = jest.fn()
41
+ const setUnreadCount = jest.fn()
42
+ const hook = renderHook(() =>
43
+ useNotificationActions(notifications, setNotifications, setUnreadCount),
44
+ )
45
+ return { hook, setNotifications, setUnreadCount }
46
+ }
47
+
48
+ async function callAndCaptureRejection(run: () => Promise<unknown>): Promise<boolean> {
49
+ let rejected = false
50
+ await act(async () => {
51
+ try {
52
+ await run()
53
+ } catch {
54
+ rejected = true
55
+ }
56
+ })
57
+ return rejected
58
+ }
59
+
60
+ describe('useNotificationActions — failed API writes leave local state unchanged', () => {
61
+ beforeEach(() => {
62
+ jest.resetAllMocks()
63
+ })
64
+
65
+ it('markAsRead surfaces the error and does not mutate state when the write fails', async () => {
66
+ setFetchResult(500)
67
+ const { hook, setNotifications, setUnreadCount } = renderActions([makeNotification('n1')])
68
+
69
+ const rejected = await callAndCaptureRejection(() => hook.result.current.markAsRead('n1'))
70
+
71
+ expect(rejected).toBe(true)
72
+ expect(setNotifications).not.toHaveBeenCalled()
73
+ expect(setUnreadCount).not.toHaveBeenCalled()
74
+ })
75
+
76
+ it('dismiss surfaces the error and does not mutate state when the write fails', async () => {
77
+ setFetchResult(500)
78
+ const { hook, setNotifications, setUnreadCount } = renderActions([makeNotification('n1')])
79
+
80
+ const rejected = await callAndCaptureRejection(() => hook.result.current.dismiss('n1'))
81
+
82
+ expect(rejected).toBe(true)
83
+ expect(setNotifications).not.toHaveBeenCalled()
84
+ expect(setUnreadCount).not.toHaveBeenCalled()
85
+ expect(hook.result.current.dismissUndo).toBeNull()
86
+ })
87
+
88
+ it('undoDismiss (restore) surfaces the error and preserves the undo banner when the write fails', async () => {
89
+ const { hook, setNotifications, setUnreadCount } = renderActions([makeNotification('n1')])
90
+
91
+ setFetchResult(200)
92
+ await act(async () => {
93
+ await hook.result.current.dismiss('n1')
94
+ })
95
+ expect(hook.result.current.dismissUndo).not.toBeNull()
96
+
97
+ setNotifications.mockClear()
98
+ setUnreadCount.mockClear()
99
+ setFetchResult(500)
100
+
101
+ const rejected = await callAndCaptureRejection(() => hook.result.current.undoDismiss())
102
+
103
+ expect(rejected).toBe(true)
104
+ expect(setNotifications).not.toHaveBeenCalled()
105
+ expect(setUnreadCount).not.toHaveBeenCalled()
106
+ expect(hook.result.current.dismissUndo).not.toBeNull()
107
+ })
108
+
109
+ it('markAllRead surfaces the error and does not mutate state when the write fails', async () => {
110
+ setFetchResult(500)
111
+ const { hook, setNotifications, setUnreadCount } = renderActions([makeNotification('n1')])
112
+
113
+ const rejected = await callAndCaptureRejection(() => hook.result.current.markAllRead())
114
+
115
+ expect(rejected).toBe(true)
116
+ expect(setNotifications).not.toHaveBeenCalled()
117
+ expect(setUnreadCount).not.toHaveBeenCalled()
118
+ })
119
+ })
@@ -1,6 +1,6 @@
1
1
  "use client"
2
2
  import * as React from 'react'
3
- import { apiCall } from '../utils/apiCall'
3
+ import { apiCall, apiCallOrThrow } from '../utils/apiCall'
4
4
  import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
5
5
 
6
6
  export type NotificationDismissUndoState = {
@@ -32,7 +32,7 @@ export function useNotificationActions(
32
32
  const dismissUndoTimerRef = React.useRef<number | null>(null)
33
33
 
34
34
  const markAsRead = React.useCallback(async (id: string) => {
35
- await apiCall(`/api/notifications/${id}/read`, { method: 'PUT' })
35
+ await apiCallOrThrow(`/api/notifications/${id}/read`, { method: 'PUT' })
36
36
  setNotifications((prev) =>
37
37
  prev.map((n) =>
38
38
  n.id === id ? { ...n, status: 'read', readAt: new Date().toISOString() } : n,
@@ -69,7 +69,7 @@ export function useNotificationActions(
69
69
 
70
70
  const dismiss = React.useCallback(
71
71
  async (id: string) => {
72
- await apiCall(`/api/notifications/${id}/dismiss`, { method: 'PUT' })
72
+ await apiCallOrThrow(`/api/notifications/${id}/dismiss`, { method: 'PUT' })
73
73
  const notification = notifications.find((n) => n.id === id)
74
74
  setNotifications((prev) => prev.filter((n) => n.id !== id))
75
75
  if (notification?.status === 'unread') {
@@ -95,7 +95,7 @@ export function useNotificationActions(
95
95
 
96
96
  const undoDismiss = React.useCallback(async () => {
97
97
  if (!dismissUndo) return
98
- await apiCall(`/api/notifications/${dismissUndo.notification.id}/restore`, {
98
+ await apiCallOrThrow(`/api/notifications/${dismissUndo.notification.id}/restore`, {
99
99
  method: 'PUT',
100
100
  headers: { 'content-type': 'application/json' },
101
101
  body: JSON.stringify({ status: dismissUndo.previousStatus }),
@@ -129,7 +129,7 @@ export function useNotificationActions(
129
129
  }, [dismissUndo, setNotifications, setUnreadCount])
130
130
 
131
131
  const markAllRead = React.useCallback(async () => {
132
- await apiCall('/api/notifications/mark-all-read', { method: 'PUT' })
132
+ await apiCallOrThrow('/api/notifications/mark-all-read', { method: 'PUT' })
133
133
  setNotifications((prev) =>
134
134
  prev.map((n) =>
135
135
  n.status === 'unread'
@@ -26,6 +26,10 @@ export function ProgressTopBar({ className, t, completedAutoHideMs }: ProgressTo
26
26
  const visibleCompleted = useAutoHideCompletedJobs(recentlyCompleted, completedAutoHideMs)
27
27
  const [expanded, setExpanded] = React.useState(false)
28
28
 
29
+ // `om:progress:expanded` is a trivial scalar flag ('true' | 'false') with no
30
+ // schema to evolve, so it is intentionally kept raw rather than wrapped in a
31
+ // versioned envelope (the versioning threshold for structured persisted state
32
+ // lives in `@open-mercato/shared/lib/browser/versionedPreference`).
29
33
  React.useEffect(() => {
30
34
  const saved = localStorage.getItem('om:progress:expanded')
31
35
  if (saved === 'true') setExpanded(true)