@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.
@@ -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
- // Options whose label is genuinely known (not a self-mapping `value === label`
96
- // placeholder). Used to decide whether eager resolution still needs to run.
97
- const knownLabelValues = React.useMemo(() => {
98
- const set = new Set<string>()
99
- for (const option of [...staticOptions, ...asyncOptions, ...resolvedOptions]) {
100
- if (option.label && option.label !== option.value) set.add(option.value)
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 set
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(async () => {
181
+ const handle = window.setTimeout(() => {
157
182
  setLoading(true)
158
- try {
159
- const items = await loadSuggestions(query)
160
- if (!cancelled) {
161
- const normalized = normalizeOptions(items)
162
- setAsyncOptions((prev) => areOptionsEqual(prev, normalized) ? prev : normalized)
163
- }
164
- } finally {
165
- if (!cancelled) setLoading(false)
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="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"
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 to allow click on suggestions
340
- setTimeout(() => {
341
- if (disabled) return
342
- confirmSelection(input)
343
- }, 200)
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={() => selectValue(option.value)}
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>