♻️ 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:
+232
-106
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user