♻️ 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
@@ -0,0 +1,106 @@
/*
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 { useMutation } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { createChannel, updateChannel } from '../api'
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
import {
transformFormDataToCreatePayload,
transformFormDataToUpdatePayload,
type ChannelFormValues,
} from '../lib'
import type { Channel } from '../types'
type UseChannelMutateFormParams = {
currentRow?: Channel | null
isEditing: boolean
isMultiKeyChannel: boolean
onSuccess: () => void
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function getErrorMessage(error: unknown): string | undefined {
if (error instanceof Error && typeof error.message === 'string') {
return error.message
}
if (!isRecord(error)) return undefined
const response = error.response
if (isRecord(response)) {
const data = response.data
if (isRecord(data)) {
const message = data.message
if (typeof message === 'string') return message
}
}
const message = error.message
if (typeof message === 'string') return message
return undefined
}
export function useChannelMutateForm(props: UseChannelMutateFormParams) {
const { t } = useTranslation()
return useMutation({
mutationFn: async (data: ChannelFormValues): Promise<string> => {
if (props.isEditing && props.currentRow) {
const payload = transformFormDataToUpdatePayload(
data,
props.currentRow.id
)
const payloadWithKeyMode =
props.isMultiKeyChannel && data.key_mode
? {
...payload,
key_mode: data.key_mode,
}
: payload
const response = await updateChannel(
props.currentRow.id,
payloadWithKeyMode
)
if (!response.success) {
throw new Error(response.message || t(ERROR_MESSAGES.UPDATE_FAILED))
}
return SUCCESS_MESSAGES.UPDATED
}
const payload = transformFormDataToCreatePayload(data)
const response = await createChannel(payload)
if (!response.success) {
throw new Error(response.message || t(ERROR_MESSAGES.CREATE_FAILED))
}
return SUCCESS_MESSAGES.CREATED
},
onSuccess: (messageKey) => {
toast.success(t(messageKey))
props.onSuccess()
},
onError: (error: unknown) => {
toast.error(getErrorMessage(error) || t(ERROR_MESSAGES.CREATE_FAILED))
},
})
}