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
307 lines
10 KiB
TypeScript
Vendored
307 lines
10 KiB
TypeScript
Vendored
/*
|
||
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>
|
||
)
|
||
}
|