@open-mercato/ui 0.6.6-develop.6176.1.4507b99c2f → 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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/ai/AiChat.js +1 -28
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/AiChatSessions.js +35 -32
- package/dist/ai/AiChatSessions.js.map +2 -2
- package/dist/ai/modelPickerStorage.js +29 -0
- package/dist/ai/modelPickerStorage.js.map +7 -0
- package/dist/ai/useAiChat.js.map +2 -2
- package/dist/backend/AppShell.js +24 -19
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/DataTable.js +26 -23
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/notifications/useNotificationActions.js +5 -5
- package/dist/backend/notifications/useNotificationActions.js.map +2 -2
- package/dist/backend/progress/ProgressTopBar.js.map +2 -2
- package/package.json +3 -3
- package/src/ai/AiChat.tsx +1 -37
- package/src/ai/AiChatSessions.tsx +54 -48
- package/src/ai/__tests__/AiChat.modelPickerStorage.test.ts +40 -0
- package/src/ai/__tests__/AiChatSessions.storage.test.ts +42 -0
- package/src/ai/__tests__/AiChatSessions.test.tsx +10 -6
- package/src/ai/modelPickerStorage.ts +40 -0
- package/src/ai/useAiChat.ts +7 -0
- package/src/backend/AppShell.tsx +41 -17
- package/src/backend/DataTable.tsx +34 -29
- package/src/backend/__tests__/DataTable.perspectiveStorage.test.ts +44 -0
- package/src/backend/notifications/__tests__/useNotificationActions.failedWrites.test.tsx +119 -0
- package/src/backend/notifications/useNotificationActions.ts +5 -5
- package/src/backend/progress/ProgressTopBar.tsx +4 -0
|
@@ -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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|