new-api/web/default/src/components/multi-select.tsx
t0ng7u 3360882642 ♻️ refactor(channels): rebuild channel editor UX with modular sections and Base UI multi-select
Restructure the default-theme channel create/edit experience to match classic
frontend behavior, improve form UX, and align with the project's Base UI design
system.

Channel editor architecture:
- Split the monolithic channel mutate drawer into focused section components
  (basic, API access, auth, models, advanced) with shared drawer layout
  primitives
- Extract submission, toast handling, and react-query cache invalidation into
  `useChannelMutateForm`
- Add a dedicated loading skeleton for channel detail fetch during edit mode
- Remove the top-level configuration summary block

Form validation and data handling:
- Strengthen `channel-form` Zod schema with JSON, model mapping, status code
  mapping, Codex credential, and Vertex AI key refinements
- Move type-specific conditional validation into `superRefine`
- Normalize base URL formatting and tighten model mapping value validation

Model mapping editor:
- Add Visual/JSON tabbed editing with inline JSON and duplicate-key feedback
- Improve accessibility for icon-only actions and add model suggestion datalists

MultiSelect component:
- Replace the custom cmdk-based implementation with Base UI Combobox chips
- Align focus, border, ring, disabled, and invalid states with standard Input
  styling via `ComboboxChips`
- Preserve existing API for all current callers (`options`, `selected`,
  `onChange`, `allowCreate`, `createLabel`)
- Support inline custom value creation and comma/newline batch input
- Limit visible chips with a compact "+N more" overflow summary via
  `maxVisibleChips` (8 in the channel editor)
- Anchor the dropdown to the full chips container via `useComboboxAnchor` so
  the popup matches input width and long model names are no longer truncated

Models & groups UX:
- Integrate manual custom model entry directly into the model MultiSelect
- Remove the separate manual model input/button block
- Keep selected-model count badge and existing model-mapping guardrail behavior

i18n:
- Add and sync translation keys for section descriptions, validation messages,
  model mapping UI, and MultiSelect labels across en, zh, fr, ja, ru, and vi
- Fix missing translations for "Name, provider type, and availability.",
  "Endpoint, provider-specific settings, and credentials.", and "Published
  models, groups, and model remapping rules."
- Remove obsolete keys tied to the deprecated summary and manual model entry UI
2026-05-26 01:55:27 +08:00

