feat: enhance channel configuration UI and validation

- Updated ChannelInstructionsPanel to include a button for viewing documentation, improving user guidance.
- Enhanced ChannelTokenField to support showing/hiding secret values with appropriate labels and icons.
- Refined ChannelTypeSelector to display connection type icons and improved layout for better user experience.
- Added new messages for documentation links, validation feedback, and secret management in i18n.
- Extended ChannelMeta to include optional documentation URLs for better context on configuration fields.
- Implemented credential validation logic in ChannelsPage to ensure user inputs are validated before saving.
- Introduced ChannelLogo component to display channel icons in the UI.
- Added tests for channel credential validation to ensure proper error handling and feedback.
This commit is contained in:
duanshuwen
2026-04-19 16:43:07 +08:00
parent d2e48b21d8
commit 18f12d6ce3
22 changed files with 1131 additions and 301 deletions

View File

@@ -1,157 +1,284 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlertCircle, CheckCircle2, X } from 'lucide-react';
import { useI18n } from '../../i18n';
import type { ChannelConfigFieldValueMap } from '../../lib/channel-types';
import { getChannelMeta, getChannelOptions, type ChannelMeta } from '../../lib/channel-meta';
import DialogSurface from '../../pages/Home/components/DialogSurface';
import ChannelAccountIdField from './ChannelAccountIdField';
import type { ChannelConfigFieldMeta, ChannelConfigFieldValueMap } from '../../lib/channel-types';
import { getChannelMeta, type ChannelMeta } from '../../lib/channel-meta';
import ChannelConfigActions from './ChannelConfigActions';
import ChannelConfigFields from './ChannelConfigFields';
import ChannelInstructionsPanel from './ChannelInstructionsPanel';
import ChannelTypeSelector from './ChannelTypeSelector';
export type ChannelFieldValidationResult = {
key: string;
label: string;
kind: string;
required: boolean;
provided: boolean;
valid: boolean;
errors: string[];
warnings: string[];
};
export type ChannelCredentialsValidationResult = {
valid: boolean;
errors: string[];
warnings: string[];
details?: {
channelType?: string;
accountId?: string;
channelName?: string;
connectionType?: string;
fields?: ChannelFieldValidationResult[];
[key: string]: unknown;
};
};
export type ChannelConfigModalProps = {
open: boolean;
selectedChannelType: string;
channelTypes?: ChannelMeta[];
values: ChannelConfigFieldValueMap;
accountId: string;
disableChannelType?: boolean;
disableAccountId?: boolean;
onChannelTypeChange: (value: string) => void;
onValueChange: (key: string, value: string) => void;
onAccountIdChange: (value: string) => void;
onClose: () => void;
onConfirm: () => void | Promise<void>;
onValidate?: () => void | Promise<void>;
error?: string | null;
submitting?: boolean;
validating?: boolean;
validationResult?: ChannelCredentialsValidationResult | null;
title?: string;
description?: string;
typeLabel?: string;
accountIdLabel?: string;
accountIdHelpText?: string;
tokenHelpText?: string;
instructionsTitle?: string;
docsLabel?: string;
diagnosticsNote?: string;
confirmLabel?: string;
cancelLabel?: string;
widthClassName?: string;
validateLabel?: string;
validatingLabel?: string;
};
function withTranslatedMeta(
meta: ChannelMeta,
translate: (path: string, fallback: string) => string,
): ChannelMeta {
return {
...meta,
name: translate(`channels.meta.${meta.type}.name`, meta.name),
description: translate(`channels.meta.${meta.type}.description`, meta.description),
docsUrl: meta.docsUrl
? translate(`channels.meta.${meta.type}.docsUrl`, meta.docsUrl)
: meta.docsUrl,
instructions: meta.instructions.map((instruction, index) => (
translate(`channels.meta.${meta.type}.instructions.${index}`, instruction)
)),
configFields: meta.configFields.map((field) => withTranslatedField(meta.type, field, translate)),
};
}
function withTranslatedField(
channelType: string,
field: ChannelConfigFieldMeta,
translate: (path: string, fallback: string) => string,
): ChannelConfigFieldMeta {
return {
...field,
label: translate(`channels.meta.${channelType}.fields.${field.key}.label`, field.label),
placeholder: field.placeholder
? translate(`channels.meta.${channelType}.fields.${field.key}.placeholder`, field.placeholder)
: field.placeholder,
description: field.description
? translate(`channels.meta.${channelType}.fields.${field.key}.description`, field.description)
: field.description,
docsUrl: field.docsUrl
? translate(`channels.meta.${channelType}.fields.${field.key}.docsUrl`, field.docsUrl)
: field.docsUrl,
};
}
export default function ChannelConfigModal({
open,
selectedChannelType,
channelTypes,
values,
accountId,
disableChannelType,
disableAccountId,
onChannelTypeChange,
onValueChange,
onAccountIdChange,
onClose,
onConfirm,
onValidate,
error,
submitting,
validating,
validationResult,
title,
description,
typeLabel,
accountIdLabel,
accountIdHelpText,
tokenHelpText,
instructionsTitle,
docsLabel,
diagnosticsNote,
confirmLabel,
cancelLabel,
widthClassName,
validateLabel,
validatingLabel,
}: ChannelConfigModalProps) {
const { t } = useI18n();
const options = channelTypes ?? getChannelOptions();
const activeMeta = options.find((item) => item.type === selectedChannelType) ?? getChannelMeta(selectedChannelType);
const accountFieldMeta = activeMeta.configFields.find((field) => field.key === 'accountId');
const accountLabel = accountIdLabel ?? accountFieldMeta?.label ?? t('channels.modal.accountIdLabel');
const accountHelp = accountIdHelpText ?? accountFieldMeta?.description;
const typeSelectLabel = typeLabel ?? t('channels.modal.typeLabel');
const docsText = docsLabel ?? t('channels.modal.docsLabel');
const instructionsHeading = instructionsTitle ?? t('channels.modal.instructionsTitle');
const confirmText = confirmLabel ?? t('channels.modal.confirm');
const cancelText = cancelLabel ?? t('dialog.cancel');
const notesText = diagnosticsNote ?? t('channels.modal.diagnosticsNote');
const descriptionText = description ?? t('channels.modal.description');
const { t, hasMessage } = useI18n();
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const firstFieldRef = useRef<HTMLDivElement>(null);
const translate = useCallback((path: string, fallback: string) => (
hasMessage(path) ? t(path) : fallback
), [hasMessage, t]);
const activeMeta = useMemo(
() => withTranslatedMeta(getChannelMeta(selectedChannelType), translate),
[selectedChannelType, translate],
);
const supportedFields = activeMeta.configFields.filter((field) => field.key !== 'accountId');
const canValidate = Boolean(onValidate) && activeMeta.connectionType !== 'qr';
const resolvedTitle = title ?? t('channels.modal.configureTitle', { name: activeMeta.name });
const descriptionText = description ?? activeMeta.description;
const instructionsHeading = t('channels.modal.howTo');
const confirmText = confirmLabel ?? (canValidate ? t('channels.modal.saveAndConnect') : t('channels.modal.confirm'));
const validateText = validateLabel ?? t('channels.modal.validateConfig');
const validatingText = validatingLabel ?? t('channels.modal.validating');
useEffect(() => {
if (!open) {
setShowSecrets({});
}
}, [open]);
useEffect(() => {
setShowSecrets({});
}, [selectedChannelType]);
useEffect(() => {
if (open && firstFieldRef.current) {
firstFieldRef.current.focus();
}
}, [open, selectedChannelType]);
const toggleSecretVisibility = useCallback((key: string) => {
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
if (!open) return null;
return (
<DialogSurface
open={open}
title={title ?? t('channels.modal.title')}
widthClassName={widthClassName ?? 'max-w-[920px]'}
onClose={onClose}
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-[2px]"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div className="space-y-5">
<div className="text-[13px] leading-[20px] text-[#525866] dark:text-gray-300">
{descriptionText}
<div
className="flex max-h-[92vh] w-full max-w-280 flex-col overflow-hidden rounded-[34px] border border-black/10 bg-white shadow-[0_32px_90px_-24px_rgba(15,23,42,0.35)] dark:border-white/10 dark:bg-[#1c1c20]"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-6 px-10 pb-2 pt-9">
<div className="min-w-0">
<h2
className="text-[54px] leading-[0.98] font-normal tracking-tight text-[#0f172a] dark:text-[#f3f4f6]"
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
>
{resolvedTitle}
</h2>
<p className="mt-5 max-w-4xl text-[16px] leading-8 text-[#667085] dark:text-gray-300">
{descriptionText}
</p>
</div>
<button
type="button"
className="mt-1 inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-[#98a2b3] transition-colors hover:bg-black/5 hover:text-[#0f172a] dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-100"
onClick={onClose}
aria-label={t('window.close')}
>
<X className="h-6 w-6" />
</button>
</div>
{error ? (
<div className="rounded-[12px] border border-[#f2c7cd] bg-[#fff5f6] px-4 py-3 text-[13px] text-[#c24150] dark:border-[#4b2229] dark:bg-[#2b1c1f] dark:text-[#ffb4bf]">
{error}
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto px-10 pb-8 pt-6">
<div className="space-y-8">
{error ? (
<div className="rounded-[22px] border border-[#efb3b3] bg-[#fff1f1] px-5 py-4 text-[14px] leading-7 text-[#b42318] dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300">
{error}
</div>
) : null}
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
<div className="space-y-4">
<ChannelTypeSelector
label={typeSelectLabel}
value={activeMeta.type}
options={options}
onChange={onChannelTypeChange}
disabled={disableChannelType || submitting}
helpText={activeMeta.connectionType}
<ChannelInstructionsPanel
meta={activeMeta}
title={instructionsHeading}
viewDocsLabel={t('channels.modal.viewDocs')}
/>
<ChannelAccountIdField
label={accountLabel}
value={accountId}
onChange={onAccountIdChange}
placeholder={accountFieldMeta?.placeholder}
helpText={accountHelp}
disabled={disableAccountId || submitting}
/>
<ChannelConfigFields
fields={supportedFields}
values={values}
onValueChange={onValueChange}
disabled={submitting}
accountIdLabel={accountLabel}
accountIdHelpText={accountHelp}
tokenHelpText={tokenHelpText ?? t('channels.modal.tokenHelpText')}
/>
<div className="rounded-[16px] border border-dashed border-[#E5E8EE] bg-white px-4 py-3 text-[12px] leading-[18px] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#17171a] dark:text-gray-300">
{t('channels.modal.todoNote')}
<div ref={firstFieldRef} tabIndex={-1} className="space-y-8 outline-none">
<ChannelConfigFields
fields={supportedFields}
values={values}
onValueChange={onValueChange}
disabled={submitting || validating}
showSecretMap={showSecrets}
onToggleSecret={toggleSecretVisibility}
emptyStateText={t('channels.modal.noAdditionalFields')}
envVarLabel={t('channels.modal.envVar')}
showSecretLabel={t('channels.modal.showSecret')}
hideSecretLabel={t('channels.modal.hideSecret')}
/>
</div>
{validationResult ? (
<div
className={[
'rounded-[22px] border px-5 py-4 text-[14px] leading-7',
validationResult.valid
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300'
: 'border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300',
].join(' ')}
>
<div className="flex items-start gap-3">
{validationResult.valid ? (
<CheckCircle2 className="mt-1 h-5 w-5 shrink-0" />
) : (
<AlertCircle className="mt-1 h-5 w-5 shrink-0" />
)}
<div className="min-w-0">
<div className="font-semibold">
{validationResult.valid ? t('channels.modal.credentialsVerified') : t('channels.modal.validationFailed')}
</div>
{validationResult.errors.length > 0 ? (
<ul className="mt-2 list-disc space-y-1 pl-5">
{validationResult.errors.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
) : null}
{validationResult.warnings.length > 0 ? (
<div className="mt-3">
<div className="text-[12px] font-semibold uppercase tracking-[0.12em] opacity-80">
{t('channels.modal.warnings')}
</div>
<ul className="mt-2 list-disc space-y-1 pl-5">
{validationResult.warnings.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
) : null}
</div>
</div>
</div>
) : null}
<ChannelConfigActions
confirmLabel={confirmText}
validateLabel={validateText}
validatingLabel={validatingText}
onConfirm={() => {
void onConfirm();
}}
onValidate={canValidate ? () => {
void onValidate?.();
} : undefined}
disabled={false}
submitting={submitting}
validating={validating}
/>
</div>
<ChannelInstructionsPanel
meta={activeMeta}
title={instructionsHeading}
docsLabel={docsText}
diagnosticsNote={notesText}
/>
</div>
<ChannelConfigActions
cancelLabel={cancelText}
confirmLabel={confirmText}
onClose={onClose}
onConfirm={() => {
void onConfirm();
}}
disabled={false}
submitting={submitting}
/>
</div>
</DialogSurface>
</div>
);
}