@open-mercato/ui 0.6.3-develop.3838.1.7f2657a47c → 0.6.3-develop.3851.1.642df0a016
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/dist/backend/CrudForm.js +1 -0
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +104 -26
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/utils/api.js +17 -14
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/theme/QueryProvider.js +5 -5
- package/dist/theme/QueryProvider.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +1 -0
- package/src/backend/__tests__/nav-utils.test.ts +73 -0
- package/src/backend/inputs/ComboboxInput.tsx +136 -35
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +241 -10
- package/src/backend/utils/__tests__/api.test.ts +27 -2
- package/src/backend/utils/api.ts +34 -19
- package/src/theme/QueryProvider.tsx +7 -6
|
@@ -129,4 +129,77 @@ describe('settings navigation helpers', () => {
|
|
|
129
129
|
|
|
130
130
|
expect(entries.map((item) => item.href)).toContain('/backend/customer_accounts/users')
|
|
131
131
|
})
|
|
132
|
+
|
|
133
|
+
// Regression for GH #2070: an employee without any `configs.*` grant must not
|
|
134
|
+
// see the system-status page in the sidebar (including settings sections),
|
|
135
|
+
// even when modules with adjacent prefixes are granted.
|
|
136
|
+
it('hides routes whose required features are not granted, including settings-context pages', async () => {
|
|
137
|
+
const employeeGrants = [
|
|
138
|
+
'sales.orders.view',
|
|
139
|
+
'sales.quotes.view',
|
|
140
|
+
'inbox_ops.proposals.view',
|
|
141
|
+
]
|
|
142
|
+
const entries = await buildAdminNav(
|
|
143
|
+
[
|
|
144
|
+
{
|
|
145
|
+
id: 'sales',
|
|
146
|
+
backendRoutes: [
|
|
147
|
+
{
|
|
148
|
+
pattern: '/backend/sales/orders',
|
|
149
|
+
title: 'Orders',
|
|
150
|
+
requireFeatures: ['sales.orders.view'],
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
pattern: '/backend/sales/quotes',
|
|
154
|
+
title: 'Quotes',
|
|
155
|
+
requireFeatures: ['sales.quotes.view'],
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: 'inbox_ops',
|
|
161
|
+
backendRoutes: [
|
|
162
|
+
{
|
|
163
|
+
pattern: '/backend/inbox-ops',
|
|
164
|
+
title: 'Proposals',
|
|
165
|
+
requireFeatures: ['inbox_ops.proposals.view'],
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: 'configs',
|
|
171
|
+
backendRoutes: [
|
|
172
|
+
{
|
|
173
|
+
pattern: '/backend/config/system-status',
|
|
174
|
+
title: 'System status',
|
|
175
|
+
requireFeatures: ['configs.system_status.view'],
|
|
176
|
+
pageContext: 'settings',
|
|
177
|
+
group: 'System',
|
|
178
|
+
groupKey: 'settings.sections.system',
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
{ auth: { roles: [] } },
|
|
184
|
+
undefined,
|
|
185
|
+
undefined,
|
|
186
|
+
{
|
|
187
|
+
checkFeatures: async (features: string[]) =>
|
|
188
|
+
features.filter((feature) => employeeGrants.includes(feature)),
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
const hrefs = entries.map((item) => item.href)
|
|
193
|
+
expect(hrefs).toContain('/backend/sales/orders')
|
|
194
|
+
expect(hrefs).toContain('/backend/sales/quotes')
|
|
195
|
+
expect(hrefs).toContain('/backend/inbox-ops')
|
|
196
|
+
expect(hrefs).not.toContain('/backend/config/system-status')
|
|
197
|
+
|
|
198
|
+
// The filtered settings entry must also be absent from any settings section.
|
|
199
|
+
const settingsSections = buildSettingsSections(entries, { system: 1 })
|
|
200
|
+
const allSettingsHrefs = settingsSections.flatMap((section) =>
|
|
201
|
+
section.items.map((item) => item.href),
|
|
202
|
+
)
|
|
203
|
+
expect(allSettingsHrefs).not.toContain('/backend/config/system-status')
|
|
204
|
+
})
|
|
132
205
|
})
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
+
import { X } from 'lucide-react'
|
|
4
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
5
6
|
import { Button } from '../../primitives/button'
|
|
7
|
+
import { IconButton } from '../../primitives/icon-button'
|
|
6
8
|
|
|
7
9
|
export type ComboboxOption = {
|
|
8
10
|
value: string
|
|
@@ -28,6 +30,8 @@ export type ComboboxInputProps = {
|
|
|
28
30
|
autoFocus?: boolean
|
|
29
31
|
disabled?: boolean
|
|
30
32
|
allowCustomValues?: boolean
|
|
33
|
+
clearable?: boolean
|
|
34
|
+
clearLabel?: string
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
function normalizeOptions(input?: Array<string | ComboboxOption>): ComboboxOption[] {
|
|
@@ -72,10 +76,15 @@ export function ComboboxInput({
|
|
|
72
76
|
autoFocus,
|
|
73
77
|
disabled = false,
|
|
74
78
|
allowCustomValues = true,
|
|
79
|
+
clearable = false,
|
|
80
|
+
clearLabel,
|
|
75
81
|
}: ComboboxInputProps) {
|
|
76
82
|
const t = useT()
|
|
77
83
|
const resolvedPlaceholder = placeholder ?? t('ui.inputs.comboboxInput.placeholder', 'Type to search...')
|
|
78
84
|
const loadingLabel = t('ui.inputs.comboboxInput.loading', 'Loading suggestions…')
|
|
85
|
+
const resolvedClearLabel = clearLabel ?? t('ui.inputs.comboboxInput.clear', 'Clear value')
|
|
86
|
+
const blurCloseDelayMs = 250
|
|
87
|
+
const blurCloseMaxDelayMs = 1000
|
|
79
88
|
const [input, setInput] = React.useState('')
|
|
80
89
|
const [asyncOptions, setAsyncOptions] = React.useState<ComboboxOption[]>([])
|
|
81
90
|
const [resolvedOptions, setResolvedOptions] = React.useState<ComboboxOption[]>([])
|
|
@@ -84,6 +93,9 @@ export function ComboboxInput({
|
|
|
84
93
|
const [showSuggestions, setShowSuggestions] = React.useState(false)
|
|
85
94
|
const [selectedIndex, setSelectedIndex] = React.useState(-1)
|
|
86
95
|
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
96
|
+
const loadingRef = React.useRef(false)
|
|
97
|
+
const blurCloseTimerRef = React.useRef<number | null>(null)
|
|
98
|
+
const blurClosePendingRef = React.useRef(false)
|
|
87
99
|
const suppressOpenOnFocusRef = React.useRef(Boolean(autoFocus && !disabled))
|
|
88
100
|
const eagerFallbackLoadedValueRef = React.useRef<string | null>(null)
|
|
89
101
|
|
|
@@ -92,22 +104,18 @@ export function ComboboxInput({
|
|
|
92
104
|
[seedOptions, suggestions]
|
|
93
105
|
)
|
|
94
106
|
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
107
|
+
// Single pass over all option sources to build both coverage sets at once.
|
|
108
|
+
// knownLabelValues: values with a genuine label (not a self-mapping placeholder) —
|
|
109
|
+
// used to decide whether eager resolution still needs to run.
|
|
110
|
+
// coveredOptionValues: all values present in any source (even self-mapped).
|
|
111
|
+
const { knownLabelValues, coveredOptionValues } = React.useMemo(() => {
|
|
112
|
+
const known = new Set<string>()
|
|
113
|
+
const covered = new Set<string>()
|
|
114
|
+
for (const opt of [...staticOptions, ...asyncOptions, ...resolvedOptions]) {
|
|
115
|
+
covered.add(opt.value)
|
|
116
|
+
if (opt.label && opt.label !== opt.value) known.add(opt.value)
|
|
101
117
|
}
|
|
102
|
-
return
|
|
103
|
-
}, [staticOptions, asyncOptions, resolvedOptions])
|
|
104
|
-
|
|
105
|
-
const coveredOptionValues = React.useMemo(() => {
|
|
106
|
-
const set = new Set<string>()
|
|
107
|
-
for (const option of [...staticOptions, ...asyncOptions, ...resolvedOptions]) {
|
|
108
|
-
set.add(option.value)
|
|
109
|
-
}
|
|
110
|
-
return set
|
|
118
|
+
return { knownLabelValues: known, coveredOptionValues: covered }
|
|
111
119
|
}, [staticOptions, asyncOptions, resolvedOptions])
|
|
112
120
|
|
|
113
121
|
const optionMap = React.useMemo(() => {
|
|
@@ -139,6 +147,23 @@ export function ComboboxInput({
|
|
|
139
147
|
return Array.from(optionMap.values())
|
|
140
148
|
}, [optionMap])
|
|
141
149
|
|
|
150
|
+
React.useEffect(() => {
|
|
151
|
+
loadingRef.current = loading
|
|
152
|
+
}, [loading])
|
|
153
|
+
|
|
154
|
+
const clearBlurCloseTimer = React.useCallback(() => {
|
|
155
|
+
if (blurCloseTimerRef.current === null) return
|
|
156
|
+
window.clearTimeout(blurCloseTimerRef.current)
|
|
157
|
+
blurCloseTimerRef.current = null
|
|
158
|
+
}, [])
|
|
159
|
+
|
|
160
|
+
const resetBlurCloseState = React.useCallback(() => {
|
|
161
|
+
blurClosePendingRef.current = false
|
|
162
|
+
clearBlurCloseTimer()
|
|
163
|
+
}, [clearBlurCloseTimer])
|
|
164
|
+
|
|
165
|
+
React.useEffect(() => resetBlurCloseState, [resetBlurCloseState])
|
|
166
|
+
|
|
142
167
|
const filteredSuggestions = React.useMemo(() => {
|
|
143
168
|
const query = input.toLowerCase().trim()
|
|
144
169
|
if (!query) return availableOptions
|
|
@@ -153,17 +178,20 @@ export function ComboboxInput({
|
|
|
153
178
|
if (!loadSuggestions || !touched || disabled) return
|
|
154
179
|
const query = input.trim()
|
|
155
180
|
let cancelled = false
|
|
156
|
-
const handle = window.setTimeout(
|
|
181
|
+
const handle = window.setTimeout(() => {
|
|
157
182
|
setLoading(true)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
183
|
+
Promise.resolve()
|
|
184
|
+
.then(() => loadSuggestions(query))
|
|
185
|
+
.then((items) => {
|
|
186
|
+
if (!cancelled) {
|
|
187
|
+
const normalized = normalizeOptions(items)
|
|
188
|
+
setAsyncOptions((prev) => areOptionsEqual(prev, normalized) ? prev : normalized)
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
.catch(() => {})
|
|
192
|
+
.finally(() => {
|
|
193
|
+
if (!cancelled) setLoading(false)
|
|
194
|
+
})
|
|
167
195
|
}, 200)
|
|
168
196
|
return () => {
|
|
169
197
|
cancelled = true
|
|
@@ -198,6 +226,8 @@ export function ComboboxInput({
|
|
|
198
226
|
eagerFallbackLoadedValueRef.current = value
|
|
199
227
|
// Fallback: pull the first page of async suggestions so a remount that lost
|
|
200
228
|
// its option cache can still recover the label without user interaction.
|
|
229
|
+
// Note: if the loader is paginated and the value falls outside the first page,
|
|
230
|
+
// the fallback silently fails and the raw value remains visible.
|
|
201
231
|
if (loadSuggestions) {
|
|
202
232
|
setLoading(true)
|
|
203
233
|
Promise.resolve()
|
|
@@ -211,7 +241,8 @@ export function ComboboxInput({
|
|
|
211
241
|
.finally(() => { if (!cancelled) setLoading(false) })
|
|
212
242
|
}
|
|
213
243
|
return () => { cancelled = true }
|
|
214
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
244
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps — resolveDescription intentionally excluded:
|
|
245
|
+
// including it would re-run the effect on every render when the prop is an inline function
|
|
215
246
|
}, [value, disabled, knownLabelValues, coveredOptionValues, eagerResolveLabel, loadSuggestions])
|
|
216
247
|
|
|
217
248
|
// Sync input with value when value changes externally and input is not focused.
|
|
@@ -225,6 +256,7 @@ export function ComboboxInput({
|
|
|
225
256
|
const selectValue = React.useCallback(
|
|
226
257
|
(nextValue: string) => {
|
|
227
258
|
if (disabled) return
|
|
259
|
+
resetBlurCloseState()
|
|
228
260
|
const trimmed = nextValue.trim()
|
|
229
261
|
onChange(trimmed)
|
|
230
262
|
const option = optionMap.get(trimmed)
|
|
@@ -232,7 +264,7 @@ export function ComboboxInput({
|
|
|
232
264
|
setShowSuggestions(false)
|
|
233
265
|
setSelectedIndex(-1)
|
|
234
266
|
},
|
|
235
|
-
[disabled, onChange, optionMap]
|
|
267
|
+
[disabled, onChange, optionMap, resetBlurCloseState]
|
|
236
268
|
)
|
|
237
269
|
|
|
238
270
|
const findOptionForInput = React.useCallback(
|
|
@@ -251,6 +283,10 @@ export function ComboboxInput({
|
|
|
251
283
|
const confirmSelection = React.useCallback(
|
|
252
284
|
(raw: string) => {
|
|
253
285
|
if (disabled) return
|
|
286
|
+
if (clearable && raw.trim() === '') {
|
|
287
|
+
selectValue('')
|
|
288
|
+
return
|
|
289
|
+
}
|
|
254
290
|
const option = findOptionForInput(raw)
|
|
255
291
|
if (option) {
|
|
256
292
|
selectValue(option.value)
|
|
@@ -271,9 +307,41 @@ export function ComboboxInput({
|
|
|
271
307
|
}
|
|
272
308
|
selectValue(raw)
|
|
273
309
|
},
|
|
274
|
-
[allowCustomValues, disabled, findOptionForInput, optionMap, selectValue, value]
|
|
310
|
+
[allowCustomValues, clearable, disabled, findOptionForInput, optionMap, selectValue, value]
|
|
275
311
|
)
|
|
276
312
|
|
|
313
|
+
const handleClear = React.useCallback(() => {
|
|
314
|
+
if (disabled) return
|
|
315
|
+
selectValue('')
|
|
316
|
+
inputRef.current?.focus()
|
|
317
|
+
}, [disabled, selectValue])
|
|
318
|
+
|
|
319
|
+
const closeAfterBlur = React.useCallback(() => {
|
|
320
|
+
blurCloseTimerRef.current = null
|
|
321
|
+
if (disabled) return
|
|
322
|
+
blurClosePendingRef.current = false
|
|
323
|
+
confirmSelection(input)
|
|
324
|
+
setShowSuggestions(false)
|
|
325
|
+
setSelectedIndex(-1)
|
|
326
|
+
}, [confirmSelection, disabled, input])
|
|
327
|
+
|
|
328
|
+
const attemptBlurClose = React.useCallback(() => {
|
|
329
|
+
blurCloseTimerRef.current = null
|
|
330
|
+
if (disabled) return
|
|
331
|
+
if (loadingRef.current) {
|
|
332
|
+
blurCloseTimerRef.current = window.setTimeout(closeAfterBlur, blurCloseMaxDelayMs)
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
closeAfterBlur()
|
|
336
|
+
}, [blurCloseMaxDelayMs, closeAfterBlur, disabled])
|
|
337
|
+
|
|
338
|
+
React.useEffect(() => {
|
|
339
|
+
if (!blurClosePendingRef.current) return
|
|
340
|
+
if (loading) return
|
|
341
|
+
clearBlurCloseTimer()
|
|
342
|
+
closeAfterBlur()
|
|
343
|
+
}, [clearBlurCloseTimer, closeAfterBlur, loading])
|
|
344
|
+
|
|
277
345
|
const handleKeyDown = React.useCallback(
|
|
278
346
|
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
279
347
|
if (disabled) return
|
|
@@ -305,6 +373,8 @@ export function ComboboxInput({
|
|
|
305
373
|
[confirmSelection, disabled, filteredSuggestions, input, selectValue, selectedIndex, showSuggestions]
|
|
306
374
|
)
|
|
307
375
|
|
|
376
|
+
const showClearButton = clearable && !disabled && (value !== '' || input !== '')
|
|
377
|
+
|
|
308
378
|
return (
|
|
309
379
|
<div className="relative w-full">
|
|
310
380
|
{/* Use raw <input> here instead of the DS Input primitive: ComboboxInput's
|
|
@@ -314,7 +384,12 @@ export function ComboboxInput({
|
|
|
314
384
|
<input
|
|
315
385
|
ref={inputRef}
|
|
316
386
|
type="text"
|
|
317
|
-
className=
|
|
387
|
+
className={[
|
|
388
|
+
'w-full h-9 rounded-md border border-input bg-background px-3 text-sm shadow-xs transition-colors outline-none placeholder:text-muted-foreground focus-visible:shadow-focus focus-visible:border-foreground disabled:bg-bg-disabled disabled:border-border-disabled disabled:text-muted-foreground disabled:cursor-not-allowed',
|
|
389
|
+
showClearButton ? 'pr-9' : '',
|
|
390
|
+
]
|
|
391
|
+
.filter(Boolean)
|
|
392
|
+
.join(' ')}
|
|
318
393
|
value={input}
|
|
319
394
|
placeholder={resolvedPlaceholder}
|
|
320
395
|
autoFocus={autoFocus}
|
|
@@ -326,6 +401,10 @@ export function ComboboxInput({
|
|
|
326
401
|
suppressOpenOnFocusRef.current = false
|
|
327
402
|
return
|
|
328
403
|
}
|
|
404
|
+
resetBlurCloseState()
|
|
405
|
+
if (loadSuggestions && availableOptions.length === 0) {
|
|
406
|
+
setLoading(true)
|
|
407
|
+
}
|
|
329
408
|
setShowSuggestions(true)
|
|
330
409
|
}}
|
|
331
410
|
onChange={(event) => {
|
|
@@ -336,14 +415,33 @@ export function ComboboxInput({
|
|
|
336
415
|
}}
|
|
337
416
|
onKeyDown={handleKeyDown}
|
|
338
417
|
onBlur={() => {
|
|
339
|
-
// Delay
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
418
|
+
// Delay closing so clicks on the popup can resolve first. If async
|
|
419
|
+
// suggestions are still loading, keep the dropdown open instead of
|
|
420
|
+
// closing before the first payload arrives.
|
|
421
|
+
blurClosePendingRef.current = true
|
|
422
|
+
clearBlurCloseTimer()
|
|
423
|
+
if (loadingRef.current) {
|
|
424
|
+
blurCloseTimerRef.current = window.setTimeout(closeAfterBlur, blurCloseMaxDelayMs)
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
blurCloseTimerRef.current = window.setTimeout(attemptBlurClose, blurCloseDelayMs)
|
|
344
428
|
}}
|
|
345
429
|
/>
|
|
346
430
|
|
|
431
|
+
{showClearButton ? (
|
|
432
|
+
<IconButton
|
|
433
|
+
type="button"
|
|
434
|
+
variant="ghost"
|
|
435
|
+
size="xs"
|
|
436
|
+
aria-label={resolvedClearLabel}
|
|
437
|
+
className="absolute right-1 top-1/2 -translate-y-1/2"
|
|
438
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
439
|
+
onClick={handleClear}
|
|
440
|
+
>
|
|
441
|
+
<X className="size-3" />
|
|
442
|
+
</IconButton>
|
|
443
|
+
) : null}
|
|
444
|
+
|
|
347
445
|
{showSuggestions && !disabled && (loading || filteredSuggestions.length > 0) && (
|
|
348
446
|
<div className="absolute z-popover w-full mt-1 rounded-md border border-input bg-popover p-2 shadow-md max-h-48 sm:max-h-60 overflow-auto">
|
|
349
447
|
{loading && touched ? (
|
|
@@ -363,7 +461,10 @@ export function ComboboxInput({
|
|
|
363
461
|
.filter(Boolean)
|
|
364
462
|
.join(' ')}
|
|
365
463
|
onMouseDown={(event) => event.preventDefault()}
|
|
366
|
-
onClick={() =>
|
|
464
|
+
onClick={() => {
|
|
465
|
+
resetBlurCloseState()
|
|
466
|
+
selectValue(option.value)
|
|
467
|
+
}}
|
|
367
468
|
onMouseEnter={() => setSelectedIndex(index)}
|
|
368
469
|
>
|
|
369
470
|
<span className="font-medium text-foreground">{option.label}</span>
|