@open-mercato/ui 0.8.1-develop.7296.1.2111d779db → 0.9.0
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/backend/CrudForm.js +19 -6
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +62 -10
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/RowActions.js +54 -5
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/charts/KpiCard.js +2 -1
- package/dist/backend/charts/KpiCard.js.map +2 -2
- package/dist/backend/detail/AttachmentsSection.js +17 -5
- package/dist/backend/detail/AttachmentsSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +38 -20
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js +11 -1
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +26 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/eventBridge.js +33 -0
- package/dist/backend/injection/eventBridge.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +20 -3
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +1 -1
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/inputs/TagsInput.js +20 -2
- package/dist/backend/inputs/TagsInput.js.map +2 -2
- package/dist/backend/messages/EmailThreadsPanel.js +2 -1
- package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
- package/dist/backend/messages/MessageComposer.js.map +2 -2
- package/dist/backend/messages/index.js.map +2 -2
- package/dist/backend/messages/message-compose-form-groups.js +24 -0
- package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
- package/dist/backend/messages/useMessageCompose.js +27 -0
- package/dist/backend/messages/useMessageCompose.js.map +2 -2
- package/dist/backend/messages/useMessageComposeOperations.js +1 -0
- package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
- package/dist/backend/notifications/NotificationItem.js +8 -1
- package/dist/backend/notifications/NotificationItem.js.map +2 -2
- package/dist/portal/PortalShell.js +2 -2
- package/dist/portal/PortalShell.js.map +2 -2
- package/dist/primitives/calendar.js +69 -14
- package/dist/primitives/calendar.js.map +2 -2
- package/package.json +4 -5
- package/src/backend/CrudForm.tsx +30 -8
- package/src/backend/DataTable.tsx +95 -20
- package/src/backend/RowActions.tsx +114 -6
- package/src/backend/__tests__/AttachmentsSection.upload.test.tsx +183 -0
- package/src/backend/__tests__/CrudForm.fieldInjection.test.tsx +139 -2
- package/src/backend/__tests__/CrudForm.hiddenGroups.test.tsx +1 -1
- package/src/backend/__tests__/DataTable.extensions.test.tsx +30 -0
- package/src/backend/__tests__/DataTable.perspectiveReconcile.test.tsx +320 -2
- package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +19 -0
- package/src/backend/__tests__/DevRuntimeDiagnosticsBanner.test.tsx +117 -0
- package/src/backend/__tests__/NotesSection.test.tsx +138 -0
- package/src/backend/__tests__/RowActions.test.tsx +202 -0
- package/src/backend/charts/KpiCard.tsx +1 -0
- package/src/backend/charts/__tests__/KpiCard.test.tsx +20 -0
- package/src/backend/detail/AttachmentsSection.tsx +17 -5
- package/src/backend/detail/NotesSection.tsx +122 -66
- package/src/backend/dev/DevRuntimeDiagnosticsBanner.tsx +24 -1
- package/src/backend/icons/lucideRegistry.generated.tsx +26 -0
- package/src/backend/injection/__tests__/eventBridge.test.tsx +47 -1
- package/src/backend/injection/eventBridge.ts +35 -0
- package/src/backend/inputs/LookupSelect.tsx +32 -3
- package/src/backend/inputs/PhoneNumberField.tsx +1 -1
- package/src/backend/inputs/TagsInput.tsx +24 -3
- package/src/backend/inputs/__tests__/LookupSelect.test.tsx +166 -1
- package/src/backend/inputs/__tests__/PhoneNumberField.typeahead.test.tsx +43 -0
- package/src/backend/inputs/__tests__/TagsInput.test.tsx +81 -0
- package/src/backend/messages/EmailThreadsPanel.tsx +21 -1
- package/src/backend/messages/MessageComposer.tsx +1 -0
- package/src/backend/messages/__tests__/EmailThreadsPanel.senderLabel.test.tsx +99 -0
- package/src/backend/messages/__tests__/MessageComposer.test.tsx +44 -0
- package/src/backend/messages/__tests__/useMessageCompose.test.tsx +69 -0
- package/src/backend/messages/__tests__/useMessageComposeSender.test.tsx +208 -0
- package/src/backend/messages/index.ts +1 -0
- package/src/backend/messages/message-compose-form-groups.tsx +41 -0
- package/src/backend/messages/message-composer.types.ts +26 -0
- package/src/backend/messages/useMessageCompose.ts +46 -0
- package/src/backend/messages/useMessageComposeOperations.ts +8 -0
- package/src/backend/notifications/NotificationItem.tsx +7 -0
- package/src/backend/notifications/__tests__/NotificationItem.customRenderer.test.tsx +139 -0
- package/src/portal/PortalShell.tsx +7 -4
- package/src/portal/__tests__/PortalShell.test.tsx +29 -1
- package/src/primitives/__tests__/calendar.test.tsx +174 -1
- package/src/primitives/calendar.tsx +168 -96
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
AlertCircle,
|
|
10
10
|
AlertOctagon,
|
|
11
11
|
AlertTriangle,
|
|
12
|
+
AlignLeft,
|
|
12
13
|
Apple,
|
|
13
14
|
Archive,
|
|
14
15
|
AtSign,
|
|
@@ -73,9 +74,12 @@ import {
|
|
|
73
74
|
GitPullRequestArrow,
|
|
74
75
|
Globe,
|
|
75
76
|
GraduationCap,
|
|
77
|
+
Grid3x3,
|
|
76
78
|
Hammer,
|
|
77
79
|
Hand,
|
|
78
80
|
Handshake,
|
|
81
|
+
Hash,
|
|
82
|
+
Heading,
|
|
79
83
|
Headphones,
|
|
80
84
|
Heart,
|
|
81
85
|
HelpCircle,
|
|
@@ -90,6 +94,7 @@ import {
|
|
|
90
94
|
Link,
|
|
91
95
|
List,
|
|
92
96
|
ListChecks,
|
|
97
|
+
ListOrdered,
|
|
93
98
|
ListX,
|
|
94
99
|
Loader,
|
|
95
100
|
Loader2,
|
|
@@ -110,9 +115,11 @@ import {
|
|
|
110
115
|
PackagePlus,
|
|
111
116
|
PackageSearch,
|
|
112
117
|
PackageX,
|
|
118
|
+
Paperclip,
|
|
113
119
|
PauseCircle,
|
|
114
120
|
PencilLine,
|
|
115
121
|
PenLine,
|
|
122
|
+
PenTool,
|
|
116
123
|
Percent,
|
|
117
124
|
Phone,
|
|
118
125
|
PhoneCall,
|
|
@@ -126,7 +133,9 @@ import {
|
|
|
126
133
|
Repeat2,
|
|
127
134
|
Reply,
|
|
128
135
|
RotateCcw,
|
|
136
|
+
Rows,
|
|
129
137
|
Ruler,
|
|
138
|
+
Search,
|
|
130
139
|
Send,
|
|
131
140
|
Settings,
|
|
132
141
|
Shapes,
|
|
@@ -138,6 +147,7 @@ import {
|
|
|
138
147
|
ShoppingCart,
|
|
139
148
|
Shuffle,
|
|
140
149
|
Sliders,
|
|
150
|
+
SlidersHorizontal,
|
|
141
151
|
Smartphone,
|
|
142
152
|
Snowflake,
|
|
143
153
|
Sparkles,
|
|
@@ -149,11 +159,14 @@ import {
|
|
|
149
159
|
ThumbsUp,
|
|
150
160
|
Ticket,
|
|
151
161
|
Timer,
|
|
162
|
+
ToggleLeft,
|
|
163
|
+
ToggleRight,
|
|
152
164
|
Trash2,
|
|
153
165
|
TrendingUp,
|
|
154
166
|
TriangleAlert,
|
|
155
167
|
Trophy,
|
|
156
168
|
Truck,
|
|
169
|
+
Type,
|
|
157
170
|
Undo,
|
|
158
171
|
Undo2,
|
|
159
172
|
Unlock,
|
|
@@ -181,6 +194,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
181
194
|
'alert-circle': AlertCircle,
|
|
182
195
|
'alert-octagon': AlertOctagon,
|
|
183
196
|
'alert-triangle': AlertTriangle,
|
|
197
|
+
'align-left': AlignLeft,
|
|
184
198
|
'apple': Apple,
|
|
185
199
|
'archive': Archive,
|
|
186
200
|
'at-sign': AtSign,
|
|
@@ -245,9 +259,12 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
245
259
|
'git-pull-request-arrow': GitPullRequestArrow,
|
|
246
260
|
'globe': Globe,
|
|
247
261
|
'graduation-cap': GraduationCap,
|
|
262
|
+
'grid-3x3': Grid3x3,
|
|
248
263
|
'hammer': Hammer,
|
|
249
264
|
'hand': Hand,
|
|
250
265
|
'handshake': Handshake,
|
|
266
|
+
'hash': Hash,
|
|
267
|
+
'heading': Heading,
|
|
251
268
|
'headphones': Headphones,
|
|
252
269
|
'heart': Heart,
|
|
253
270
|
'help-circle': HelpCircle,
|
|
@@ -262,6 +279,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
262
279
|
'link': Link,
|
|
263
280
|
'list': List,
|
|
264
281
|
'list-checks': ListChecks,
|
|
282
|
+
'list-ordered': ListOrdered,
|
|
265
283
|
'list-x': ListX,
|
|
266
284
|
'loader': Loader,
|
|
267
285
|
'loader-2': Loader2,
|
|
@@ -282,8 +300,10 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
282
300
|
'package-plus': PackagePlus,
|
|
283
301
|
'package-search': PackageSearch,
|
|
284
302
|
'package-x': PackageX,
|
|
303
|
+
'paperclip': Paperclip,
|
|
285
304
|
'pause-circle': PauseCircle,
|
|
286
305
|
'pen-line': PenLine,
|
|
306
|
+
'pen-tool': PenTool,
|
|
287
307
|
'pencil-line': PencilLine,
|
|
288
308
|
'percent': Percent,
|
|
289
309
|
'phone': Phone,
|
|
@@ -298,7 +318,9 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
298
318
|
'repeat-2': Repeat2,
|
|
299
319
|
'reply': Reply,
|
|
300
320
|
'rotate-ccw': RotateCcw,
|
|
321
|
+
'rows': Rows,
|
|
301
322
|
'ruler': Ruler,
|
|
323
|
+
'search': Search,
|
|
302
324
|
'send': Send,
|
|
303
325
|
'settings': Settings,
|
|
304
326
|
'shapes': Shapes,
|
|
@@ -310,6 +332,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
310
332
|
'shopping-cart': ShoppingCart,
|
|
311
333
|
'shuffle': Shuffle,
|
|
312
334
|
'sliders': Sliders,
|
|
335
|
+
'sliders-horizontal': SlidersHorizontal,
|
|
313
336
|
'smartphone': Smartphone,
|
|
314
337
|
'snowflake': Snowflake,
|
|
315
338
|
'sparkles': Sparkles,
|
|
@@ -321,11 +344,14 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
321
344
|
'thumbs-up': ThumbsUp,
|
|
322
345
|
'ticket': Ticket,
|
|
323
346
|
'timer': Timer,
|
|
347
|
+
'toggle-left': ToggleLeft,
|
|
348
|
+
'toggle-right': ToggleRight,
|
|
324
349
|
'trash-2': Trash2,
|
|
325
350
|
'trending-up': TrendingUp,
|
|
326
351
|
'triangle-alert': TriangleAlert,
|
|
327
352
|
'trophy': Trophy,
|
|
328
353
|
'truck': Truck,
|
|
354
|
+
'type': Type,
|
|
329
355
|
'undo': Undo,
|
|
330
356
|
'undo-2': Undo2,
|
|
331
357
|
'unlock': Unlock,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** @jest-environment jsdom */
|
|
2
2
|
import { act, renderHook } from '@testing-library/react'
|
|
3
|
+
import { emitOrganizationScopeChanged } from '@open-mercato/shared/lib/frontend/organizationEvents'
|
|
3
4
|
import { useEventBridge } from '../eventBridge'
|
|
4
5
|
|
|
5
6
|
class EventSourceMock {
|
|
@@ -9,8 +10,14 @@ class EventSourceMock {
|
|
|
9
10
|
onmessage: ((event: MessageEvent) => void) | null = null
|
|
10
11
|
onerror: ((event: Event) => void) | null = null
|
|
11
12
|
close = jest.fn()
|
|
13
|
+
readonly url: string
|
|
14
|
+
readonly options: EventSourceInit | undefined
|
|
15
|
+
readonly cookiesAtCreation: string
|
|
12
16
|
|
|
13
|
-
constructor() {
|
|
17
|
+
constructor(url: string | URL, options?: EventSourceInit) {
|
|
18
|
+
this.url = String(url)
|
|
19
|
+
this.options = options
|
|
20
|
+
this.cookiesAtCreation = document.cookie
|
|
14
21
|
EventSourceMock.instances.push(this)
|
|
15
22
|
}
|
|
16
23
|
}
|
|
@@ -81,4 +88,43 @@ describe('useEventBridge connection lifecycle', () => {
|
|
|
81
88
|
expect(EventSourceMock.instances).toHaveLength(2)
|
|
82
89
|
unmount()
|
|
83
90
|
})
|
|
91
|
+
|
|
92
|
+
it('reconnects immediately with current cookies when organization scope changes', () => {
|
|
93
|
+
document.cookie = 'om_selected_org=org-before; path=/'
|
|
94
|
+
const { unmount } = renderHook(() => useEventBridge())
|
|
95
|
+
const firstSource = EventSourceMock.instances[0]
|
|
96
|
+
act(() => firstSource.onopen?.(new Event('open')))
|
|
97
|
+
|
|
98
|
+
document.cookie = 'om_selected_org=org-after; path=/'
|
|
99
|
+
act(() => {
|
|
100
|
+
emitOrganizationScopeChanged({ organizationId: 'org-after', tenantId: 'tenant-1' })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
expect(firstSource.close).toHaveBeenCalledTimes(1)
|
|
104
|
+
expect(EventSourceMock.instances).toHaveLength(2)
|
|
105
|
+
const replacementSource = EventSourceMock.instances[1]
|
|
106
|
+
expect(replacementSource.url).toBe('/api/events/stream')
|
|
107
|
+
expect(replacementSource.options).toEqual({ withCredentials: true })
|
|
108
|
+
expect(replacementSource.cookiesAtCreation).toContain('om_selected_org=org-after')
|
|
109
|
+
act(() => firstSource.onerror?.(new Event('error')))
|
|
110
|
+
expect(replacementSource.close).not.toHaveBeenCalled()
|
|
111
|
+
expect(EventSourceMock.instances).toHaveLength(2)
|
|
112
|
+
unmount()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('keeps the open stream when the scope signal does not change the scope cookies', () => {
|
|
116
|
+
document.cookie = 'om_selected_org=org-same; path=/'
|
|
117
|
+
document.cookie = 'om_selected_tenant=tenant-1; path=/'
|
|
118
|
+
const { unmount } = renderHook(() => useEventBridge())
|
|
119
|
+
const source = EventSourceMock.instances[0]
|
|
120
|
+
act(() => source.onopen?.(new Event('open')))
|
|
121
|
+
|
|
122
|
+
act(() => {
|
|
123
|
+
emitOrganizationScopeChanged({ organizationId: 'org-same', tenantId: 'tenant-1' })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
expect(source.close).not.toHaveBeenCalled()
|
|
127
|
+
expect(EventSourceMock.instances).toHaveLength(1)
|
|
128
|
+
unmount()
|
|
129
|
+
})
|
|
84
130
|
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import { useEffect, useRef } from 'react'
|
|
3
3
|
import type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'
|
|
4
|
+
import { subscribeOrganizationScopeChanged } from '@open-mercato/shared/lib/frontend/organizationEvents'
|
|
4
5
|
import { APP_EVENT_DOM_NAME } from './useAppEvent'
|
|
5
6
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
6
7
|
|
|
@@ -12,6 +13,21 @@ const RECONNECT_BASE_MS = 1_000
|
|
|
12
13
|
const RECONNECT_MAX_MS = 30_000
|
|
13
14
|
const DEDUP_WINDOW_MS = 500
|
|
14
15
|
const BRIDGE_RECONNECTED_EVENT_ID = 'om:bridge:reconnected'
|
|
16
|
+
const SCOPE_COOKIE_NAMES = ['om_selected_org', 'om_selected_tenant'] as const
|
|
17
|
+
|
|
18
|
+
function readScopeCookieSignature(): string {
|
|
19
|
+
if (typeof document === 'undefined') return ''
|
|
20
|
+
const values = new Map<string, string>()
|
|
21
|
+
for (const part of document.cookie.split(';')) {
|
|
22
|
+
const separatorIndex = part.indexOf('=')
|
|
23
|
+
if (separatorIndex < 0) continue
|
|
24
|
+
const name = part.slice(0, separatorIndex).trim()
|
|
25
|
+
if ((SCOPE_COOKIE_NAMES as readonly string[]).includes(name)) {
|
|
26
|
+
values.set(name, part.slice(separatorIndex + 1).trim())
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return SCOPE_COOKIE_NAMES.map((name) => `${name}=${values.get(name) ?? ''}`).join(';')
|
|
30
|
+
}
|
|
15
31
|
|
|
16
32
|
type EventBridgeReadyWindow = { __omEventBridgeReady?: boolean }
|
|
17
33
|
|
|
@@ -41,6 +57,7 @@ export function useEventBridge(): void {
|
|
|
41
57
|
const recentEvents = useRef<Map<string, number>>(new Map())
|
|
42
58
|
const hasEverConnected = useRef(false)
|
|
43
59
|
const reconnectPending = useRef(false)
|
|
60
|
+
const connectedScopeSignature = useRef<string | null>(null)
|
|
44
61
|
|
|
45
62
|
useEffect(() => {
|
|
46
63
|
let mounted = true
|
|
@@ -80,8 +97,10 @@ export function useEventBridge(): void {
|
|
|
80
97
|
try {
|
|
81
98
|
const source = new EventSource(SSE_ENDPOINT, { withCredentials: true })
|
|
82
99
|
sourceRef.current = source
|
|
100
|
+
connectedScopeSignature.current = readScopeCookieSignature()
|
|
83
101
|
|
|
84
102
|
source.onopen = () => {
|
|
103
|
+
if (sourceRef.current !== source) return
|
|
85
104
|
const shouldEmitReconnect = hasEverConnected.current && reconnectPending.current
|
|
86
105
|
hasEverConnected.current = true
|
|
87
106
|
reconnectPending.current = false
|
|
@@ -103,6 +122,7 @@ export function useEventBridge(): void {
|
|
|
103
122
|
}
|
|
104
123
|
|
|
105
124
|
source.onmessage = (event) => {
|
|
125
|
+
if (sourceRef.current !== source) return
|
|
106
126
|
resetHeartbeatTimer()
|
|
107
127
|
if (!event.data || event.data === ':heartbeat') return
|
|
108
128
|
|
|
@@ -121,6 +141,7 @@ export function useEventBridge(): void {
|
|
|
121
141
|
}
|
|
122
142
|
|
|
123
143
|
source.onerror = () => {
|
|
144
|
+
if (sourceRef.current !== source) return
|
|
124
145
|
if (hasEverConnected.current) {
|
|
125
146
|
reconnectPending.current = true
|
|
126
147
|
}
|
|
@@ -173,12 +194,26 @@ export function useEventBridge(): void {
|
|
|
173
194
|
connect()
|
|
174
195
|
}
|
|
175
196
|
|
|
197
|
+
function handleOrganizationScopeChange() {
|
|
198
|
+
if (sourceRef.current && connectedScopeSignature.current === readScopeCookieSignature()) return
|
|
199
|
+
if (hasEverConnected.current) reconnectPending.current = true
|
|
200
|
+
disconnect()
|
|
201
|
+
if (reconnectTimer.current) {
|
|
202
|
+
clearTimeout(reconnectTimer.current)
|
|
203
|
+
reconnectTimer.current = null
|
|
204
|
+
}
|
|
205
|
+
reconnectAttempts.current = 0
|
|
206
|
+
connect()
|
|
207
|
+
}
|
|
208
|
+
|
|
176
209
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
210
|
+
const unsubscribeOrganizationScope = subscribeOrganizationScopeChanged(handleOrganizationScopeChange)
|
|
177
211
|
connect()
|
|
178
212
|
|
|
179
213
|
return () => {
|
|
180
214
|
mounted = false
|
|
181
215
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
216
|
+
unsubscribeOrganizationScope()
|
|
182
217
|
disconnect()
|
|
183
218
|
if (reconnectTimer.current) {
|
|
184
219
|
clearTimeout(reconnectTimer.current)
|
|
@@ -44,6 +44,16 @@ type LookupSelectProps = {
|
|
|
44
44
|
defaultOpen?: boolean
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function keepSelectedItem(
|
|
48
|
+
result: LookupSelectItem[],
|
|
49
|
+
selected: LookupSelectItem | null,
|
|
50
|
+
value: string | null,
|
|
51
|
+
): LookupSelectItem[] {
|
|
52
|
+
if (!value || !selected || selected.id !== value) return result
|
|
53
|
+
if (result.some((item) => item.id === value)) return result
|
|
54
|
+
return [selected, ...result]
|
|
55
|
+
}
|
|
56
|
+
|
|
47
57
|
export function LookupSelect({
|
|
48
58
|
value,
|
|
49
59
|
onChange,
|
|
@@ -97,11 +107,17 @@ export function LookupSelect({
|
|
|
97
107
|
const setQueryRef = React.useRef(setQuery)
|
|
98
108
|
const onReadyRef = React.useRef(onReady)
|
|
99
109
|
const optionsWasArrayRef = React.useRef(Array.isArray(options))
|
|
110
|
+
const valueRef = React.useRef(value)
|
|
111
|
+
const selectedItemRef = React.useRef<LookupSelectItem | null>(null)
|
|
100
112
|
|
|
101
113
|
React.useEffect(() => {
|
|
102
114
|
fetchItemsRef.current = fetchItems ?? fetchOptions
|
|
103
115
|
}, [fetchItems, fetchOptions])
|
|
104
116
|
|
|
117
|
+
React.useEffect(() => {
|
|
118
|
+
valueRef.current = value
|
|
119
|
+
}, [value])
|
|
120
|
+
|
|
105
121
|
React.useEffect(() => {
|
|
106
122
|
onReadyRef.current = onReady
|
|
107
123
|
}, [onReady])
|
|
@@ -215,10 +231,19 @@ export function LookupSelect({
|
|
|
215
231
|
const requestId = Date.now()
|
|
216
232
|
const fetcher = fetchItemsRef.current
|
|
217
233
|
const loader = fetcher ?? (() => Promise.resolve(options ?? []))
|
|
218
|
-
|
|
234
|
+
const trimmedQuery = query.trim()
|
|
235
|
+
loader(trimmedQuery)
|
|
219
236
|
.then((result) => {
|
|
220
237
|
if (cancelled) return
|
|
221
|
-
|
|
238
|
+
// A set `value` opens the list with an empty query, and that browse
|
|
239
|
+
// fetch returns an arbitrary first page. Without this the stored
|
|
240
|
+
// selection vanished from the list whenever it fell outside that page,
|
|
241
|
+
// while the form still held its id. A typed query stays a pure search.
|
|
242
|
+
setItems(
|
|
243
|
+
trimmedQuery
|
|
244
|
+
? result
|
|
245
|
+
: keepSelectedItem(result, selectedItemRef.current, valueRef.current),
|
|
246
|
+
)
|
|
222
247
|
})
|
|
223
248
|
.catch((err) => {
|
|
224
249
|
if (cancelled) return
|
|
@@ -240,11 +265,15 @@ export function LookupSelect({
|
|
|
240
265
|
|
|
241
266
|
React.useEffect(() => {
|
|
242
267
|
if (!value) {
|
|
268
|
+
selectedItemRef.current = null
|
|
243
269
|
setSelectedItem(null)
|
|
244
270
|
return
|
|
245
271
|
}
|
|
246
272
|
const match = items.find((item) => item.id === value)
|
|
247
|
-
if (match)
|
|
273
|
+
if (match) {
|
|
274
|
+
selectedItemRef.current = match
|
|
275
|
+
setSelectedItem(match)
|
|
276
|
+
}
|
|
248
277
|
}, [items, value])
|
|
249
278
|
|
|
250
279
|
/*
|
|
@@ -725,7 +725,7 @@ export function PhoneNumberField({
|
|
|
725
725
|
</SelectTrigger>
|
|
726
726
|
<SelectContent align="start">
|
|
727
727
|
{countryOptions.map(({ country: c, label }) => (
|
|
728
|
-
<SelectItem key={`${c.iso2}-${c.dialCode}`} value={c.iso2}>
|
|
728
|
+
<SelectItem key={`${c.iso2}-${c.dialCode}`} value={c.iso2} textValue={label}>
|
|
729
729
|
<SelectItemLeading>
|
|
730
730
|
<span className="text-base leading-none">{c.flag}</span>
|
|
731
731
|
</SelectItemLeading>
|
|
@@ -67,6 +67,8 @@ export function TagsInput({
|
|
|
67
67
|
const [asyncOptions, setAsyncOptions] = React.useState<TagsInputOption[]>([])
|
|
68
68
|
const [loading, setLoading] = React.useState(false)
|
|
69
69
|
const [touched, setTouched] = React.useState(false)
|
|
70
|
+
const [suggestionsDismissed, setSuggestionsDismissed] = React.useState(false)
|
|
71
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
70
72
|
const suppressBlurCommitRef = React.useRef(false)
|
|
71
73
|
const suppressSuggestionsOnFocusRef = React.useRef(Boolean(autoFocus && suppressInitialSuggestionsOnFocus && !disabled))
|
|
72
74
|
const valueRef = React.useRef(value)
|
|
@@ -141,6 +143,21 @@ export function TagsInput({
|
|
|
141
143
|
}
|
|
142
144
|
}, [disabled, input, loadSuggestions, touched])
|
|
143
145
|
|
|
146
|
+
const suggestionsOpen = !suggestionsDismissed
|
|
147
|
+
&& ((loading && touched) || (!loading && filteredSuggestions.length > 0))
|
|
148
|
+
|
|
149
|
+
React.useEffect(() => {
|
|
150
|
+
if (!suggestionsOpen) return
|
|
151
|
+
const handleEscape = (event: KeyboardEvent) => {
|
|
152
|
+
if (event.key !== 'Escape' || event.target !== inputRef.current) return
|
|
153
|
+
event.preventDefault()
|
|
154
|
+
event.stopPropagation()
|
|
155
|
+
setSuggestionsDismissed(true)
|
|
156
|
+
}
|
|
157
|
+
window.addEventListener('keydown', handleEscape, { capture: true })
|
|
158
|
+
return () => window.removeEventListener('keydown', handleEscape, { capture: true })
|
|
159
|
+
}, [suggestionsOpen])
|
|
160
|
+
|
|
144
161
|
const addValue = React.useCallback(
|
|
145
162
|
(nextValue: string) => {
|
|
146
163
|
if (disabled) return
|
|
@@ -226,6 +243,7 @@ export function TagsInput({
|
|
|
226
243
|
)
|
|
227
244
|
})}
|
|
228
245
|
<input
|
|
246
|
+
ref={inputRef}
|
|
229
247
|
className="flex-1 min-w-[80px] sm:min-w-[120px] border-0 py-1 text-sm outline-none disabled:bg-transparent"
|
|
230
248
|
value={input}
|
|
231
249
|
placeholder={placeholder || t('ui.inputs.tagsInput.placeholder', 'Add tag and press Enter')}
|
|
@@ -239,13 +257,16 @@ export function TagsInput({
|
|
|
239
257
|
}
|
|
240
258
|
if (showSuggestionsOnFocus) {
|
|
241
259
|
setTouched(true)
|
|
260
|
+
setSuggestionsDismissed(false)
|
|
242
261
|
}
|
|
243
262
|
}}
|
|
244
263
|
onMouseDown={() => {
|
|
245
264
|
setTouched(true)
|
|
265
|
+
setSuggestionsDismissed(false)
|
|
246
266
|
}}
|
|
247
267
|
onChange={(event) => {
|
|
248
268
|
setTouched(true)
|
|
269
|
+
setSuggestionsDismissed(false)
|
|
249
270
|
setInput(event.target.value)
|
|
250
271
|
}}
|
|
251
272
|
onKeyDown={(event) => {
|
|
@@ -269,10 +290,10 @@ export function TagsInput({
|
|
|
269
290
|
setInput('')
|
|
270
291
|
}}
|
|
271
292
|
/>
|
|
272
|
-
{loading && touched ? (
|
|
273
|
-
<div className="basis-full mt-1 text-xs text-muted-foreground">Loading suggestions
|
|
293
|
+
{suggestionsOpen && loading && touched ? (
|
|
294
|
+
<div className="basis-full mt-1 text-xs text-muted-foreground">{t('ui.inputs.tagsInput.loading', 'Loading suggestions…')}</div>
|
|
274
295
|
) : null}
|
|
275
|
-
{!loading && filteredSuggestions.length ? (
|
|
296
|
+
{suggestionsOpen && !loading && filteredSuggestions.length ? (
|
|
276
297
|
<div className="basis-full mt-1 flex flex-col gap-1">
|
|
277
298
|
{filteredSuggestions.map((option) => (
|
|
278
299
|
<Button
|
|
@@ -5,7 +5,7 @@ jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
|
5
5
|
}))
|
|
6
6
|
|
|
7
7
|
import * as React from 'react'
|
|
8
|
-
import { fireEvent, render, screen } from '@testing-library/react'
|
|
8
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
9
9
|
import { LookupSelect } from '../LookupSelect'
|
|
10
10
|
|
|
11
11
|
function getInput(container: HTMLElement): HTMLInputElement {
|
|
@@ -308,6 +308,171 @@ describe('LookupSelect selected value display', () => {
|
|
|
308
308
|
})
|
|
309
309
|
})
|
|
310
310
|
|
|
311
|
+
// A set `value` opens the list and fires a browse fetch with an empty query. Its
|
|
312
|
+
// result used to replace the items outright, so an edit form hydrated with
|
|
313
|
+
// `value={storedId}` + `options={[storedOption]}` lost the stored selection from
|
|
314
|
+
// the list whenever the record fell outside that arbitrary first page — the form
|
|
315
|
+
// still held the id, but nothing on screen was marked as chosen.
|
|
316
|
+
describe('LookupSelect keeps the selection across browse fetches', () => {
|
|
317
|
+
const STORED = { id: 'contractor-99', title: 'Stored Contractor' }
|
|
318
|
+
const FIRST_PAGE = [
|
|
319
|
+
{ id: 'contractor-1', title: 'Alpha' },
|
|
320
|
+
{ id: 'contractor-2', title: 'Beta' },
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
it('keeps a hydrated selection that is missing from the first page', async () => {
|
|
324
|
+
const fetchItems = jest.fn(async () => FIRST_PAGE)
|
|
325
|
+
render(
|
|
326
|
+
<LookupSelect
|
|
327
|
+
value={STORED.id}
|
|
328
|
+
onChange={() => {}}
|
|
329
|
+
options={[STORED]}
|
|
330
|
+
fetchItems={fetchItems}
|
|
331
|
+
/>,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
await screen.findByText('Alpha')
|
|
335
|
+
expect(fetchItems).toHaveBeenCalledWith('')
|
|
336
|
+
expect(screen.getByRole('option', { selected: true })).toHaveTextContent(STORED.title)
|
|
337
|
+
expect(screen.getAllByRole('option')).toHaveLength(3)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('uses the fetched copy and adds no duplicate when the page contains the selection', async () => {
|
|
341
|
+
const fetchItems = jest.fn(async () => [
|
|
342
|
+
...FIRST_PAGE,
|
|
343
|
+
{ id: STORED.id, title: 'Stored Contractor (renamed)' },
|
|
344
|
+
])
|
|
345
|
+
render(
|
|
346
|
+
<LookupSelect
|
|
347
|
+
value={STORED.id}
|
|
348
|
+
onChange={() => {}}
|
|
349
|
+
options={[STORED]}
|
|
350
|
+
fetchItems={fetchItems}
|
|
351
|
+
/>,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
await screen.findByText('Alpha')
|
|
355
|
+
expect(screen.getAllByRole('option')).toHaveLength(3)
|
|
356
|
+
expect(screen.getByRole('option', { selected: true })).toHaveTextContent('Stored Contractor (renamed)')
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('does not inject the selection into the results of a typed search', async () => {
|
|
360
|
+
const fetchItems = jest.fn(async (query: string) => (query ? [FIRST_PAGE[0]] : FIRST_PAGE))
|
|
361
|
+
const { container } = render(
|
|
362
|
+
<LookupSelect
|
|
363
|
+
value={STORED.id}
|
|
364
|
+
onChange={() => {}}
|
|
365
|
+
options={[STORED]}
|
|
366
|
+
fetchItems={fetchItems}
|
|
367
|
+
/>,
|
|
368
|
+
)
|
|
369
|
+
await screen.findByText('Beta')
|
|
370
|
+
|
|
371
|
+
fireEvent.change(getInput(container), { target: { value: 'Alp' } })
|
|
372
|
+
await waitFor(() => expect(screen.queryByText('Beta')).toBeNull())
|
|
373
|
+
|
|
374
|
+
expect(screen.getAllByRole('option')).toHaveLength(1)
|
|
375
|
+
expect(screen.getByRole('option')).toHaveTextContent('Alpha')
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('restores the selection once the typed query is cleared again', async () => {
|
|
379
|
+
const fetchItems = jest.fn(async (query: string) => (query ? [FIRST_PAGE[0]] : FIRST_PAGE))
|
|
380
|
+
const { container } = render(
|
|
381
|
+
<LookupSelect
|
|
382
|
+
value={STORED.id}
|
|
383
|
+
onChange={() => {}}
|
|
384
|
+
options={[STORED]}
|
|
385
|
+
fetchItems={fetchItems}
|
|
386
|
+
/>,
|
|
387
|
+
)
|
|
388
|
+
const input = getInput(container)
|
|
389
|
+
await screen.findByText('Beta')
|
|
390
|
+
|
|
391
|
+
fireEvent.change(input, { target: { value: 'Alp' } })
|
|
392
|
+
await waitFor(() => expect(screen.queryByText('Beta')).toBeNull())
|
|
393
|
+
fireEvent.change(input, { target: { value: '' } })
|
|
394
|
+
|
|
395
|
+
await screen.findByText('Beta')
|
|
396
|
+
expect(screen.getByRole('option', { selected: true })).toHaveTextContent(STORED.title)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('keeps a selection picked from a search when the list goes back to browsing', async () => {
|
|
400
|
+
const fetchItems = jest.fn(async (query: string) => (query ? [STORED] : FIRST_PAGE))
|
|
401
|
+
function Harness() {
|
|
402
|
+
const [value, setValue] = React.useState<string | null>(null)
|
|
403
|
+
return <LookupSelect value={value} onChange={setValue} fetchItems={fetchItems} />
|
|
404
|
+
}
|
|
405
|
+
const { container } = render(<Harness />)
|
|
406
|
+
const input = getInput(container)
|
|
407
|
+
|
|
408
|
+
fireEvent.change(input, { target: { value: 'Stored' } })
|
|
409
|
+
fireEvent.click(await screen.findByRole('option'))
|
|
410
|
+
fireEvent.change(input, { target: { value: '' } })
|
|
411
|
+
|
|
412
|
+
await screen.findByText('Alpha')
|
|
413
|
+
expect(screen.getByRole('option', { selected: true })).toHaveTextContent(STORED.title)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('keeps a known selection listed when the browse fetch comes back empty', async () => {
|
|
417
|
+
const fetchItems = jest.fn(async (query: string) => (query ? [STORED] : []))
|
|
418
|
+
function Harness() {
|
|
419
|
+
const [value, setValue] = React.useState<string | null>(null)
|
|
420
|
+
return <LookupSelect value={value} onChange={setValue} fetchItems={fetchItems} />
|
|
421
|
+
}
|
|
422
|
+
const { container } = render(<Harness />)
|
|
423
|
+
const input = getInput(container)
|
|
424
|
+
|
|
425
|
+
fireEvent.change(input, { target: { value: 'Stored' } })
|
|
426
|
+
fireEvent.click(await screen.findByRole('option'))
|
|
427
|
+
fireEvent.change(input, { target: { value: '' } })
|
|
428
|
+
|
|
429
|
+
await waitFor(() => expect(fetchItems).toHaveBeenLastCalledWith(''))
|
|
430
|
+
await waitFor(() => expect(screen.queryByText('Searching…')).toBeNull())
|
|
431
|
+
expect(screen.getByRole('option', { selected: true })).toHaveTextContent(STORED.title)
|
|
432
|
+
expect(screen.getAllByRole('option')).toHaveLength(1)
|
|
433
|
+
expect(screen.getByRole('button', { name: 'Clear selection' })).toBeInTheDocument()
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('never re-adds a previous selection after the value moves on', async () => {
|
|
437
|
+
const fetchItems = jest.fn(async (query: string) => (query ? [FIRST_PAGE[0]] : FIRST_PAGE))
|
|
438
|
+
const view = render(
|
|
439
|
+
<LookupSelect
|
|
440
|
+
value={STORED.id}
|
|
441
|
+
onChange={() => {}}
|
|
442
|
+
options={[STORED]}
|
|
443
|
+
fetchItems={fetchItems}
|
|
444
|
+
/>,
|
|
445
|
+
)
|
|
446
|
+
await screen.findByText('Beta')
|
|
447
|
+
|
|
448
|
+
view.rerender(
|
|
449
|
+
<LookupSelect
|
|
450
|
+
value="contractor-unknown"
|
|
451
|
+
onChange={() => {}}
|
|
452
|
+
options={[STORED]}
|
|
453
|
+
fetchItems={fetchItems}
|
|
454
|
+
/>,
|
|
455
|
+
)
|
|
456
|
+
const input = getInput(view.container)
|
|
457
|
+
fireEvent.change(input, { target: { value: 'Alp' } })
|
|
458
|
+
await waitFor(() => expect(screen.queryByText('Beta')).toBeNull())
|
|
459
|
+
fireEvent.change(input, { target: { value: '' } })
|
|
460
|
+
|
|
461
|
+
await screen.findByText('Beta')
|
|
462
|
+
expect(screen.queryByText(STORED.title)).toBeNull()
|
|
463
|
+
expect(screen.queryByRole('option', { selected: true })).toBeNull()
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('leaves the browse results untouched when nothing is selected', async () => {
|
|
467
|
+
render(
|
|
468
|
+
<LookupSelect value={null} onChange={() => {}} fetchItems={async () => FIRST_PAGE} minQuery={0} />,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
expect(await screen.findAllByRole('option')).toHaveLength(2)
|
|
472
|
+
expect(screen.queryByRole('option', { selected: true })).toBeNull()
|
|
473
|
+
})
|
|
474
|
+
})
|
|
475
|
+
|
|
311
476
|
// `disabled` used to gate only the search box, so a caller that locked the
|
|
312
477
|
// control still shipped a live option list: the selected card kept its click and
|
|
313
478
|
// Enter/Space handlers, "Clear selection" stayed reachable, and the action slot
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
4
|
+
useT: () => (_key: string, fallback: string) => fallback,
|
|
5
|
+
useOptionalLocale: () => undefined,
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
jest.mock('../../../primitives/select', () => {
|
|
9
|
+
const React = jest.requireActual('react')
|
|
10
|
+
return {
|
|
11
|
+
Select: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
12
|
+
SelectTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
13
|
+
SelectContent: ({ children }: { children: React.ReactNode }) => <ul>{children}</ul>,
|
|
14
|
+
SelectItemLeading: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
|
15
|
+
SelectItem: ({ children, value, textValue }: { children: React.ReactNode; value: string; textValue?: string }) => (
|
|
16
|
+
<li data-testid="country-option" data-value={value} data-text-value={textValue ?? ''}>
|
|
17
|
+
{children}
|
|
18
|
+
</li>
|
|
19
|
+
),
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
import * as React from 'react'
|
|
24
|
+
import { render, screen } from '@testing-library/react'
|
|
25
|
+
import { PHONE_COUNTRIES, PhoneNumberField } from '../PhoneNumberField'
|
|
26
|
+
|
|
27
|
+
describe('PhoneNumberField country typeahead', () => {
|
|
28
|
+
it('uses the country name as the searchable text of each option', () => {
|
|
29
|
+
render(<PhoneNumberField value={undefined} onValueChange={() => undefined} />)
|
|
30
|
+
|
|
31
|
+
const options = screen.getAllByTestId('country-option')
|
|
32
|
+
expect(options).toHaveLength(PHONE_COUNTRIES.length)
|
|
33
|
+
|
|
34
|
+
const poland = options.find((option) => option.getAttribute('data-value') === 'PL')
|
|
35
|
+
expect(poland?.getAttribute('data-text-value')).toMatch(/^Pol/)
|
|
36
|
+
|
|
37
|
+
for (const option of options) {
|
|
38
|
+
const textValue = option.getAttribute('data-text-value') ?? ''
|
|
39
|
+
expect(textValue).not.toBe('')
|
|
40
|
+
expect(textValue).not.toMatch(/^[\u{1F1E6}-\u{1F1FF}+]/u)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
})
|