Add unit tests for channel utilities and configure testing environment

- Created a new test file `channels.test.ts` to cover utilities related to channel configurations and targets.
- Implemented tests for normalizing and grouping selected channels by type, as well as building channel targets from account data and cron history.
- Mocked necessary dependencies to isolate tests and ensure accurate results.
- Updated `vite.config.ts` to set up the testing environment with jsdom and enable global variables for tests.
This commit is contained in:
duanshuwen
2026-04-18 16:12:49 +08:00
parent ee72cf7261
commit ef46c73c3e
26 changed files with 4056 additions and 186 deletions

View File

@@ -0,0 +1,34 @@
type ChannelAccountIdFieldProps = {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
helpText?: string;
disabled?: boolean;
};
export default function ChannelAccountIdField({
label,
value,
onChange,
placeholder,
helpText,
disabled,
}: ChannelAccountIdFieldProps) {
return (
<div className="space-y-2">
<div className="text-[13px] font-medium text-[#525866] dark:text-gray-300">{label}</div>
<input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
disabled={disabled}
autoComplete="off"
className="h-[44px] w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 text-[13px] text-[#171717] outline-none transition-colors placeholder:text-[#99A0AE] focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100"
/>
{helpText ? (
<div className="text-[12px] leading-[18px] text-[#99A0AE] dark:text-gray-500">{helpText}</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,39 @@
type ChannelConfigActionsProps = {
cancelLabel: string;
confirmLabel: string;
onClose: () => void;
onConfirm: () => void;
disabled?: boolean;
submitting?: boolean;
};
export default function ChannelConfigActions({
cancelLabel,
confirmLabel,
onClose,
onConfirm,
disabled,
submitting,
}: ChannelConfigActionsProps) {
return (
<div className="flex items-center justify-end gap-3 pt-1">
<button
type="button"
className="h-[40px] rounded-full bg-[#EDECE4] px-5 text-[13px] font-semibold text-[#4B4B4B] transition-colors hover:bg-[#E5E4DC] disabled:cursor-not-allowed disabled:opacity-60 dark:bg-[#222225] dark:text-gray-200"
onClick={onClose}
disabled={disabled || submitting}
>
{cancelLabel}
</button>
<button
type="button"
className="h-[40px] rounded-full bg-[#2B7FFF] px-5 text-[13px] font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
onClick={onConfirm}
disabled={disabled || submitting}
>
{submitting ? `${confirmLabel}...` : confirmLabel}
</button>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import type { ReactNode } from 'react';
import type { ChannelConfigFieldMeta, ChannelConfigFieldValueMap } from '../../lib/channel-types';
import ChannelAccountIdField from './ChannelAccountIdField';
import ChannelTokenField from './ChannelTokenField';
type ChannelConfigFieldsProps = {
fields: ChannelConfigFieldMeta[];
values: ChannelConfigFieldValueMap;
onValueChange: (key: string, value: string) => void;
disabled?: boolean;
accountIdLabel: string;
accountIdHelpText?: string;
tokenHelpText?: string;
};
function renderField({
field,
value,
onChange,
disabled,
accountIdLabel,
accountIdHelpText,
tokenHelpText,
}: {
field: ChannelConfigFieldMeta;
value: string;
onChange: (nextValue: string) => void;
disabled?: boolean;
accountIdLabel: string;
accountIdHelpText?: string;
tokenHelpText?: string;
}): ReactNode {
const commonProps = {
label: field.label,
value,
onChange,
placeholder: field.placeholder,
helpText: field.description,
disabled,
};
if (field.key === 'accountId') {
return (
<ChannelAccountIdField
{...commonProps}
label={field.label || accountIdLabel}
helpText={field.description ?? accountIdHelpText}
/>
);
}
if (field.kind === 'token' || field.kind === 'password') {
return <ChannelTokenField {...commonProps} helpText={field.description ?? tokenHelpText} />;
}
if (field.kind === 'textarea') {
return (
<div className="space-y-2">
<div className="text-[13px] font-medium text-[#525866] dark:text-gray-300">{field.label}</div>
<textarea
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={field.placeholder}
disabled={disabled}
rows={field.rows ?? 4}
className="w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 py-2 text-[13px] text-[#171717] outline-none transition-colors placeholder:text-[#99A0AE] focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100"
/>
{field.description ? (
<div className="text-[12px] leading-[18px] text-[#99A0AE] dark:text-gray-500">{field.description}</div>
) : null}
</div>
);
}
return (
<ChannelTokenField
{...commonProps}
label={field.label}
helpText={field.description ?? tokenHelpText}
/>
);
}
export default function ChannelConfigFields({
fields,
values,
onValueChange,
disabled,
accountIdLabel,
accountIdHelpText,
tokenHelpText,
}: ChannelConfigFieldsProps) {
if (fields.length === 0) {
return null;
}
return (
<div className="space-y-4">
{fields.map((field) => (
<div key={field.key}>
{renderField({
field,
value: values[field.key] ?? '',
onChange: (nextValue) => onValueChange(field.key, nextValue),
disabled,
accountIdLabel,
accountIdHelpText,
tokenHelpText,
})}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,157 @@
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 ChannelConfigActions from './ChannelConfigActions';
import ChannelConfigFields from './ChannelConfigFields';
import ChannelInstructionsPanel from './ChannelInstructionsPanel';
import ChannelTypeSelector from './ChannelTypeSelector';
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>;
error?: string | null;
submitting?: boolean;
title?: string;
description?: string;
typeLabel?: string;
accountIdLabel?: string;
accountIdHelpText?: string;
tokenHelpText?: string;
instructionsTitle?: string;
docsLabel?: string;
diagnosticsNote?: string;
confirmLabel?: string;
cancelLabel?: string;
widthClassName?: string;
};
export default function ChannelConfigModal({
open,
selectedChannelType,
channelTypes,
values,
accountId,
disableChannelType,
disableAccountId,
onChannelTypeChange,
onValueChange,
onAccountIdChange,
onClose,
onConfirm,
error,
submitting,
title,
description,
typeLabel,
accountIdLabel,
accountIdHelpText,
tokenHelpText,
instructionsTitle,
docsLabel,
diagnosticsNote,
confirmLabel,
cancelLabel,
widthClassName,
}: 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 supportedFields = activeMeta.configFields.filter((field) => field.key !== 'accountId');
return (
<DialogSurface
open={open}
title={title ?? t('channels.modal.title')}
widthClassName={widthClassName ?? 'max-w-[920px]'}
onClose={onClose}
>
<div className="space-y-5">
<div className="text-[13px] leading-[20px] text-[#525866] dark:text-gray-300">
{descriptionText}
</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="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}
/>
<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>
</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>
);
}

View File

@@ -0,0 +1,47 @@
import type { ChannelMeta } from '../../lib/channel-meta';
type ChannelInstructionsPanelProps = {
meta: ChannelMeta;
title: string;
docsLabel: string;
diagnosticsNote: string;
};
export default function ChannelInstructionsPanel({
meta,
title,
docsLabel,
diagnosticsNote,
}: ChannelInstructionsPanelProps) {
return (
<section className="rounded-[16px] border border-[#E5E8EE] bg-[#FAFBFC] p-4 dark:border-[#2a2a2d] dark:bg-[#17171a]">
<div className="text-[13px] font-medium text-[#171717] dark:text-gray-100">{title}</div>
<div className="mt-1 text-[12px] leading-[18px] text-[#99A0AE] dark:text-gray-500">
{docsLabel}
</div>
<div className="mt-3 space-y-2">
<div className="rounded-[12px] border border-dashed border-[#DCE5F1] bg-white px-3 py-2 text-[12px] leading-[18px] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-300">
{meta.description}
</div>
{meta.instructions.length > 0 ? (
<ol className="space-y-2">
{meta.instructions.map((instruction) => (
<li
key={instruction}
className="rounded-[12px] border border-[#E5E8EE] bg-white px-3 py-2 text-[12px] leading-[18px] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-300"
>
{instruction}
</li>
))}
</ol>
) : null}
</div>
<div className="mt-3 rounded-[12px] bg-[#EFF6FF] px-3 py-2 text-[12px] leading-[18px] text-[#2B4E8C] dark:bg-[#1d2633] dark:text-[#93c5fd]">
{diagnosticsNote}
</div>
</section>
);
}

View File

@@ -0,0 +1,34 @@
type ChannelTokenFieldProps = {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
helpText?: string;
disabled?: boolean;
};
export default function ChannelTokenField({
label,
value,
onChange,
placeholder,
helpText,
disabled,
}: ChannelTokenFieldProps) {
return (
<div className="space-y-2">
<div className="text-[13px] font-medium text-[#525866] dark:text-gray-300">{label}</div>
<input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
disabled={disabled}
autoComplete="off"
className="h-[44px] w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 text-[13px] text-[#171717] outline-none transition-colors placeholder:text-[#99A0AE] focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100"
/>
{helpText ? (
<div className="text-[12px] leading-[18px] text-[#99A0AE] dark:text-gray-500">{helpText}</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import type { ChannelMeta } from '../../lib/channel-meta';
type ChannelTypeSelectorProps = {
label: string;
value: string;
options: ChannelMeta[];
onChange: (value: string) => void;
disabled?: boolean;
helpText?: string;
};
export default function ChannelTypeSelector({
label,
value,
options,
onChange,
disabled,
helpText,
}: ChannelTypeSelectorProps) {
const selected = options.find((item) => item.type === value);
return (
<div className="space-y-2">
<div className="text-[13px] font-medium text-[#525866] dark:text-gray-300">{label}</div>
<select
value={value}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
className="h-[44px] w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 text-[13px] text-[#171717] outline-none transition-colors focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100"
>
{options.map((option) => (
<option key={option.type} value={option.type}>
{option.name}
</option>
))}
</select>
<div className="min-h-[36px] rounded-[12px] border border-dashed border-[#E5E8EE] bg-white px-3 py-2 text-[12px] leading-[18px] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#17171a] dark:text-gray-300">
<div className="font-medium text-[#171717] dark:text-gray-100">
{selected?.name ?? value}
</div>
<div className="mt-1">{selected?.description ?? helpText}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
export { default as ChannelAccountIdField } from './ChannelAccountIdField';
export { default as ChannelConfigActions } from './ChannelConfigActions';
export { default as ChannelConfigFields } from './ChannelConfigFields';
export { default as ChannelConfigModal } from './ChannelConfigModal';
export { default as ChannelInstructionsPanel } from './ChannelInstructionsPanel';
export { default as ChannelTokenField } from './ChannelTokenField';
export { default as ChannelTypeSelector } from './ChannelTypeSelector';
export type { ChannelConfigModalProps } from './ChannelConfigModal';