307 lines
10 KiB
TypeScript
Vendored
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import * as React from 'react'
import { Add01Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from '@/components/ui/combobox'
export type Option = {
label: string
value: string
}
interface MultiSelectProps {
options: Option[]
selected: string[]
onChange: (values: string[]) => void
placeholder?: string
className?: string
allowCreate?: boolean
/**
* Label shown for the "create" item in the dropdown.
* Supports the `{{value}}` placeholder which is replaced with the typed input.
* Falls back to `Add "{{value}}"` when omitted.
*/
createLabel?: string
/** Empty state text. Defaults to "No matching items". */
emptyText?: string
/** Optional `id` to wire labels/aria-describedby to the input. */
id?: string
/** Disable the entire control. */
disabled?: boolean
/**
* Limits rendered chips while keeping all values selected.
* Hidden values remain searchable/removable from the dropdown.
*/
maxVisibleChips?: number
}
const COMMA_REGEX = /[,\n]/
function splitDraft(value: string): { completed: string[]; draft: string } {
if (!COMMA_REGEX.test(value)) {
return { completed: [], draft: value }
}
const normalized = value.replaceAll('', ',').replaceAll('\n', ',')
const parts = normalized.split(',')
const draft = parts.at(-1) ?? ''
const completed = parts
.slice(0, -1)
.map((part) => part.trim())
.filter(Boolean)
return { completed, draft }
}
/**
* MultiSelect — tags/chips style multi-select built on Base UI Combobox.
*
* Behaviour:
* - Search filters built-in options (Base UI handles fuzzy filtering).
* - When `allowCreate` is true, custom values can be added inline:
* - Type and press Enter / "," to add a single value.
* - Paste a comma- (or newline-) separated list to add many at once.
* - A "Add \"<value>\"" item appears at the top of the dropdown when the
* typed text doesn't match any option.
* - Backspace on an empty input removes the last selected chip (Base UI default).
* - `maxVisibleChips` can cap large selections and show a compact "+N more"
* summary so forms do not grow vertically without bound.
*
* Focus/border styling is inherited from `ComboboxChips`, which uses the same
* tokens as `Input` so it stays visually consistent with other form fields.
*/
export function MultiSelect(props: MultiSelectProps) {
const { t } = useTranslation()
const placeholder = props.placeholder ?? t('Select items...')
// Anchor the popup to the chips container so its width tracks the entire
// input row, not just the leftover space at the end of wrapped chips.
const chipsAnchorRef = useComboboxAnchor()
const [inputValue, setInputValue] = React.useState('')
const [open, setOpen] = React.useState(false)
const selectedSet = React.useMemo(
() => new Set(props.selected),
[props.selected]
)
// Lookup of value -> display label so chips and items can show friendly names
// even when the underlying option list changes (e.g. custom-added values).
const labelMap = React.useMemo(() => {
const map = new Map<string, string>()
for (const option of props.options) {
map.set(option.value, option.label)
}
return map
}, [props.options])
const trimmedInput = inputValue.trim()
const inputMatchesExisting =
trimmedInput.length > 0 &&
(selectedSet.has(trimmedInput) ||
props.options.some(
(option) =>
option.value.toLowerCase() === trimmedInput.toLowerCase() ||
option.label.toLowerCase() === trimmedInput.toLowerCase()
))
const canCreate =
props.allowCreate === true &&
trimmedInput.length > 0 &&
!inputMatchesExisting
// We expose all known option values + every currently selected value to Base
// UI's items list. This way Base UI filters them by the search query and the
// user can still see the chip labels mapped correctly.
const items = React.useMemo(() => {
const set = new Set<string>(props.options.map((option) => option.value))
for (const value of props.selected) {
set.add(value)
}
if (canCreate) {
set.add(trimmedInput)
}
return Array.from(set)
}, [props.options, props.selected, canCreate, trimmedInput])
const addValues = React.useCallback(
(values: string[]) => {
const next: string[] = []
const seen = new Set<string>(props.selected)
for (const raw of values) {
const value = raw.trim()
if (!value) continue
if (seen.has(value)) continue
seen.add(value)
next.push(value)
}
if (next.length === 0) return
props.onChange([...props.selected, ...next])
},
[props]
)
const handleInputValueChange = (value: string) => {
if (!props.allowCreate) {
setInputValue(value)
return
}
const parsed = splitDraft(value)
if (parsed.completed.length > 0) {
addValues(parsed.completed)
setInputValue(parsed.draft)
return
}
setInputValue(value)
}
const handleValueChange = (next: string[]) => {
props.onChange(next)
// When an item is picked (multiple mode), Base UI keeps the input but most
// UX patterns clear it. Clearing once a value is added makes batch picking
// feel snappier and matches popular chip-style multiselects.
if (next.length > props.selected.length) {
setInputValue('')
}
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
// Enter without a highlighted option commits the typed value.
if (event.key === 'Enter' && props.allowCreate && canCreate) {
// Only fire when Base UI has no highlighted item to select. We rely on
// the highlighted item's data attribute on the popup. If the popup is
// closed or empty, manually commit the typed value.
const popup = document.querySelector<HTMLElement>(
'[data-slot="combobox-content"][data-open]'
)
const hasHighlight = popup?.querySelector('[data-highlighted]') != null
if (!hasHighlight) {
event.preventDefault()
addValues([trimmedInput])
setInputValue('')
}
}
}
return (
<Combobox
multiple
items={items}
value={props.selected}
onValueChange={handleValueChange}
inputValue={inputValue}
onInputValueChange={handleInputValueChange}
open={open}
onOpenChange={setOpen}
disabled={props.disabled}
>
<ComboboxChips
ref={chipsAnchorRef}
className={cn('w-full', props.className)}
>
<ComboboxValue>
{(values: string[]) => {
const visibleValues =
typeof props.maxVisibleChips === 'number'
? values.slice(0, props.maxVisibleChips)
: values
const hiddenCount = values.length - visibleValues.length
return (
<>
{visibleValues.map((value) => (
<ComboboxChip key={value}>
<span className='max-w-[16rem] truncate'>
{labelMap.get(value) ?? value}
</span>
</ComboboxChip>
))}
{hiddenCount > 0 && (
<span className='bg-muted text-muted-foreground flex h-[calc(--spacing(5.25))] w-fit items-center justify-center rounded-sm px-1.5 text-xs font-medium whitespace-nowrap'>
{t('+{{count}} more', { count: hiddenCount })}
</span>
)}
</>
)
}}
</ComboboxValue>
<ComboboxChipsInput
id={props.id}
placeholder={props.selected.length === 0 ? placeholder : undefined}
onKeyDown={handleKeyDown}
aria-label={placeholder}
/>
</ComboboxChips>
<ComboboxContent anchor={chipsAnchorRef}>
<ComboboxList>
<ComboboxCollection>
{(item: string) => {
const isCreate = canCreate && item === trimmedInput
const label = labelMap.get(item) ?? item
return (
<ComboboxItem
key={item}
value={item}
className={isCreate ? 'text-foreground' : undefined}
>
{isCreate ? (
<>
<HugeiconsIcon
icon={Add01Icon}
strokeWidth={2}
className='text-muted-foreground'
aria-hidden='true'
/>
<span className='truncate'>
{props.createLabel
? t(props.createLabel, { value: item })
: t('Add "{{value}}"', { value: item })}
</span>
</>
) : (
<span className='truncate'>{label}</span>
)}
</ComboboxItem>
)
}}
</ComboboxCollection>
</ComboboxList>
<ComboboxEmpty>
{props.emptyText ?? t('No matching items')}
</ComboboxEmpty>
</ComboboxContent>
</Combobox>
)
}