@open-mercato/ui 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3045.b4b3320cc2
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/AGENTS.md +2 -1
- package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +204 -0
- package/dist/ai/AiAssistantLauncher.js +596 -0
- package/dist/ai/AiAssistantLauncher.js.map +7 -0
- package/dist/ai/AiChat.js +1092 -0
- package/dist/ai/AiChat.js.map +7 -0
- package/dist/ai/AiChatSessions.js +297 -0
- package/dist/ai/AiChatSessions.js.map +7 -0
- package/dist/ai/AiDock.js +347 -0
- package/dist/ai/AiDock.js.map +7 -0
- package/dist/ai/AiMessageContent.js +369 -0
- package/dist/ai/AiMessageContent.js.map +7 -0
- package/dist/ai/ChatPaneTabs.js +251 -0
- package/dist/ai/ChatPaneTabs.js.map +7 -0
- package/dist/ai/index.js +115 -0
- package/dist/ai/index.js.map +7 -0
- package/dist/ai/parts/ConfirmationCard.js +211 -0
- package/dist/ai/parts/ConfirmationCard.js.map +7 -0
- package/dist/ai/parts/FieldDiffCard.js +119 -0
- package/dist/ai/parts/FieldDiffCard.js.map +7 -0
- package/dist/ai/parts/MutationPreviewCard.js +224 -0
- package/dist/ai/parts/MutationPreviewCard.js.map +7 -0
- package/dist/ai/parts/MutationResultCard.js +240 -0
- package/dist/ai/parts/MutationResultCard.js.map +7 -0
- package/dist/ai/parts/approval-cards-map.js +15 -0
- package/dist/ai/parts/approval-cards-map.js.map +7 -0
- package/dist/ai/parts/index.js +24 -0
- package/dist/ai/parts/index.js.map +7 -0
- package/dist/ai/parts/pending-action-api.js +60 -0
- package/dist/ai/parts/pending-action-api.js.map +7 -0
- package/dist/ai/parts/types.js +1 -0
- package/dist/ai/parts/types.js.map +7 -0
- package/dist/ai/parts/useAiPendingActionPolling.js +126 -0
- package/dist/ai/parts/useAiPendingActionPolling.js.map +7 -0
- package/dist/ai/records/ActivityCard.js +83 -0
- package/dist/ai/records/ActivityCard.js.map +7 -0
- package/dist/ai/records/CompanyCard.js +81 -0
- package/dist/ai/records/CompanyCard.js.map +7 -0
- package/dist/ai/records/DealCard.js +76 -0
- package/dist/ai/records/DealCard.js.map +7 -0
- package/dist/ai/records/PersonCard.js +68 -0
- package/dist/ai/records/PersonCard.js.map +7 -0
- package/dist/ai/records/ProductCard.js +68 -0
- package/dist/ai/records/ProductCard.js.map +7 -0
- package/dist/ai/records/RecordCard.js +29 -0
- package/dist/ai/records/RecordCard.js.map +7 -0
- package/dist/ai/records/RecordCardShell.js +103 -0
- package/dist/ai/records/RecordCardShell.js.map +7 -0
- package/dist/ai/records/index.js +31 -0
- package/dist/ai/records/index.js.map +7 -0
- package/dist/ai/records/registry.js +51 -0
- package/dist/ai/records/registry.js.map +7 -0
- package/dist/ai/records/types.js +1 -0
- package/dist/ai/records/types.js.map +7 -0
- package/dist/ai/ui-part-registry.js +112 -0
- package/dist/ai/ui-part-registry.js.map +7 -0
- package/dist/ai/ui-part-slots.js +14 -0
- package/dist/ai/ui-part-slots.js.map +7 -0
- package/dist/ai/ui-parts/pending-phase3-placeholder.js +35 -0
- package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +7 -0
- package/dist/ai/upload-adapter.js +256 -0
- package/dist/ai/upload-adapter.js.map +7 -0
- package/dist/ai/useAiChat.js +549 -0
- package/dist/ai/useAiChat.js.map +7 -0
- package/dist/ai/useAiChatUpload.js +127 -0
- package/dist/ai/useAiChatUpload.js.map +7 -0
- package/dist/ai/useAiShortcuts.js +43 -0
- package/dist/ai/useAiShortcuts.js.map +7 -0
- package/dist/backend/AppShell.js +8 -4
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/BackendChromeProvider.js +2 -0
- package/dist/backend/BackendChromeProvider.js.map +2 -2
- package/dist/backend/DataTable.js +19 -2
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/FilterBar.js +19 -15
- package/dist/backend/FilterBar.js.map +2 -2
- package/dist/backend/dashboard/DashboardScreen.js +31 -3
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/dist/backend/injection/spotIds.js +6 -0
- package/dist/backend/injection/spotIds.js.map +2 -2
- package/dist/backend/notifications/useNotificationEffect.js +38 -2
- package/dist/backend/notifications/useNotificationEffect.js.map +2 -2
- package/dist/index.js +1 -0
- package/dist/index.js.map +2 -2
- package/jest.config.cjs +7 -1
- package/jest.markdown-mock.tsx +7 -0
- package/package.json +10 -4
- package/src/ai/AiAssistantLauncher.tsx +805 -0
- package/src/ai/AiChat.tsx +1483 -0
- package/src/ai/AiChatSessions.tsx +429 -0
- package/src/ai/AiDock.tsx +505 -0
- package/src/ai/AiMessageContent.tsx +515 -0
- package/src/ai/ChatPaneTabs.tsx +310 -0
- package/src/ai/__tests__/AiChat.conversation.test.tsx +160 -0
- package/src/ai/__tests__/AiChat.debug.test.tsx +152 -0
- package/src/ai/__tests__/AiChat.registry.test.tsx +213 -0
- package/src/ai/__tests__/AiChat.test.tsx +257 -0
- package/src/ai/__tests__/AiDock.test.tsx +124 -0
- package/src/ai/__tests__/AiMessageContent.test.ts +111 -0
- package/src/ai/__tests__/ui-part-registry.test.ts +199 -0
- package/src/ai/__tests__/ui-part-slots.test.ts +43 -0
- package/src/ai/__tests__/upload-adapter.test.ts +213 -0
- package/src/ai/__tests__/useAiChatUpload.test.tsx +163 -0
- package/src/ai/__tests__/useAiShortcuts.test.tsx +100 -0
- package/src/ai/index.ts +125 -0
- package/src/ai/parts/ConfirmationCard.tsx +310 -0
- package/src/ai/parts/FieldDiffCard.tsx +173 -0
- package/src/ai/parts/MutationPreviewCard.tsx +302 -0
- package/src/ai/parts/MutationResultCard.tsx +360 -0
- package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +169 -0
- package/src/ai/parts/__tests__/FieldDiffCard.test.tsx +74 -0
- package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +177 -0
- package/src/ai/parts/__tests__/MutationResultCard.test.tsx +127 -0
- package/src/ai/parts/__tests__/useAiPendingActionPolling.test.tsx +151 -0
- package/src/ai/parts/approval-cards-map.ts +24 -0
- package/src/ai/parts/index.ts +27 -0
- package/src/ai/parts/pending-action-api.ts +123 -0
- package/src/ai/parts/types.ts +84 -0
- package/src/ai/parts/useAiPendingActionPolling.ts +210 -0
- package/src/ai/records/ActivityCard.tsx +102 -0
- package/src/ai/records/CompanyCard.tsx +89 -0
- package/src/ai/records/DealCard.tsx +85 -0
- package/src/ai/records/PersonCard.tsx +77 -0
- package/src/ai/records/ProductCard.tsx +83 -0
- package/src/ai/records/RecordCard.tsx +37 -0
- package/src/ai/records/RecordCardShell.tsx +169 -0
- package/src/ai/records/index.ts +30 -0
- package/src/ai/records/registry.tsx +80 -0
- package/src/ai/records/types.ts +90 -0
- package/src/ai/ui-part-registry.ts +233 -0
- package/src/ai/ui-part-slots.ts +32 -0
- package/src/ai/ui-parts/pending-phase3-placeholder.tsx +50 -0
- package/src/ai/upload-adapter.ts +421 -0
- package/src/ai/useAiChat.ts +865 -0
- package/src/ai/useAiChatUpload.ts +180 -0
- package/src/ai/useAiShortcuts.ts +79 -0
- package/src/backend/AppShell.tsx +12 -5
- package/src/backend/BackendChromeProvider.tsx +2 -0
- package/src/backend/DataTable.tsx +20 -1
- package/src/backend/FilterBar.tsx +26 -13
- package/src/backend/__tests__/BackendChromeProvider.test.tsx +45 -0
- package/src/backend/dashboard/DashboardScreen.tsx +38 -3
- package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +24 -1
- package/src/backend/injection/spotIds.ts +6 -0
- package/src/backend/notifications/__tests__/useNotificationEffect.test.tsx +77 -0
- package/src/backend/notifications/useNotificationEffect.ts +47 -2
- package/src/index.ts +1 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import {
|
|
5
|
+
uploadAttachmentsForChat,
|
|
6
|
+
type UploadAttachmentsForChatOptions,
|
|
7
|
+
type UploadAttachmentsForChatResult,
|
|
8
|
+
type UploadFailureReason,
|
|
9
|
+
} from './upload-adapter'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* React hook wrapping {@link uploadAttachmentsForChat} with per-file state so
|
|
13
|
+
* the {@link AiChat} composer can render progress chips, error badges, and a
|
|
14
|
+
* Clear action without each consumer re-implementing the machinery.
|
|
15
|
+
*
|
|
16
|
+
* The hook is DS-neutral: it exposes only state and {@link UploadFailureReason}
|
|
17
|
+
* codes. Consumers translate user-facing strings through `useT()` at render
|
|
18
|
+
* time — no hard-coded copy in the hook.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface UseAiChatUploadOptions extends UploadAttachmentsForChatOptions {
|
|
22
|
+
/** Identical to the adapter options; forwarded verbatim. */
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type AiChatUploadFileStatus = 'queued' | 'uploading' | 'done' | 'error'
|
|
26
|
+
|
|
27
|
+
export interface AiChatUploadFileState {
|
|
28
|
+
fileName: string
|
|
29
|
+
size: number
|
|
30
|
+
progress: number
|
|
31
|
+
status: AiChatUploadFileStatus
|
|
32
|
+
attachmentId?: string
|
|
33
|
+
reason?: UploadFailureReason
|
|
34
|
+
error?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UseAiChatUploadState {
|
|
38
|
+
files: AiChatUploadFileState[]
|
|
39
|
+
overallProgress: number
|
|
40
|
+
busy: boolean
|
|
41
|
+
upload: (files: File[]) => Promise<UploadAttachmentsForChatResult>
|
|
42
|
+
reset: () => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const EMPTY_STATE: AiChatUploadFileState[] = []
|
|
46
|
+
|
|
47
|
+
function computeOverallProgress(entries: AiChatUploadFileState[]): number {
|
|
48
|
+
if (entries.length === 0) return 0
|
|
49
|
+
const total = entries.reduce((sum, entry) => sum + entry.progress, 0)
|
|
50
|
+
const average = total / entries.length
|
|
51
|
+
if (!Number.isFinite(average)) return 0
|
|
52
|
+
if (average < 0) return 0
|
|
53
|
+
if (average > 1) return 1
|
|
54
|
+
return average
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function useAiChatUpload(
|
|
58
|
+
options: UseAiChatUploadOptions = {},
|
|
59
|
+
): UseAiChatUploadState {
|
|
60
|
+
const [files, setFiles] = React.useState<AiChatUploadFileState[]>(EMPTY_STATE)
|
|
61
|
+
const [busy, setBusy] = React.useState(false)
|
|
62
|
+
const optionsRef = React.useRef(options)
|
|
63
|
+
React.useEffect(() => {
|
|
64
|
+
optionsRef.current = options
|
|
65
|
+
}, [options])
|
|
66
|
+
|
|
67
|
+
const overallProgress = React.useMemo(() => computeOverallProgress(files), [files])
|
|
68
|
+
|
|
69
|
+
const reset = React.useCallback(() => {
|
|
70
|
+
setFiles(EMPTY_STATE)
|
|
71
|
+
setBusy(false)
|
|
72
|
+
}, [])
|
|
73
|
+
|
|
74
|
+
const upload = React.useCallback(
|
|
75
|
+
async (incoming: File[]): Promise<UploadAttachmentsForChatResult> => {
|
|
76
|
+
if (!incoming || incoming.length === 0) {
|
|
77
|
+
return { items: [], failed: [] }
|
|
78
|
+
}
|
|
79
|
+
const initialEntries: AiChatUploadFileState[] = incoming.map((file) => ({
|
|
80
|
+
fileName: file.name,
|
|
81
|
+
size: file.size,
|
|
82
|
+
progress: 0,
|
|
83
|
+
status: 'uploading',
|
|
84
|
+
}))
|
|
85
|
+
setFiles(initialEntries)
|
|
86
|
+
setBusy(true)
|
|
87
|
+
|
|
88
|
+
const callerOptions = optionsRef.current
|
|
89
|
+
const callerProgress = callerOptions.onProgress
|
|
90
|
+
const result = await uploadAttachmentsForChat(incoming, {
|
|
91
|
+
...callerOptions,
|
|
92
|
+
onProgress: (fileIndex, progress) => {
|
|
93
|
+
const ratio =
|
|
94
|
+
progress.total > 0
|
|
95
|
+
? Math.max(0, Math.min(1, progress.loaded / progress.total))
|
|
96
|
+
: 0
|
|
97
|
+
setFiles((current) => {
|
|
98
|
+
if (fileIndex < 0 || fileIndex >= current.length) return current
|
|
99
|
+
const next = current.slice()
|
|
100
|
+
const entry = next[fileIndex]
|
|
101
|
+
if (!entry) return current
|
|
102
|
+
next[fileIndex] = { ...entry, progress: ratio }
|
|
103
|
+
return next
|
|
104
|
+
})
|
|
105
|
+
if (callerProgress) {
|
|
106
|
+
try {
|
|
107
|
+
callerProgress(fileIndex, progress)
|
|
108
|
+
} catch {
|
|
109
|
+
// Consumer-supplied callbacks must never abort state updates.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
}).catch((err) => {
|
|
114
|
+
// uploadAttachmentsForChat only rejects on programming errors; coerce
|
|
115
|
+
// to a failure envelope so the hook state never throws at consumers.
|
|
116
|
+
const message = err instanceof Error ? err.message : 'Upload batch failed.'
|
|
117
|
+
return {
|
|
118
|
+
items: [],
|
|
119
|
+
failed: incoming.map((file, inputIndex) => ({
|
|
120
|
+
fileName: file.name,
|
|
121
|
+
originalFileName: file.name,
|
|
122
|
+
inputIndex,
|
|
123
|
+
reason: 'network' as UploadFailureReason,
|
|
124
|
+
message,
|
|
125
|
+
})),
|
|
126
|
+
} satisfies UploadAttachmentsForChatResult
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
setFiles((current) => {
|
|
130
|
+
const failedByName = new Map<string, typeof result.failed[number]>()
|
|
131
|
+
for (const failure of result.failed) {
|
|
132
|
+
if (!failedByName.has(failure.fileName)) {
|
|
133
|
+
failedByName.set(failure.fileName, failure)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return current.map((entry, index) => {
|
|
137
|
+
const success = result.items.find(
|
|
138
|
+
(item, itemIndex) => itemIndex === index && item.fileName === entry.fileName,
|
|
139
|
+
)
|
|
140
|
+
if (success) {
|
|
141
|
+
return {
|
|
142
|
+
...entry,
|
|
143
|
+
progress: 1,
|
|
144
|
+
status: 'done' as AiChatUploadFileStatus,
|
|
145
|
+
attachmentId: success.attachmentId,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const failure = failedByName.get(entry.fileName)
|
|
149
|
+
if (failure) {
|
|
150
|
+
failedByName.delete(entry.fileName)
|
|
151
|
+
return {
|
|
152
|
+
...entry,
|
|
153
|
+
status: 'error' as AiChatUploadFileStatus,
|
|
154
|
+
reason: failure.reason,
|
|
155
|
+
error: failure.message,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Defensive: a worker exited without producing either outcome.
|
|
159
|
+
return {
|
|
160
|
+
...entry,
|
|
161
|
+
status: 'error' as AiChatUploadFileStatus,
|
|
162
|
+
reason: 'network' as UploadFailureReason,
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
setBusy(false)
|
|
168
|
+
return result
|
|
169
|
+
},
|
|
170
|
+
[],
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
files,
|
|
175
|
+
overallProgress,
|
|
176
|
+
busy,
|
|
177
|
+
upload,
|
|
178
|
+
reset,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared keyboard-shortcut hook for the AI surfaces shipped in Phase 2
|
|
7
|
+
* (Step 4.6 / Phase 2 WS-B polish). Centralises the `Cmd/Ctrl+Enter` and
|
|
8
|
+
* `Escape` handling used by `<AiChat>`, the AI playground, and the agent
|
|
9
|
+
* settings page so every surface honours the same shortcuts without each
|
|
10
|
+
* page rolling its own listener.
|
|
11
|
+
*
|
|
12
|
+
* - `onSubmit` fires on `Enter` (without `Shift`) when the shortcut is
|
|
13
|
+
* triggered while focus is inside the bound element. `Shift+Enter` is
|
|
14
|
+
* left to the browser for native newline insertion.
|
|
15
|
+
* - `onCancel` fires on `Escape`. Callers decide what cancel means (abort an
|
|
16
|
+
* in-flight stream, blur the composer, close a drawer, reset a draft).
|
|
17
|
+
* - `enabled` gates the hook for conditional bindings without unmounting.
|
|
18
|
+
*
|
|
19
|
+
* The hook is deliberately minimal. It never stops propagation; callers that
|
|
20
|
+
* embed modal dialogs keep their own Escape handling because React events
|
|
21
|
+
* bubble predictably.
|
|
22
|
+
*/
|
|
23
|
+
export interface UseAiShortcutsOptions {
|
|
24
|
+
onSubmit?: () => void
|
|
25
|
+
onCancel?: () => void
|
|
26
|
+
enabled?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UseAiShortcutsResult {
|
|
30
|
+
/**
|
|
31
|
+
* Keyboard handler ready to be attached via `onKeyDown`. Returns `true`
|
|
32
|
+
* when the event matched a shortcut so callers can branch on the result.
|
|
33
|
+
*/
|
|
34
|
+
handleKeyDown: (event: React.KeyboardEvent) => boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useAiShortcuts(options: UseAiShortcutsOptions): UseAiShortcutsResult {
|
|
38
|
+
const { onSubmit, onCancel, enabled = true } = options
|
|
39
|
+
|
|
40
|
+
const onSubmitRef = React.useRef(onSubmit)
|
|
41
|
+
const onCancelRef = React.useRef(onCancel)
|
|
42
|
+
React.useEffect(() => {
|
|
43
|
+
onSubmitRef.current = onSubmit
|
|
44
|
+
}, [onSubmit])
|
|
45
|
+
React.useEffect(() => {
|
|
46
|
+
onCancelRef.current = onCancel
|
|
47
|
+
}, [onCancel])
|
|
48
|
+
|
|
49
|
+
const handleKeyDown = React.useCallback<UseAiShortcutsResult['handleKeyDown']>(
|
|
50
|
+
(event) => {
|
|
51
|
+
if (!enabled) return false
|
|
52
|
+
// Enter — primary submit. Shift+Enter inserts a newline instead.
|
|
53
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
54
|
+
if (onSubmitRef.current) {
|
|
55
|
+
event.preventDefault()
|
|
56
|
+
onSubmitRef.current()
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
// Escape — secondary cancel. Never swallow unless a handler is bound so
|
|
62
|
+
// parent dialogs can still handle Escape the native way.
|
|
63
|
+
if (event.key === 'Escape') {
|
|
64
|
+
if (onCancelRef.current) {
|
|
65
|
+
event.preventDefault()
|
|
66
|
+
onCancelRef.current()
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
return false
|
|
72
|
+
},
|
|
73
|
+
[enabled],
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return { handleKeyDown }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export default useAiShortcuts
|
package/src/backend/AppShell.tsx
CHANGED
|
@@ -30,6 +30,9 @@ import { resolveInjectedIcon } from './injection/resolveInjectedIcon'
|
|
|
30
30
|
import { useEventBridge } from './injection/eventBridge'
|
|
31
31
|
import { StatusBadgeInjectionSpot } from './injection/StatusBadgeInjectionSpot'
|
|
32
32
|
import { UmesDevToolsPanel } from './devtools'
|
|
33
|
+
import { AiDockProvider } from '../ai/AiDock'
|
|
34
|
+
import { AiChatSessionsProvider } from '../ai/AiChatSessions'
|
|
35
|
+
import { AiAssistantLauncher } from '../ai/AiAssistantLauncher'
|
|
33
36
|
import { BackendChromeProvider, useBackendChrome } from './BackendChromeProvider'
|
|
34
37
|
import {
|
|
35
38
|
BACKEND_LAYOUT_FOOTER_INJECTION_SPOT_ID,
|
|
@@ -393,7 +396,11 @@ export function AppShell(props: AppShellProps) {
|
|
|
393
396
|
return (
|
|
394
397
|
<QueryProvider>
|
|
395
398
|
<BackendChromeProvider adminNavApi={props.adminNavApi}>
|
|
396
|
-
<
|
|
399
|
+
<AiChatSessionsProvider>
|
|
400
|
+
<AiDockProvider>
|
|
401
|
+
<AppShellBody {...props} />
|
|
402
|
+
</AiDockProvider>
|
|
403
|
+
</AiChatSessionsProvider>
|
|
397
404
|
</BackendChromeProvider>
|
|
398
405
|
</QueryProvider>
|
|
399
406
|
)
|
|
@@ -464,11 +471,11 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
|
|
|
464
471
|
}
|
|
465
472
|
update()
|
|
466
473
|
target.addEventListener('scroll', update, { passive: true })
|
|
467
|
-
const ro = new ResizeObserver(update)
|
|
468
|
-
ro
|
|
474
|
+
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : null
|
|
475
|
+
ro?.observe(target)
|
|
469
476
|
return () => {
|
|
470
477
|
target.removeEventListener('scroll', update)
|
|
471
|
-
ro
|
|
478
|
+
ro?.disconnect()
|
|
472
479
|
}
|
|
473
480
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
474
481
|
}, [pathname, effectiveCollapsed])
|
|
@@ -1229,6 +1236,7 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
|
|
|
1229
1236
|
context={injectionContext}
|
|
1230
1237
|
/>
|
|
1231
1238
|
{renderedTopbarInjectedActions}
|
|
1239
|
+
<AiAssistantLauncher variant="topbar" />
|
|
1232
1240
|
{rightHeaderSlot ? (
|
|
1233
1241
|
rightHeaderSlot
|
|
1234
1242
|
) : (
|
|
@@ -1298,4 +1306,3 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
|
|
|
1298
1306
|
</HeaderContext.Provider>
|
|
1299
1307
|
)
|
|
1300
1308
|
}
|
|
1301
|
-
|
|
@@ -41,6 +41,8 @@ export function BackendChromeProvider({ adminNavApi, children }: BackendChromePr
|
|
|
41
41
|
const nextPayload = call.result
|
|
42
42
|
chromeCache.set(buildCacheKey(adminNavApi), nextPayload)
|
|
43
43
|
setPayload(nextPayload)
|
|
44
|
+
} catch {
|
|
45
|
+
return
|
|
44
46
|
} finally {
|
|
45
47
|
setIsLoading(false)
|
|
46
48
|
}
|
|
@@ -1039,7 +1039,7 @@ export function DataTable<T>({
|
|
|
1039
1039
|
}, [injectionSpotId, perspective?.tableId])
|
|
1040
1040
|
const resolvedInjectionSpotId = injectionSpotId ?? (perspective?.tableId ? `data-table:${perspective.tableId}` : null)
|
|
1041
1041
|
const resolvedReplacementHandle = replacementHandle ?? ComponentReplacementHandles.dataTable(extensionTableId ?? 'unknown')
|
|
1042
|
-
const
|
|
1042
|
+
const baseInjectionContext = React.useMemo(
|
|
1043
1043
|
() => injectionContext ?? { tableId: perspective?.tableId ?? null, title: typeof title === 'string' ? title : undefined },
|
|
1044
1044
|
[injectionContext, perspective?.tableId, title]
|
|
1045
1045
|
)
|
|
@@ -1051,6 +1051,10 @@ export function DataTable<T>({
|
|
|
1051
1051
|
() => (resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:toolbar` : null),
|
|
1052
1052
|
[resolvedInjectionSpotId]
|
|
1053
1053
|
)
|
|
1054
|
+
const searchTrailingInjectionSpotId = React.useMemo(
|
|
1055
|
+
() => (resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:search-trailing` : null),
|
|
1056
|
+
[resolvedInjectionSpotId]
|
|
1057
|
+
)
|
|
1054
1058
|
const footerInjectionSpotId = React.useMemo(
|
|
1055
1059
|
() => (resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:footer` : null),
|
|
1056
1060
|
[resolvedInjectionSpotId]
|
|
@@ -1340,6 +1344,15 @@ export function DataTable<T>({
|
|
|
1340
1344
|
if (Object.keys(rowSelection).length === 0) return
|
|
1341
1345
|
setRowSelection({})
|
|
1342
1346
|
}, [hasInjectedBulkActions, rowSelection])
|
|
1347
|
+
const resolvedInjectionContext = React.useMemo(
|
|
1348
|
+
() => {
|
|
1349
|
+
if (!hasInjectedBulkActions) return baseInjectionContext
|
|
1350
|
+
const selectedIds = Object.keys(rowSelection).filter((key) => rowSelection[key])
|
|
1351
|
+
if (selectedIds.length === 0) return baseInjectionContext
|
|
1352
|
+
return { ...baseInjectionContext, _selectedRowIds: selectedIds, _selectedCount: selectedIds.length }
|
|
1353
|
+
},
|
|
1354
|
+
[baseInjectionContext, hasInjectedBulkActions, rowSelection],
|
|
1355
|
+
)
|
|
1343
1356
|
React.useEffect(() => {
|
|
1344
1357
|
const ids = table.getAllLeafColumns().map((column) => column.id)
|
|
1345
1358
|
if (!ids.length) return
|
|
@@ -2180,6 +2193,9 @@ export function DataTable<T>({
|
|
|
2180
2193
|
}) : null}
|
|
2181
2194
|
</div>
|
|
2182
2195
|
) : null
|
|
2196
|
+
const searchTrailingNode = searchTrailingInjectionSpotId && onSearchChange ? (
|
|
2197
|
+
<InjectionSpot spotId={searchTrailingInjectionSpotId} context={resolvedInjectionContext} />
|
|
2198
|
+
) : null
|
|
2183
2199
|
return (
|
|
2184
2200
|
<FilterBar
|
|
2185
2201
|
searchValue={searchValue}
|
|
@@ -2192,6 +2208,7 @@ export function DataTable<T>({
|
|
|
2192
2208
|
onClear={onFiltersClear}
|
|
2193
2209
|
leadingItems={leadingItems}
|
|
2194
2210
|
trailingItems={trailingItems}
|
|
2211
|
+
searchTrailing={searchTrailingNode}
|
|
2195
2212
|
filtersExtraContent={fieldsetSelector}
|
|
2196
2213
|
layout={embedded ? 'inline' : 'stacked'}
|
|
2197
2214
|
className={embedded ? 'min-h-[2.25rem]' : undefined}
|
|
@@ -2224,6 +2241,8 @@ export function DataTable<T>({
|
|
|
2224
2241
|
selectedRows,
|
|
2225
2242
|
runBulkAction,
|
|
2226
2243
|
runPropBulkAction,
|
|
2244
|
+
searchTrailingInjectionSpotId,
|
|
2245
|
+
resolvedInjectionContext,
|
|
2227
2246
|
])
|
|
2228
2247
|
|
|
2229
2248
|
const hasTitle = title != null
|
|
@@ -17,6 +17,13 @@ export type FilterBarProps = {
|
|
|
17
17
|
className?: string
|
|
18
18
|
leadingItems?: React.ReactNode
|
|
19
19
|
trailingItems?: React.ReactNode
|
|
20
|
+
/**
|
|
21
|
+
* Items rendered immediately after the search input on the same row.
|
|
22
|
+
* Intended for compact, icon-sized triggers (AI assistants, saved view
|
|
23
|
+
* shortcuts). Stays adjacent to the search input regardless of
|
|
24
|
+
* `searchAlign` and is suppressed when no search input is rendered.
|
|
25
|
+
*/
|
|
26
|
+
searchTrailing?: React.ReactNode
|
|
20
27
|
layout?: 'stacked' | 'inline'
|
|
21
28
|
filtersExtraContent?: React.ReactNode
|
|
22
29
|
}
|
|
@@ -33,6 +40,7 @@ export function FilterBar({
|
|
|
33
40
|
className,
|
|
34
41
|
leadingItems,
|
|
35
42
|
trailingItems,
|
|
43
|
+
searchTrailing,
|
|
36
44
|
layout = 'stacked',
|
|
37
45
|
filtersExtraContent,
|
|
38
46
|
}: FilterBarProps) {
|
|
@@ -72,20 +80,25 @@ export function FilterBar({
|
|
|
72
80
|
}, [values])
|
|
73
81
|
|
|
74
82
|
const containerClass = `flex flex-col ${layout === 'inline' ? 'gap-1 sm:gap-2' : 'gap-2'} w-full`
|
|
75
|
-
const
|
|
76
|
-
<div className={`
|
|
77
|
-
<
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
const searchBlock = onSearchChange ? (
|
|
84
|
+
<div className={`flex items-center gap-2 ${searchAlign === 'right' ? 'sm:ml-auto' : ''}`}>
|
|
85
|
+
<div className="relative w-full sm:w-72 lg:w-80">
|
|
86
|
+
<input
|
|
87
|
+
value={searchDraft}
|
|
88
|
+
onChange={(e) => setSearchDraft(e.target.value)}
|
|
89
|
+
placeholder={resolvedSearchPlaceholder}
|
|
90
|
+
className="h-9 w-full rounded-md border border-input bg-background pl-8 pr-2 text-sm shadow-xs outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50"
|
|
91
|
+
suppressHydrationWarning
|
|
92
|
+
/>
|
|
93
|
+
<Search aria-hidden="true" className="absolute left-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
94
|
+
</div>
|
|
95
|
+
{searchTrailing ? (
|
|
96
|
+
<div className="flex items-center gap-1">{searchTrailing}</div>
|
|
97
|
+
) : null}
|
|
85
98
|
</div>
|
|
86
99
|
) : null
|
|
87
100
|
const controls = (
|
|
88
|
-
<div className={`flex flex-wrap items-center gap-2 ${searchAlign === 'left' &&
|
|
101
|
+
<div className={`flex flex-wrap items-center gap-2 ${searchAlign === 'left' && searchBlock ? 'sm:ml-auto' : ''}`}>
|
|
89
102
|
{filters.length > 0 && (
|
|
90
103
|
<Button variant="outline" onClick={() => setOpen(true)}>
|
|
91
104
|
<ListFilter aria-hidden="true" className="size-4 opacity-80" />
|
|
@@ -103,9 +116,9 @@ export function FilterBar({
|
|
|
103
116
|
return (
|
|
104
117
|
<div className={`${containerClass} ${className ?? ''}`}>
|
|
105
118
|
<div className="flex flex-wrap items-center gap-2 w-full">
|
|
106
|
-
{searchAlign === 'left' ?
|
|
119
|
+
{searchAlign === 'left' ? searchBlock : null}
|
|
107
120
|
{controls}
|
|
108
|
-
{searchAlign === 'right' ?
|
|
121
|
+
{searchAlign === 'right' ? searchBlock : null}
|
|
109
122
|
</div>
|
|
110
123
|
{/* Active filter chips */}
|
|
111
124
|
{filters.length > 0 && activeCount > 0 && (
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
7
|
+
import { BackendChromeProvider, useBackendChrome } from '../BackendChromeProvider'
|
|
8
|
+
import { apiCall } from '../utils/apiCall'
|
|
9
|
+
|
|
10
|
+
jest.mock('../utils/apiCall', () => ({
|
|
11
|
+
apiCall: jest.fn(),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
function ChromeStateProbe() {
|
|
15
|
+
const chrome = useBackendChrome()
|
|
16
|
+
return (
|
|
17
|
+
<div>
|
|
18
|
+
<span data-testid="loading">{chrome.isLoading ? 'loading' : 'idle'}</span>
|
|
19
|
+
<span data-testid="ready">{chrome.isReady ? 'ready' : 'not-ready'}</span>
|
|
20
|
+
</div>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('BackendChromeProvider', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
;(apiCall as jest.Mock).mockReset()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('contains transient navigation fetch failures', async () => {
|
|
30
|
+
;(apiCall as jest.Mock).mockRejectedValue(new TypeError('Failed to fetch'))
|
|
31
|
+
|
|
32
|
+
render(
|
|
33
|
+
<BackendChromeProvider adminNavApi="/api/auth/admin/nav">
|
|
34
|
+
<ChromeStateProbe />
|
|
35
|
+
</BackendChromeProvider>,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
await waitFor(() => {
|
|
39
|
+
expect(screen.getByTestId('loading')).toHaveTextContent('idle')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
expect(screen.getByTestId('ready')).toHaveTextContent('not-ready')
|
|
43
|
+
expect(apiCall).toHaveBeenCalledWith('/api/auth/admin/nav', { credentials: 'include' })
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -5,11 +5,12 @@ import { Button } from '@open-mercato/ui/primitives/button'
|
|
|
5
5
|
import { IconButton } from '@open-mercato/ui/primitives/icon-button'
|
|
6
6
|
import { Spinner } from '@open-mercato/ui/primitives/spinner'
|
|
7
7
|
import { ErrorNotice } from '@open-mercato/ui/primitives/ErrorNotice'
|
|
8
|
+
import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
|
|
8
9
|
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
9
|
-
import { loadDashboardWidgetModule } from './widgetRegistry'
|
|
10
|
+
import { getDashboardWidgets, loadDashboardWidgetModule } from './widgetRegistry'
|
|
10
11
|
import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
|
|
11
12
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
12
|
-
import { GripVertical, Plus, RefreshCw, Settings2, Trash2, X, Loader2 } from 'lucide-react'
|
|
13
|
+
import { GripVertical, Info, Plus, RefreshCw, Settings2, Trash2, X, Loader2 } from 'lucide-react'
|
|
13
14
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
14
15
|
import { InjectionSpot } from '../injection/InjectionSpot'
|
|
15
16
|
|
|
@@ -93,6 +94,7 @@ export function DashboardScreen() {
|
|
|
93
94
|
const t = useT()
|
|
94
95
|
const [loading, setLoading] = React.useState(true)
|
|
95
96
|
const [error, setError] = React.useState<string | null>(null)
|
|
97
|
+
const [hasRegisteredWidgets, setHasRegisteredWidgets] = React.useState(true)
|
|
96
98
|
const [saving, setSaving] = React.useState(false)
|
|
97
99
|
const [layout, setLayout] = React.useState<LayoutItem[]>([])
|
|
98
100
|
const [widgetCatalog, setWidgetCatalog] = React.useState<WidgetMeta[]>([])
|
|
@@ -119,9 +121,11 @@ export function DashboardScreen() {
|
|
|
119
121
|
throw new Error(`Failed with status ${call.status}`)
|
|
120
122
|
}
|
|
121
123
|
const data = call.result
|
|
124
|
+
const registeredWidgetCount = getDashboardWidgets().length
|
|
122
125
|
const normalizedLayout = sortLayout(data.layout?.items ?? [])
|
|
123
126
|
setLayout(normalizedLayout)
|
|
124
127
|
setWidgetCatalog(data.widgets ?? [])
|
|
128
|
+
setHasRegisteredWidgets(registeredWidgetCount > 0 || (data.widgets ?? []).length > 0)
|
|
125
129
|
setAllowedWidgetIds(data.allowedWidgetIds ?? [])
|
|
126
130
|
setCanConfigure(!!data.canConfigure)
|
|
127
131
|
if (data.context) {
|
|
@@ -142,6 +146,17 @@ export function DashboardScreen() {
|
|
|
142
146
|
}
|
|
143
147
|
} catch (err) {
|
|
144
148
|
console.error('Failed to load dashboard layout', err)
|
|
149
|
+
if (getDashboardWidgets().length === 0) {
|
|
150
|
+
setHasRegisteredWidgets(false)
|
|
151
|
+
setLayout([])
|
|
152
|
+
setWidgetCatalog([])
|
|
153
|
+
setAllowedWidgetIds([])
|
|
154
|
+
setCanConfigure(false)
|
|
155
|
+
setContext(null)
|
|
156
|
+
setEditing(false)
|
|
157
|
+
setSettingsId(null)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
145
160
|
setError(t('dashboard.loadError'))
|
|
146
161
|
} finally {
|
|
147
162
|
setLoading(false)
|
|
@@ -345,6 +360,21 @@ export function DashboardScreen() {
|
|
|
345
360
|
)
|
|
346
361
|
}
|
|
347
362
|
|
|
363
|
+
if (!hasRegisteredWidgets && layout.length === 0) {
|
|
364
|
+
return (
|
|
365
|
+
<Alert variant="info">
|
|
366
|
+
<Info className="h-4 w-4" aria-hidden />
|
|
367
|
+
<AlertTitle>{t('dashboard.empty.noWidgets.title', 'No dashboard widgets yet')}</AlertTitle>
|
|
368
|
+
<AlertDescription>
|
|
369
|
+
{t(
|
|
370
|
+
'dashboard.empty.noWidgets.description',
|
|
371
|
+
'After you add the first module that exposes dashboard widgets, they will appear here.',
|
|
372
|
+
)}
|
|
373
|
+
</AlertDescription>
|
|
374
|
+
</Alert>
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
|
|
348
378
|
return (
|
|
349
379
|
<div className="space-y-6">
|
|
350
380
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
@@ -464,7 +494,12 @@ export function DashboardScreen() {
|
|
|
464
494
|
|
|
465
495
|
{layout.length === 0 && (
|
|
466
496
|
<div className="rounded-lg border border-dashed bg-muted/30 p-10 text-center text-sm text-muted-foreground">
|
|
467
|
-
{
|
|
497
|
+
{!hasRegisteredWidgets
|
|
498
|
+
? t(
|
|
499
|
+
'dashboard.empty.noWidgets.description',
|
|
500
|
+
'After you add the first module that exposes dashboard widgets, they will appear here.',
|
|
501
|
+
)
|
|
502
|
+
: canConfigure ? t('dashboard.empty.configurable') : t('dashboard.empty.readonly')}
|
|
468
503
|
</div>
|
|
469
504
|
)}
|
|
470
505
|
|
|
@@ -7,7 +7,7 @@ import { screen, waitFor } from '@testing-library/react'
|
|
|
7
7
|
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
8
8
|
import { DashboardScreen } from '../DashboardScreen'
|
|
9
9
|
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
10
|
-
import { loadDashboardWidgetModule } from '../widgetRegistry'
|
|
10
|
+
import { getDashboardWidgets, loadDashboardWidgetModule } from '../widgetRegistry'
|
|
11
11
|
|
|
12
12
|
jest.setTimeout(20000)
|
|
13
13
|
|
|
@@ -16,6 +16,7 @@ jest.mock('@open-mercato/ui/backend/utils/apiCall', () => ({
|
|
|
16
16
|
}))
|
|
17
17
|
|
|
18
18
|
jest.mock('../widgetRegistry', () => ({
|
|
19
|
+
getDashboardWidgets: jest.fn(),
|
|
19
20
|
loadDashboardWidgetModule: jest.fn(),
|
|
20
21
|
}))
|
|
21
22
|
|
|
@@ -31,6 +32,8 @@ const createMockResponse = (status: number): Response => ({ status } as Response
|
|
|
31
32
|
|
|
32
33
|
const dict = {
|
|
33
34
|
'dashboard.loadError': 'Failed to load dashboard',
|
|
35
|
+
'dashboard.empty.noWidgets.title': 'No dashboard widgets yet',
|
|
36
|
+
'dashboard.empty.noWidgets.description': 'Dashboard widgets will appear here after you add a module.',
|
|
34
37
|
'dashboard.widgets.foo.title': 'Widget Foo',
|
|
35
38
|
'dashboard.widgets.foo.description': 'Widget description',
|
|
36
39
|
}
|
|
@@ -71,6 +74,7 @@ function MockWidget() {
|
|
|
71
74
|
describe('DashboardScreen', () => {
|
|
72
75
|
beforeEach(() => {
|
|
73
76
|
jest.resetAllMocks()
|
|
77
|
+
;(getDashboardWidgets as jest.Mock).mockReturnValue([{ key: 'foo.loader', loader: jest.fn() }])
|
|
74
78
|
;(loadDashboardWidgetModule as jest.Mock).mockResolvedValue({
|
|
75
79
|
Widget: MockWidget,
|
|
76
80
|
hydrateSettings: (value: unknown) => value,
|
|
@@ -111,4 +115,23 @@ describe('DashboardScreen', () => {
|
|
|
111
115
|
|
|
112
116
|
errorSpy.mockRestore()
|
|
113
117
|
})
|
|
118
|
+
|
|
119
|
+
it('shows an informational empty state when no dashboard widgets are registered', async () => {
|
|
120
|
+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
121
|
+
;(getDashboardWidgets as jest.Mock).mockReturnValue([])
|
|
122
|
+
;(apiCall as jest.Mock).mockResolvedValue({
|
|
123
|
+
ok: false,
|
|
124
|
+
status: 500,
|
|
125
|
+
result: null,
|
|
126
|
+
response: createMockResponse(500),
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
renderWithProviders(<DashboardScreen />, { dict })
|
|
130
|
+
|
|
131
|
+
expect(await screen.findByText('No dashboard widgets yet')).toBeInTheDocument()
|
|
132
|
+
expect(screen.getByText('Dashboard widgets will appear here after you add a module.')).toBeInTheDocument()
|
|
133
|
+
expect(screen.queryByText('Failed to load dashboard')).not.toBeInTheDocument()
|
|
134
|
+
|
|
135
|
+
errorSpy.mockRestore()
|
|
136
|
+
})
|
|
114
137
|
})
|
|
@@ -33,6 +33,12 @@ export const DataTableInjectionSpots = {
|
|
|
33
33
|
header: (tableId: string): InjectionSpotId => `data-table:${tableId}:header`,
|
|
34
34
|
footer: (tableId: string): InjectionSpotId => `data-table:${tableId}:footer`,
|
|
35
35
|
toolbar: (tableId: string): InjectionSpotId => `data-table:${tableId}:toolbar`,
|
|
36
|
+
// Slot rendered immediately after the search input on the same row as the
|
|
37
|
+
// FilterBar — intended for compact, icon-sized triggers (AI assistants,
|
|
38
|
+
// saved view shortcuts, etc.). Hosts pass the resolved spot ID through to
|
|
39
|
+
// FilterBar's `searchTrailing` prop. Stays empty when the table has no
|
|
40
|
+
// search input.
|
|
41
|
+
searchTrailing: (tableId: string): InjectionSpotId => `data-table:${tableId}:search-trailing`,
|
|
36
42
|
emptyState: (tableId: string): InjectionSpotId => `data-table:${tableId}:empty-state`,
|
|
37
43
|
columns: (tableId: string): InjectionSpotId => `data-table:${tableId}:columns`,
|
|
38
44
|
rowActions: (tableId: string): InjectionSpotId => `data-table:${tableId}:row-actions`,
|