@open-mercato/ui 0.6.2-develop.3461.1.605f31c2c9 → 0.6.2
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 +130 -22
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/AiChatSessions.js +73 -3
- package/dist/ai/AiChatSessions.js.map +2 -2
- package/dist/ai/conversation-store.js +149 -0
- package/dist/ai/conversation-store.js.map +7 -0
- package/dist/ai/parts/MutationPreviewCard.js +4 -3
- package/dist/ai/parts/MutationPreviewCard.js.map +2 -2
- package/dist/ai/parts/MutationResultCard.js +9 -16
- package/dist/ai/parts/MutationResultCard.js.map +2 -2
- package/dist/ai/useAiChat.js +80 -4
- package/dist/ai/useAiChat.js.map +2 -2
- package/dist/backend/AuthSessionGuard.js +99 -0
- package/dist/backend/AuthSessionGuard.js.map +7 -0
- package/dist/backend/CrudForm.js +2 -1
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/FilterOverlay.js +1 -1
- package/dist/backend/FilterOverlay.js.map +1 -1
- package/dist/backend/dashboard/widgetRegistry.js +2 -1
- package/dist/backend/dashboard/widgetRegistry.js.map +2 -2
- package/dist/backend/date-range/dateRanges.js +16 -18
- package/dist/backend/date-range/dateRanges.js.map +2 -2
- package/dist/backend/detail/AttachmentMetadataDialog.js +3 -1
- package/dist/backend/detail/AttachmentMetadataDialog.js.map +2 -2
- package/dist/backend/detail/InlineEditors.js +1 -1
- package/dist/backend/detail/InlineEditors.js.map +1 -1
- package/dist/backend/injection/useAppEvent.js +2 -7
- package/dist/backend/injection/useAppEvent.js.map +2 -2
- package/dist/backend/injection/widgetRegistry.js +2 -1
- package/dist/backend/injection/widgetRegistry.js.map +2 -2
- package/dist/backend/messages/useMessageCompose.js +62 -5
- package/dist/backend/messages/useMessageCompose.js.map +2 -2
- package/dist/backend/messages/useMessageComposeOperations.js +64 -5
- package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
- package/dist/backend/schedule/ScheduleCalendar.js +11 -1
- package/dist/backend/schedule/ScheduleCalendar.js.map +2 -2
- package/dist/backend/schedule/ScheduleToolbar.js +11 -1
- package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
- package/dist/primitives/calendar.js +1 -1
- package/dist/primitives/calendar.js.map +1 -1
- package/dist/primitives/date-format.js +1 -1
- package/dist/primitives/date-format.js.map +1 -1
- package/dist/primitives/date-picker-helpers.js +7 -9
- package/dist/primitives/date-picker-helpers.js.map +2 -2
- package/dist/primitives/date-picker.js +1 -1
- package/dist/primitives/date-picker.js.map +2 -2
- package/dist/primitives/date-range-picker.js +1 -1
- package/dist/primitives/date-range-picker.js.map +2 -2
- package/jest.config.cjs +4 -1
- package/package.json +6 -7
- package/src/ai/AiChat.tsx +138 -22
- package/src/ai/AiChatSessions.tsx +89 -9
- package/src/ai/__tests__/AiAssistantLauncher.test.tsx +1 -1
- package/src/ai/__tests__/AiChat.conversation.test.tsx +86 -0
- package/src/ai/__tests__/AiChat.test.tsx +17 -2
- package/src/ai/__tests__/conversation-store.test.ts +29 -0
- package/src/ai/conversation-store.ts +246 -0
- package/src/ai/parts/MutationPreviewCard.tsx +25 -20
- package/src/ai/parts/MutationResultCard.tsx +15 -27
- package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +26 -1
- package/src/ai/parts/__tests__/MutationResultCard.test.tsx +16 -1
- package/src/ai/useAiChat.ts +97 -8
- package/src/backend/AuthSessionGuard.tsx +114 -0
- package/src/backend/CrudForm.tsx +3 -2
- package/src/backend/FilterOverlay.tsx +1 -1
- package/src/backend/__tests__/AppShell.test.tsx +1 -1
- package/src/backend/__tests__/AuthSessionGuard.test.tsx +150 -0
- package/src/backend/__tests__/SidebarCustomizationEditor.test.tsx +8 -1
- package/src/backend/dashboard/widgetRegistry.ts +2 -1
- package/src/backend/date-range/dateRanges.ts +16 -18
- package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
- package/src/backend/detail/InlineEditors.tsx +1 -1
- package/src/backend/injection/__tests__/useAppEvent.test.ts +16 -0
- package/src/backend/injection/useAppEvent.ts +2 -9
- package/src/backend/injection/widgetRegistry.ts +2 -1
- package/src/backend/messages/__tests__/MessageComposer.test.tsx +77 -0
- package/src/backend/messages/useMessageCompose.ts +64 -5
- package/src/backend/messages/useMessageComposeOperations.ts +70 -4
- package/src/backend/schedule/ScheduleCalendar.tsx +11 -1
- package/src/backend/schedule/ScheduleToolbar.tsx +11 -1
- package/src/primitives/calendar.tsx +2 -2
- package/src/primitives/date-format.ts +2 -2
- package/src/primitives/date-picker-helpers.ts +7 -9
- package/src/primitives/date-picker.tsx +2 -2
- package/src/primitives/date-range-picker.tsx +2 -2
|
@@ -122,35 +122,23 @@ export function MutationResultCard(props: MutationResultCardProps) {
|
|
|
122
122
|
</AlertTitle>
|
|
123
123
|
<div className="text-sm leading-relaxed">
|
|
124
124
|
<p>
|
|
125
|
-
{
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
) + `: ${result.commandName}`
|
|
130
|
-
: t(
|
|
131
|
-
'ai_assistant.chat.mutation_cards.result.successBody',
|
|
132
|
-
'The mutation completed successfully.',
|
|
133
|
-
)}
|
|
125
|
+
{t(
|
|
126
|
+
'ai_assistant.chat.mutation_cards.result.successBody',
|
|
127
|
+
'The requested change was saved successfully.',
|
|
128
|
+
)}
|
|
134
129
|
</p>
|
|
135
|
-
{recordId ? (
|
|
130
|
+
{recordId && href ? (
|
|
136
131
|
<p className="mt-1 text-xs">
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
: {recordId}
|
|
148
|
-
</a>
|
|
149
|
-
) : (
|
|
150
|
-
<span className="font-mono" data-ai-mutation-result-record-id>
|
|
151
|
-
{recordId}
|
|
152
|
-
</span>
|
|
153
|
-
)}
|
|
132
|
+
<a
|
|
133
|
+
className="text-primary underline"
|
|
134
|
+
href={href}
|
|
135
|
+
data-ai-mutation-result-link
|
|
136
|
+
>
|
|
137
|
+
{t(
|
|
138
|
+
'ai_assistant.chat.mutation_cards.result.viewRecord',
|
|
139
|
+
'View record',
|
|
140
|
+
)}
|
|
141
|
+
</a>
|
|
154
142
|
</p>
|
|
155
143
|
) : null}
|
|
156
144
|
</div>
|
|
@@ -155,7 +155,7 @@ describe('MutationPreviewCard', () => {
|
|
|
155
155
|
unmount()
|
|
156
156
|
})
|
|
157
157
|
|
|
158
|
-
it('Review details
|
|
158
|
+
it('does not render Review details when the visible single-record diff is already complete', () => {
|
|
159
159
|
installPollingMock(makeAction())
|
|
160
160
|
renderWithProviders(
|
|
161
161
|
<MutationPreviewCard
|
|
@@ -165,6 +165,31 @@ describe('MutationPreviewCard', () => {
|
|
|
165
165
|
{ dict },
|
|
166
166
|
)
|
|
167
167
|
expect(document.querySelector('[data-ai-mutation-preview-details]')).toBeNull()
|
|
168
|
+
expect(screen.queryByRole('button', { name: /Review details/i })).toBeNull()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('Review details toggles the expanded batch diff section', async () => {
|
|
172
|
+
installPollingMock(
|
|
173
|
+
makeAction({
|
|
174
|
+
fieldDiff: [],
|
|
175
|
+
records: [
|
|
176
|
+
{
|
|
177
|
+
recordId: 'r-1',
|
|
178
|
+
entityType: 'customers.person',
|
|
179
|
+
label: 'Alice',
|
|
180
|
+
fieldDiff: [{ field: 'name', before: 'Alice', after: 'Alicia' }],
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
}),
|
|
184
|
+
)
|
|
185
|
+
renderWithProviders(
|
|
186
|
+
<MutationPreviewCard
|
|
187
|
+
componentId="mutation-preview-card"
|
|
188
|
+
pendingActionId="pa-1"
|
|
189
|
+
/>,
|
|
190
|
+
{ dict },
|
|
191
|
+
)
|
|
192
|
+
expect(document.querySelector('[data-ai-mutation-preview-details]')).toBeNull()
|
|
168
193
|
fireEvent.click(screen.getByRole('button', { name: /Review details/i }))
|
|
169
194
|
await waitFor(() => {
|
|
170
195
|
expect(document.querySelector('[data-ai-mutation-preview-details]')).not.toBeNull()
|
|
@@ -16,7 +16,7 @@ import type { AiPendingActionCardAction } from '../types'
|
|
|
16
16
|
|
|
17
17
|
const dict = {
|
|
18
18
|
'ai_assistant.chat.mutation_cards.result.successTitle': 'Action applied',
|
|
19
|
-
'ai_assistant.chat.mutation_cards.result.successBody': 'The
|
|
19
|
+
'ai_assistant.chat.mutation_cards.result.successBody': 'The requested change was saved successfully.',
|
|
20
20
|
'ai_assistant.chat.mutation_cards.result.successWithCommand': 'Completed',
|
|
21
21
|
'ai_assistant.chat.mutation_cards.result.viewRecord': 'View record',
|
|
22
22
|
'ai_assistant.chat.mutation_cards.result.partialTitle': 'Action applied with failures',
|
|
@@ -87,6 +87,21 @@ describe('MutationResultCard', () => {
|
|
|
87
87
|
const link = document.querySelector('[data-ai-mutation-result-link]') as HTMLAnchorElement
|
|
88
88
|
expect(link).not.toBeNull()
|
|
89
89
|
expect(link.getAttribute('href')).toBe('/backend/customers/people/p-1')
|
|
90
|
+
expect(link.textContent).toBe('View record')
|
|
91
|
+
expect(screen.queryByText(/customers\.updatePerson/)).toBeNull()
|
|
92
|
+
expect(screen.queryByText(/p-1/)).toBeNull()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('does not expose internal command names or record ids when no record link exists', () => {
|
|
96
|
+
installPollingMock(baseAction())
|
|
97
|
+
renderWithProviders(
|
|
98
|
+
<MutationResultCard componentId="mutation-result-card" pendingActionId="pa-1" />,
|
|
99
|
+
{ dict },
|
|
100
|
+
)
|
|
101
|
+
expect(screen.getByText('The requested change was saved successfully.')).toBeInTheDocument()
|
|
102
|
+
expect(screen.queryByText(/customers\.updatePerson/)).toBeNull()
|
|
103
|
+
expect(screen.queryByText(/p-1/)).toBeNull()
|
|
104
|
+
expect(document.querySelector('[data-ai-mutation-result-record-id]')).toBeNull()
|
|
90
105
|
})
|
|
91
106
|
|
|
92
107
|
it('renders the partial success variant + list of failed records', () => {
|
package/src/ai/useAiChat.ts
CHANGED
|
@@ -4,6 +4,12 @@ import * as React from 'react'
|
|
|
4
4
|
import { createAiAgentTransport } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/agent-transport'
|
|
5
5
|
import { apiFetch } from '../backend/utils/api'
|
|
6
6
|
import type { LoopTracePanelTrace } from './LoopTracePanel'
|
|
7
|
+
import {
|
|
8
|
+
createAiServerConversation,
|
|
9
|
+
importAiLocalConversation,
|
|
10
|
+
loadAiServerTranscript,
|
|
11
|
+
serverMessageToChatMessage,
|
|
12
|
+
} from './conversation-store'
|
|
7
13
|
|
|
8
14
|
/**
|
|
9
15
|
* Chat message shape used by {@link AiChat}. Kept intentionally minimal so the
|
|
@@ -12,6 +18,7 @@ import type { LoopTracePanelTrace } from './LoopTracePanel'
|
|
|
12
18
|
* shape for `messages`.
|
|
13
19
|
*/
|
|
14
20
|
export interface AiChatMessageFile {
|
|
21
|
+
id?: string
|
|
15
22
|
name: string
|
|
16
23
|
type: string
|
|
17
24
|
previewUrl?: string
|
|
@@ -166,6 +173,7 @@ function makeConversationId(): string {
|
|
|
166
173
|
}
|
|
167
174
|
|
|
168
175
|
const SESSION_STORAGE_PREFIX = 'om-ai-chat:'
|
|
176
|
+
const SESSION_IMPORT_MARKER_PREFIX = 'om-ai-chat-imported:'
|
|
169
177
|
const SESSION_STORAGE_VERSION = 1
|
|
170
178
|
const SESSION_UPDATED_EVENT = 'om-ai-chat-session-updated'
|
|
171
179
|
const SESSION_STREAM_STATE_EVENT = 'om-ai-chat-session-stream-state'
|
|
@@ -247,19 +255,20 @@ function writePersistedSession(
|
|
|
247
255
|
if (typeof window === 'undefined') return
|
|
248
256
|
try {
|
|
249
257
|
// Strip transient blob/object preview URLs before persisting (they would
|
|
250
|
-
// not survive a reload). Self-contained `data:` URLs
|
|
251
|
-
//
|
|
252
|
-
// attachment URLs are intentionally not used because the LLM provider
|
|
253
|
-
// cannot reach a localhost origin and we want a single durable shape
|
|
254
|
-
// that works for both transport and reload.
|
|
258
|
+
// not survive a reload). Self-contained `data:` URLs and same-origin
|
|
259
|
+
// attachment thumbnail URLs are durable enough for local fallback storage.
|
|
255
260
|
const messages = session.messages.map((message) => {
|
|
256
261
|
if (!message.files || message.files.length === 0) return message
|
|
257
|
-
const safeFiles = message.files.map(({ name, type, previewUrl }) => {
|
|
262
|
+
const safeFiles = message.files.map(({ id, name, type, previewUrl }) => {
|
|
258
263
|
const durable =
|
|
259
|
-
typeof previewUrl === 'string' &&
|
|
264
|
+
typeof previewUrl === 'string' &&
|
|
265
|
+
(previewUrl.startsWith('data:') ||
|
|
266
|
+
previewUrl.startsWith('/api/attachments/image/') ||
|
|
267
|
+
previewUrl.startsWith('/api/attachments/file/'))
|
|
260
268
|
? previewUrl
|
|
261
269
|
: undefined
|
|
262
|
-
|
|
270
|
+
const base = id ? { id, name, type } : { name, type }
|
|
271
|
+
return durable ? { ...base, previewUrl: durable } : base
|
|
263
272
|
})
|
|
264
273
|
return { ...message, files: safeFiles }
|
|
265
274
|
})
|
|
@@ -286,6 +295,28 @@ function clearPersistedSession(agent: string, conversationId?: string | null): v
|
|
|
286
295
|
}
|
|
287
296
|
}
|
|
288
297
|
|
|
298
|
+
function getImportMarkerKey(agent: string, conversationId: string): string {
|
|
299
|
+
return `${SESSION_IMPORT_MARKER_PREFIX}${agent}:${conversationId}`
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function hasImportMarker(agent: string, conversationId: string): boolean {
|
|
303
|
+
if (typeof window === 'undefined') return false
|
|
304
|
+
try {
|
|
305
|
+
return window.localStorage.getItem(getImportMarkerKey(agent, conversationId)) === '1'
|
|
306
|
+
} catch {
|
|
307
|
+
return false
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function writeImportMarker(agent: string, conversationId: string): void {
|
|
312
|
+
if (typeof window === 'undefined') return
|
|
313
|
+
try {
|
|
314
|
+
window.localStorage.setItem(getImportMarkerKey(agent, conversationId), '1')
|
|
315
|
+
} catch {
|
|
316
|
+
// ignore local marker failures; the server import remains authoritative
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
289
320
|
function getTransportEndpoint(
|
|
290
321
|
agent: string,
|
|
291
322
|
apiPath?: string,
|
|
@@ -802,14 +833,19 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
802
833
|
// Without this, closing the dock or switching agents while the assistant
|
|
803
834
|
// is "Thinking" abandons the partial reply (issue #1816).
|
|
804
835
|
const latestMessagesRef = React.useRef<AiChatMessage[]>(messages)
|
|
836
|
+
const latestStatusRef = React.useRef<'idle' | 'submitting' | 'streaming'>(status)
|
|
805
837
|
const persistKeyRef = React.useRef<string | null>(null)
|
|
806
838
|
const agentRef = React.useRef<string>(agent)
|
|
839
|
+
const pageContextRef = React.useRef<Record<string, unknown> | undefined>(pageContext)
|
|
807
840
|
const effectiveConversationIdRef = React.useRef<string>(effectiveConversationId)
|
|
808
841
|
const sessionStorageKeyRef = React.useRef<string>(sessionStorageKey)
|
|
809
842
|
const mountedRef = React.useRef(true)
|
|
810
843
|
React.useEffect(() => {
|
|
811
844
|
latestMessagesRef.current = messages
|
|
812
845
|
}, [messages])
|
|
846
|
+
React.useEffect(() => {
|
|
847
|
+
latestStatusRef.current = status
|
|
848
|
+
}, [status])
|
|
813
849
|
React.useEffect(() => {
|
|
814
850
|
persistKeyRef.current =
|
|
815
851
|
typeof conversationIdInput === 'string' && conversationIdInput.length > 0
|
|
@@ -819,6 +855,9 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
819
855
|
React.useEffect(() => {
|
|
820
856
|
agentRef.current = agent
|
|
821
857
|
}, [agent])
|
|
858
|
+
React.useEffect(() => {
|
|
859
|
+
pageContextRef.current = pageContext
|
|
860
|
+
}, [pageContext])
|
|
822
861
|
React.useEffect(() => {
|
|
823
862
|
effectiveConversationIdRef.current = effectiveConversationId
|
|
824
863
|
}, [effectiveConversationId])
|
|
@@ -874,6 +913,53 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
874
913
|
[persistSnapshot],
|
|
875
914
|
)
|
|
876
915
|
|
|
916
|
+
React.useEffect(() => {
|
|
917
|
+
let cancelled = false
|
|
918
|
+
const localCandidate = readPersistedSession(agent, persistKey)
|
|
919
|
+
|
|
920
|
+
async function hydrateFromServer(): Promise<void> {
|
|
921
|
+
const transcript = await loadAiServerTranscript(effectiveConversationId, { limit: 100 })
|
|
922
|
+
if (cancelled || latestStatusRef.current !== 'idle') return
|
|
923
|
+
|
|
924
|
+
if (transcript) {
|
|
925
|
+
const serverMessages = transcript.messages
|
|
926
|
+
.map(serverMessageToChatMessage)
|
|
927
|
+
.filter((message): message is AiChatMessage => message !== null)
|
|
928
|
+
if (serverMessages.length > 0 || !localCandidate || localCandidate.messages.length === 0) {
|
|
929
|
+
updateMessages(serverMessages)
|
|
930
|
+
if (serverMessages.length > 0) persistSnapshot(serverMessages, true)
|
|
931
|
+
return
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (!localCandidate || localCandidate.messages.length === 0) {
|
|
936
|
+
if (!transcript) {
|
|
937
|
+
void createAiServerConversation({
|
|
938
|
+
agentId: agent,
|
|
939
|
+
conversationId: effectiveConversationId,
|
|
940
|
+
pageContext: pageContextRef.current ?? null,
|
|
941
|
+
})
|
|
942
|
+
}
|
|
943
|
+
return
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (hasImportMarker(agent, effectiveConversationId)) return
|
|
947
|
+
const imported = await importAiLocalConversation({
|
|
948
|
+
agentId: agent,
|
|
949
|
+
conversationId: effectiveConversationId,
|
|
950
|
+
pageContext: pageContextRef.current ?? null,
|
|
951
|
+
messages: localCandidate.messages,
|
|
952
|
+
})
|
|
953
|
+
if (cancelled || !imported) return
|
|
954
|
+
writeImportMarker(agent, effectiveConversationId)
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
void hydrateFromServer()
|
|
958
|
+
return () => {
|
|
959
|
+
cancelled = true
|
|
960
|
+
}
|
|
961
|
+
}, [agent, effectiveConversationId, persistKey, persistSnapshot, updateMessages])
|
|
962
|
+
|
|
877
963
|
const setStatus = React.useCallback((next: 'idle' | 'submitting' | 'streaming') => {
|
|
878
964
|
if (mountedRef.current) {
|
|
879
965
|
setStatusInternal(next)
|
|
@@ -961,8 +1047,11 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
961
1047
|
const url = getTransportEndpoint(agent, apiPath, providerOverride, modelOverride)
|
|
962
1048
|
const body = {
|
|
963
1049
|
messages: outgoingHistory.map((message) => ({
|
|
1050
|
+
id: message.id,
|
|
964
1051
|
role: message.role,
|
|
965
1052
|
content: message.content,
|
|
1053
|
+
files: message.files,
|
|
1054
|
+
uiParts: message.uiParts,
|
|
966
1055
|
})),
|
|
967
1056
|
pageContext,
|
|
968
1057
|
attachmentIds,
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { apiCall } from './utils/apiCall'
|
|
4
|
+
|
|
5
|
+
export const AUTH_IDENTITY_STORAGE_KEY = 'om:auth:identity'
|
|
6
|
+
export const AUTH_IDENTITY_BROADCAST_CHANNEL = 'om-auth-identity'
|
|
7
|
+
|
|
8
|
+
type FeatureCheckResponse = {
|
|
9
|
+
ok: boolean
|
|
10
|
+
granted?: string[]
|
|
11
|
+
userId?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type AuthSessionGuardProps = {
|
|
15
|
+
serverUserId: string | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const __reload = {
|
|
19
|
+
fn: (): void => {
|
|
20
|
+
if (typeof window !== 'undefined') window.location.reload()
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function AuthSessionGuard({ serverUserId }: AuthSessionGuardProps) {
|
|
25
|
+
React.useEffect(() => {
|
|
26
|
+
if (typeof window === 'undefined') return
|
|
27
|
+
let cancelled = false
|
|
28
|
+
let reloadScheduled = false
|
|
29
|
+
|
|
30
|
+
const triggerReload = () => {
|
|
31
|
+
if (reloadScheduled) return
|
|
32
|
+
reloadScheduled = true
|
|
33
|
+
__reload.fn()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const checkIdentity = async () => {
|
|
37
|
+
if (cancelled || reloadScheduled) return
|
|
38
|
+
try {
|
|
39
|
+
const res = await apiCall<FeatureCheckResponse>('/api/auth/feature-check', {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: { 'content-type': 'application/json' },
|
|
42
|
+
body: JSON.stringify({ features: [] }),
|
|
43
|
+
cache: 'no-store',
|
|
44
|
+
})
|
|
45
|
+
if (cancelled || reloadScheduled) return
|
|
46
|
+
if (res.status === 401) {
|
|
47
|
+
if (serverUserId) triggerReload()
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
if (!res.ok) return
|
|
51
|
+
const currentUserId = typeof res.result?.userId === 'string' ? res.result.userId : null
|
|
52
|
+
if (!currentUserId) return
|
|
53
|
+
if (currentUserId !== serverUserId) triggerReload()
|
|
54
|
+
} catch {
|
|
55
|
+
// network errors are ignored — next focus/storage event retries
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const onVisibilityOrFocus = () => {
|
|
60
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
|
|
61
|
+
void checkIdentity()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const onStorage = (event: StorageEvent) => {
|
|
65
|
+
if (event.key !== AUTH_IDENTITY_STORAGE_KEY) return
|
|
66
|
+
void checkIdentity()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let broadcastChannel: BroadcastChannel | null = null
|
|
70
|
+
if (typeof BroadcastChannel !== 'undefined') {
|
|
71
|
+
try {
|
|
72
|
+
broadcastChannel = new BroadcastChannel(AUTH_IDENTITY_BROADCAST_CHANNEL)
|
|
73
|
+
broadcastChannel.onmessage = () => { void checkIdentity() }
|
|
74
|
+
} catch {
|
|
75
|
+
broadcastChannel = null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
window.addEventListener('focus', onVisibilityOrFocus)
|
|
80
|
+
document.addEventListener('visibilitychange', onVisibilityOrFocus)
|
|
81
|
+
window.addEventListener('storage', onStorage)
|
|
82
|
+
|
|
83
|
+
return () => {
|
|
84
|
+
cancelled = true
|
|
85
|
+
window.removeEventListener('focus', onVisibilityOrFocus)
|
|
86
|
+
document.removeEventListener('visibilitychange', onVisibilityOrFocus)
|
|
87
|
+
window.removeEventListener('storage', onStorage)
|
|
88
|
+
if (broadcastChannel) {
|
|
89
|
+
broadcastChannel.onmessage = null
|
|
90
|
+
broadcastChannel.close()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}, [serverUserId])
|
|
94
|
+
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function notifyAuthIdentityChange(): void {
|
|
99
|
+
if (typeof window === 'undefined') return
|
|
100
|
+
try {
|
|
101
|
+
if (typeof BroadcastChannel !== 'undefined') {
|
|
102
|
+
const channel = new BroadcastChannel(AUTH_IDENTITY_BROADCAST_CHANNEL)
|
|
103
|
+
channel.postMessage('changed')
|
|
104
|
+
channel.close()
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// ignore — fall back to storage event below
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
window.localStorage.setItem(AUTH_IDENTITY_STORAGE_KEY, String(Date.now()))
|
|
111
|
+
} catch {
|
|
112
|
+
// private mode / quota errors are non-fatal
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -77,8 +77,9 @@ import { buildFormFieldsFromCustomFields, buildFormFieldFromCustomFieldDef } fro
|
|
|
77
77
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
78
78
|
import { TagsInput } from './inputs/TagsInput'
|
|
79
79
|
import { ComboboxInput } from './inputs/ComboboxInput'
|
|
80
|
-
import { format
|
|
81
|
-
import
|
|
80
|
+
import { format } from 'date-fns/format'
|
|
81
|
+
import { parseISO } from 'date-fns/parseISO'
|
|
82
|
+
import type { Locale } from 'date-fns/locale'
|
|
82
83
|
import { DateTimePicker } from './inputs/DateTimePicker'
|
|
83
84
|
import { TimePicker } from './inputs/TimePicker'
|
|
84
85
|
import { DatePicker } from './inputs/DatePicker'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import * as React from 'react'
|
|
3
|
-
import { format } from 'date-fns'
|
|
3
|
+
import { format } from 'date-fns/format'
|
|
4
4
|
import { Button } from '../primitives/button'
|
|
5
5
|
import { Checkbox } from '../primitives/checkbox'
|
|
6
6
|
import { DateRangePicker } from '../primitives/date-range-picker'
|
|
@@ -600,7 +600,7 @@ describe('AppShell', () => {
|
|
|
600
600
|
|
|
601
601
|
await waitFor(() => {
|
|
602
602
|
expect(screen.getByText('Pay Links')).toBeInTheDocument()
|
|
603
|
-
})
|
|
603
|
+
}, { timeout: 10_000 })
|
|
604
604
|
|
|
605
605
|
const link = screen.getByRole('link', { name: 'Pay Links' })
|
|
606
606
|
expect(link.querySelector('svg.lucide-ticket')).toBeTruthy()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { act, render, waitFor } from '@testing-library/react'
|
|
7
|
+
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
8
|
+
import {
|
|
9
|
+
__reload,
|
|
10
|
+
AUTH_IDENTITY_STORAGE_KEY,
|
|
11
|
+
AuthSessionGuard,
|
|
12
|
+
notifyAuthIdentityChange,
|
|
13
|
+
} from '../AuthSessionGuard'
|
|
14
|
+
|
|
15
|
+
jest.mock('@open-mercato/ui/backend/utils/apiCall', () => ({
|
|
16
|
+
apiCall: jest.fn(),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
const apiCallMock = apiCall as jest.MockedFunction<typeof apiCall>
|
|
20
|
+
|
|
21
|
+
const originalReload = __reload.fn
|
|
22
|
+
|
|
23
|
+
function mockLocationReload() {
|
|
24
|
+
const reloadSpy = jest.fn()
|
|
25
|
+
__reload.fn = reloadSpy
|
|
26
|
+
return reloadSpy
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function restoreLocation() {
|
|
30
|
+
__reload.fn = originalReload
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeApiResult(payload: { status?: number; userId?: string | null }) {
|
|
34
|
+
const status = payload.status ?? 200
|
|
35
|
+
return {
|
|
36
|
+
ok: status >= 200 && status < 300,
|
|
37
|
+
status,
|
|
38
|
+
result: payload.userId !== undefined ? { ok: true, granted: [], userId: payload.userId } : null,
|
|
39
|
+
response: { ok: status < 400, status } as unknown as Response,
|
|
40
|
+
cacheStatus: null,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('AuthSessionGuard', () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
apiCallMock.mockReset()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
restoreLocation()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('reloads when the cookie identity differs from the server-rendered identity', async () => {
|
|
54
|
+
const reload = mockLocationReload()
|
|
55
|
+
apiCallMock.mockResolvedValue(makeApiResult({ userId: 'admin-id' }))
|
|
56
|
+
|
|
57
|
+
render(<AuthSessionGuard serverUserId="employee-id" />)
|
|
58
|
+
|
|
59
|
+
await act(async () => {
|
|
60
|
+
window.dispatchEvent(new Event('focus'))
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
await waitFor(() => {
|
|
64
|
+
expect(reload).toHaveBeenCalledTimes(1)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('reloads when the cookie session has been cleared but the page still has a user', async () => {
|
|
69
|
+
const reload = mockLocationReload()
|
|
70
|
+
apiCallMock.mockResolvedValue(makeApiResult({ status: 401 }))
|
|
71
|
+
|
|
72
|
+
render(<AuthSessionGuard serverUserId="employee-id" />)
|
|
73
|
+
|
|
74
|
+
await act(async () => {
|
|
75
|
+
window.dispatchEvent(new Event('focus'))
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
await waitFor(() => {
|
|
79
|
+
expect(reload).toHaveBeenCalledTimes(1)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('does not reload when the cookie identity still matches', async () => {
|
|
84
|
+
const reload = mockLocationReload()
|
|
85
|
+
apiCallMock.mockResolvedValue(makeApiResult({ userId: 'employee-id' }))
|
|
86
|
+
|
|
87
|
+
render(<AuthSessionGuard serverUserId="employee-id" />)
|
|
88
|
+
|
|
89
|
+
await act(async () => {
|
|
90
|
+
window.dispatchEvent(new Event('focus'))
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
await waitFor(() => {
|
|
94
|
+
expect(apiCallMock).toHaveBeenCalled()
|
|
95
|
+
})
|
|
96
|
+
expect(reload).not.toHaveBeenCalled()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('does not poll while the tab is hidden', async () => {
|
|
100
|
+
mockLocationReload()
|
|
101
|
+
apiCallMock.mockResolvedValue(makeApiResult({ userId: 'employee-id' }))
|
|
102
|
+
|
|
103
|
+
Object.defineProperty(document, 'visibilityState', {
|
|
104
|
+
configurable: true,
|
|
105
|
+
get: () => 'hidden',
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
render(<AuthSessionGuard serverUserId="employee-id" />)
|
|
109
|
+
|
|
110
|
+
await act(async () => {
|
|
111
|
+
document.dispatchEvent(new Event('visibilitychange'))
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
expect(apiCallMock).not.toHaveBeenCalled()
|
|
115
|
+
|
|
116
|
+
Object.defineProperty(document, 'visibilityState', {
|
|
117
|
+
configurable: true,
|
|
118
|
+
get: () => 'visible',
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('reacts to cross-tab storage broadcasts', async () => {
|
|
123
|
+
const reload = mockLocationReload()
|
|
124
|
+
apiCallMock.mockResolvedValue(makeApiResult({ userId: 'admin-id' }))
|
|
125
|
+
|
|
126
|
+
render(<AuthSessionGuard serverUserId="employee-id" />)
|
|
127
|
+
|
|
128
|
+
await act(async () => {
|
|
129
|
+
window.dispatchEvent(
|
|
130
|
+
new StorageEvent('storage', {
|
|
131
|
+
key: AUTH_IDENTITY_STORAGE_KEY,
|
|
132
|
+
newValue: String(Date.now()),
|
|
133
|
+
}),
|
|
134
|
+
)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
await waitFor(() => {
|
|
138
|
+
expect(reload).toHaveBeenCalledTimes(1)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('notifyAuthIdentityChange', () => {
|
|
144
|
+
it('writes a sentinel value to localStorage so other tabs receive a storage event', () => {
|
|
145
|
+
const setItemSpy = jest.spyOn(window.localStorage.__proto__, 'setItem')
|
|
146
|
+
notifyAuthIdentityChange()
|
|
147
|
+
expect(setItemSpy).toHaveBeenCalledWith(AUTH_IDENTITY_STORAGE_KEY, expect.any(String))
|
|
148
|
+
setItemSpy.mockRestore()
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -55,6 +55,13 @@ jest.mock('../injection/mutationEvents', () => ({
|
|
|
55
55
|
dispatchBackendMutationError: jest.fn(),
|
|
56
56
|
}))
|
|
57
57
|
|
|
58
|
+
jest.mock('../injection/useGuardedMutation', () => ({
|
|
59
|
+
useGuardedMutation: () => ({
|
|
60
|
+
runMutation: async ({ operation }: { operation: () => Promise<unknown> }) => operation(),
|
|
61
|
+
retryLastMutation: jest.fn(async () => false),
|
|
62
|
+
}),
|
|
63
|
+
}))
|
|
64
|
+
|
|
58
65
|
const fakeGroups = [
|
|
59
66
|
{
|
|
60
67
|
id: 'core',
|
|
@@ -218,7 +225,7 @@ describe('SidebarCustomizationEditor', () => {
|
|
|
218
225
|
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
|
219
226
|
|
|
220
227
|
expect(screen.getAllByText(duplicateMessage)).toHaveLength(1)
|
|
221
|
-
})
|
|
228
|
+
}, 15_000)
|
|
222
229
|
|
|
223
230
|
it('clears the inline dialog error when the user edits the name', async () => {
|
|
224
231
|
const duplicateMessage = 'A variant with this name already exists. Choose a different name.'
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ModuleDashboardWidgetEntry } from '@open-mercato/shared/modules/registry'
|
|
2
2
|
import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
|
|
3
|
+
import { applyDashboardWidgetOverridesToEntries } from '@open-mercato/shared/modules/overrides'
|
|
3
4
|
|
|
4
5
|
type Entry = ModuleDashboardWidgetEntry
|
|
5
6
|
|
|
@@ -10,7 +11,7 @@ export function registerDashboardWidgets(entries: Entry[]) {
|
|
|
10
11
|
if (_dashboardWidgetEntries !== null && process.env.NODE_ENV === 'development') {
|
|
11
12
|
console.debug('[Bootstrap] Dashboard widgets re-registered (this may occur during HMR)')
|
|
12
13
|
}
|
|
13
|
-
_dashboardWidgetEntries = entries
|
|
14
|
+
_dashboardWidgetEntries = applyDashboardWidgetOverridesToEntries(entries)
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export function getDashboardWidgets(): Entry[] {
|
|
@@ -1,21 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
differenceInDays,
|
|
18
|
-
} from 'date-fns'
|
|
1
|
+
import { differenceInDays } from 'date-fns/differenceInDays'
|
|
2
|
+
import { endOfDay } from 'date-fns/endOfDay'
|
|
3
|
+
import { endOfMonth } from 'date-fns/endOfMonth'
|
|
4
|
+
import { endOfQuarter } from 'date-fns/endOfQuarter'
|
|
5
|
+
import { endOfWeek } from 'date-fns/endOfWeek'
|
|
6
|
+
import { endOfYear } from 'date-fns/endOfYear'
|
|
7
|
+
import { startOfDay } from 'date-fns/startOfDay'
|
|
8
|
+
import { startOfMonth } from 'date-fns/startOfMonth'
|
|
9
|
+
import { startOfQuarter } from 'date-fns/startOfQuarter'
|
|
10
|
+
import { startOfWeek } from 'date-fns/startOfWeek'
|
|
11
|
+
import { startOfYear } from 'date-fns/startOfYear'
|
|
12
|
+
import { subDays } from 'date-fns/subDays'
|
|
13
|
+
import { subMonths } from 'date-fns/subMonths'
|
|
14
|
+
import { subQuarters } from 'date-fns/subQuarters'
|
|
15
|
+
import { subWeeks } from 'date-fns/subWeeks'
|
|
16
|
+
import { subYears } from 'date-fns/subYears'
|
|
19
17
|
|
|
20
18
|
export type DateRangePreset =
|
|
21
19
|
| 'today'
|