♻️ refactor(channels): rebuild channel create/edit drawer with modular sections and improved form UX

Restructure the default-theme channel create/edit experience to align with
classic frontend behavior, modern form UX patterns, and 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 per UX feedback

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 (`options`, `selected`, `onChange`, `allowCreate`,
  `createLabel`) for all current callers
- Support inline custom value creation, comma/newline batch input, searchable
  options, portal-based dropdown positioning, and chip removal

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 and existing model-mapping guardrail behavior

i18n:
- Add and sync translation keys for new editor sections, validation messages,
  model mapping UI, and MultiSelect empty/create labels across en, zh, fr, ja,
  ru, and vi
- Remove obsolete keys tied to the deprecated summary and manual model entry UI

Affected areas:
- `web/default/src/features/channels/components/drawers/`
- `web/default/src/features/channels/hooks/use-channel-mutate-form.ts`
- `web/default/src/features/channels/lib/channel-form.ts`
- `web/default/src/features/channels/lib/model-mapping-validation.ts`
- `web/default/src/features/channels/components/model-mapping-editor.tsx`
- `web/default/src/components/multi-select.tsx`
- `web/default/src/i18n/locales/*.json`
This commit is contained in:
t0ng7u
2026-05-26 01:22:49 +08:00
parent 583da45296
commit 3d850d38b6
19 changed files with 3465 additions and 2478 deletions
+232 -106
View File
@@ -8,21 +8,31 @@ 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
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/>.
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 { Command as CommandPrimitive } from 'cmdk'
import { X } from 'lucide-react'
import { Add01Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Command, CommandGroup, CommandItem } from '@/components/ui/command'
import { cn } from '@/lib/utils'
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
} from '@/components/ui/combobox'
export type Option = {
label: string
@@ -35,116 +45,232 @@ interface MultiSelectProps {
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
}
export function MultiSelect({
options,
selected,
onChange,
placeholder,
className,
}: MultiSelectProps) {
const { t } = useTranslation()
const resolvedPlaceholder = placeholder ?? t('Select items...')
const inputRef = React.useRef<HTMLInputElement>(null)
const [open, setOpen] = React.useState(false)
const [inputValue, setInputValue] = React.useState('')
const COMMA_REGEX = /[,\n]/
const handleUnselect = (value: string) => {
onChange(selected.filter((s) => s !== value))
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).
*
* 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...')
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 handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current
if (input) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (input.value === '' && selected.length > 0) {
onChange(selected.slice(0, -1))
}
}
if (e.key === 'Escape') {
input.blur()
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('')
}
}
}
const selectables = options.filter(
(option) => !selected.includes(option.value)
)
return (
<Command
onKeyDown={handleKeyDown}
className={`overflow-visible bg-transparent ${className || ''}`}
<Combobox
multiple
items={items}
value={props.selected}
onValueChange={handleValueChange}
inputValue={inputValue}
onInputValueChange={handleInputValueChange}
open={open}
onOpenChange={setOpen}
disabled={props.disabled}
>
<div className='group border-input ring-offset-background focus-within:ring-ring rounded-md border px-3 py-2 text-sm focus-within:ring-2 focus-within:ring-offset-2'>
<div className='flex flex-wrap gap-1'>
{selected.map((value) => {
const option = options.find((o) => o.value === value)
return (
<Badge key={value} variant='secondary'>
{option?.label || value}
<Button
variant='ghost'
size='icon-sm'
aria-label='Remove'
className='ml-1 size-auto p-0'
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUnselect(value)
}
}}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={() => handleUnselect(value)}
<ComboboxChips className={cn('w-full', props.className)}>
<ComboboxValue>
{(values: string[]) =>
values.map((value) => (
<ComboboxChip key={value}>
<span className='max-w-[16rem] truncate'>
{labelMap.get(value) ?? value}
</span>
</ComboboxChip>
))
}
</ComboboxValue>
<ComboboxChipsInput
id={props.id}
placeholder={props.selected.length === 0 ? placeholder : undefined}
onKeyDown={handleKeyDown}
aria-label={placeholder}
/>
</ComboboxChips>
<ComboboxContent>
<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}
>
<X
className='text-muted-foreground hover:text-foreground h-3 w-3'
aria-hidden='true'
/>
</Button>
</Badge>
)
})}
<CommandPrimitive.Input
ref={inputRef}
value={inputValue}
onValueChange={setInputValue}
onBlur={() => setOpen(false)}
onFocus={() => setOpen(true)}
placeholder={selected.length === 0 ? resolvedPlaceholder : ''}
className='placeholder:text-muted-foreground flex-1 bg-transparent outline-none'
/>
</div>
</div>
<div className='relative'>
{open && selectables.length > 0 ? (
<div className='bg-popover text-popover-foreground animate-in absolute top-0 z-10 w-full rounded-md border shadow-md outline-none'>
<CommandGroup className='h-full max-h-60 overflow-auto'>
{selectables.map((option) => {
return (
<CommandItem
key={option.value}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onSelect={() => {
setInputValue('')
onChange([...selected, option.value])
}}
className='cursor-pointer'
>
{option.label}
</CommandItem>
)
})}
</CommandGroup>
</div>
) : null}
</div>
</Command>
{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>
)
}