Files
openchamber/packages/ui/src/components/sections/providers/CustomProviderForm.tsx
T
Cursor AgentandSerhii Dziupin b7c09ee137 fix custom provider edit gating and form reset on re-render
Gate Edit/incomplete-auth on config-layer sources so catalog providers
are not treated as editable custom overrides. Snapshot form initial
values when Edit is clicked and ignore same-id initialValues identity
changes so parent re-renders cannot wipe in-progress edits.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
2026-08-02 12:42:23 +00:00

397 lines
16 KiB
TypeScript

import React from 'react';
import {
SettingsSection,
SettingsStackedField,
SETTINGS_FIELDS_STACK_CLASS,
SETTINGS_FIELD_LABEL_CLASS,
SETTINGS_HELPER_CLASS,
SETTINGS_ICON_BUTTON_CLASS,
SETTINGS_CONTROL_CLUSTER_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import {
createEmptyCustomProviderForm,
createHeaderRow,
createModelRow,
validateCustomProvider,
type CustomProviderFormState,
type CustomProviderPersistPlan,
type CustomProviderTranslator,
type FieldErrors,
type HeaderFieldErrors,
type ModelFieldErrors,
} from './custom-provider-form';
type CustomProviderFormProps = {
existingProviderIDs: ReadonlySet<string>;
disabledProviders?: readonly string[];
busy?: boolean;
mode?: 'create' | 'edit';
initialValues?: CustomProviderFormState;
allowExistingAuth?: boolean;
authFailureHint?: string | null;
onSubmit: (plan: CustomProviderPersistPlan) => void | Promise<void>;
onCancel?: () => void;
onDisconnect?: () => void | Promise<void>;
};
export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
existingProviderIDs,
disabledProviders = [],
busy = false,
mode = 'create',
initialValues,
allowExistingAuth = false,
authFailureHint = null,
onSubmit,
onCancel,
onDisconnect,
}) => {
const { t } = useI18n();
const isEdit = mode === 'edit';
const [form, setForm] = React.useState<CustomProviderFormState>(
() => initialValues ?? createEmptyCustomProviderForm(),
);
const [err, setErr] = React.useState<FieldErrors>({});
const [modelErrors, setModelErrors] = React.useState<ModelFieldErrors[]>([]);
const [headerErrors, setHeaderErrors] = React.useState<HeaderFieldErrors[]>([]);
const seededEditProviderIdRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!initialValues) {
return;
}
// Edit mode: seed once per provider id so parent re-renders (new object
// identity for the same snapshot) do not wipe in-progress edits.
if (isEdit && seededEditProviderIdRef.current === initialValues.providerID) {
return;
}
seededEditProviderIdRef.current = isEdit ? initialValues.providerID : null;
setForm(initialValues);
setErr({});
setModelErrors([]);
setHeaderErrors([]);
}, [initialValues, isEdit]);
const setField = (key: keyof Pick<CustomProviderFormState, 'providerID' | 'name' | 'baseURL' | 'apiKey'>, value: string) => {
setForm((prev) => ({ ...prev, [key]: value }));
setErr((prev) => ({ ...prev, [key]: undefined }));
};
const setModel = (index: number, key: 'id' | 'name', value: string) => {
setForm((prev) => ({
...prev,
models: prev.models.map((row, rowIndex) => (rowIndex === index ? { ...row, [key]: value } : row)),
}));
setModelErrors((prev) => {
const next = [...prev];
next[index] = { ...(next[index] ?? {}), [key]: undefined };
return next;
});
};
const setHeader = (index: number, key: 'key' | 'value', value: string) => {
setForm((prev) => ({
...prev,
headers: prev.headers.map((row, rowIndex) => (rowIndex === index ? { ...row, [key]: value } : row)),
}));
setHeaderErrors((prev) => {
const next = [...prev];
next[index] = { ...(next[index] ?? {}), [key]: undefined };
return next;
});
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (busy) {
return;
}
const output = validateCustomProvider({
form,
t: ((key, vars) => t(key as Parameters<typeof t>[0], vars)) as CustomProviderTranslator,
existingProviderIDs,
disabledProviders,
editingProviderID: isEdit ? form.providerID : undefined,
allowExistingAuth: isEdit && allowExistingAuth,
});
setErr(output.err);
setModelErrors(output.models);
setHeaderErrors(output.headers);
if (!output.result) {
return;
}
await onSubmit(output.result);
};
return (
<form onSubmit={handleSubmit} className="space-y-0">
<SettingsSection
title={isEdit ? t('settings.providers.page.custom.editTitle') : t('settings.providers.page.custom.title')}
divider={false}
settingsItem="providers.custom"
contentClassName={SETTINGS_FIELDS_STACK_CLASS}
>
<p className={SETTINGS_HELPER_CLASS}>{t('settings.providers.page.custom.description')}</p>
{authFailureHint ? (
<p className="typography-meta text-[var(--status-warning)]" role="status">
{authFailureHint}
</p>
) : null}
<SettingsStackedField
label={t('settings.providers.page.custom.field.providerID.label')}
info={t('settings.providers.page.custom.field.providerID.info')}
>
<Input
value={form.providerID}
onChange={(event) => setField('providerID', event.target.value)}
placeholder={t('settings.providers.page.custom.field.providerID.placeholder')}
className="h-8 rounded-md px-3 font-mono text-xs"
autoFocus={!isEdit}
disabled={isEdit || busy}
aria-invalid={Boolean(err.providerID)}
aria-label={t('settings.providers.page.custom.field.providerID.label')}
/>
{err.providerID ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.providerID}</p> : null}
</SettingsStackedField>
<SettingsStackedField
label={t('settings.providers.page.custom.field.name.label')}
info={t('settings.providers.page.custom.field.name.info')}
>
<Input
value={form.name}
onChange={(event) => setField('name', event.target.value)}
placeholder={t('settings.providers.page.custom.field.name.placeholder')}
className="h-8 rounded-md px-3"
aria-invalid={Boolean(err.name)}
aria-label={t('settings.providers.page.custom.field.name.label')}
/>
{err.name ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.name}</p> : null}
</SettingsStackedField>
<SettingsStackedField
label={t('settings.providers.page.custom.field.baseURL.label')}
info={t('settings.providers.page.custom.field.baseURL.info')}
>
<Input
value={form.baseURL}
onChange={(event) => setField('baseURL', event.target.value)}
placeholder={t('settings.providers.page.custom.field.baseURL.placeholder')}
className="h-8 rounded-md px-3 font-mono text-xs"
aria-invalid={Boolean(err.baseURL)}
aria-label={t('settings.providers.page.custom.field.baseURL.label')}
/>
{err.baseURL ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.baseURL}</p> : null}
</SettingsStackedField>
<SettingsStackedField
label={t('settings.providers.page.custom.field.apiKey.label')}
info={
isEdit && allowExistingAuth
? t('settings.providers.page.custom.field.apiKey.editInfo')
: t('settings.providers.page.custom.field.apiKey.info')
}
>
<Input
type="password"
value={form.apiKey}
onChange={(event) => setField('apiKey', event.target.value)}
placeholder={
isEdit && allowExistingAuth
? t('settings.providers.page.custom.field.apiKey.editPlaceholder')
: t('settings.providers.page.custom.field.apiKey.placeholder')
}
className="h-8 rounded-md px-3 font-mono text-xs"
aria-invalid={Boolean(err.apiKey)}
aria-label={t('settings.providers.page.custom.field.apiKey.label')}
/>
{err.apiKey ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.apiKey}</p> : null}
</SettingsStackedField>
</SettingsSection>
<SettingsSection
title={t('settings.providers.page.custom.models.title')}
contentClassName={SETTINGS_FIELDS_STACK_CLASS}
>
{form.models.map((model, index) => (
<div key={model.row} className={`${SETTINGS_CONTROL_CLUSTER_CLASS} space-y-2`}>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 space-y-2">
<div>
<label className={SETTINGS_FIELD_LABEL_CLASS}>
{t('settings.providers.page.custom.models.idLabel')}
</label>
<Input
value={model.id}
onChange={(event) => setModel(index, 'id', event.target.value)}
placeholder={t('settings.providers.page.custom.models.idPlaceholder')}
className="mt-1 h-8 rounded-md px-3 font-mono text-xs"
aria-label={t('settings.providers.page.custom.models.idLabel')}
/>
{modelErrors[index]?.id ? (
<p className="mt-1 typography-meta text-[var(--status-error)]">{modelErrors[index]?.id}</p>
) : null}
</div>
<div>
<label className={SETTINGS_FIELD_LABEL_CLASS}>
{t('settings.providers.page.custom.models.nameLabel')}
</label>
<Input
value={model.name}
onChange={(event) => setModel(index, 'name', event.target.value)}
placeholder={t('settings.providers.page.custom.models.namePlaceholder')}
className="mt-1 h-8 rounded-md px-3"
aria-label={t('settings.providers.page.custom.models.nameLabel')}
/>
{modelErrors[index]?.name ? (
<p className="mt-1 typography-meta text-[var(--status-error)]">{modelErrors[index]?.name}</p>
) : null}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className={SETTINGS_ICON_BUTTON_CLASS}
disabled={form.models.length <= 1}
onClick={() => {
if (form.models.length <= 1) return;
setForm((prev) => ({
...prev,
models: prev.models.filter((_, rowIndex) => rowIndex !== index),
}));
setModelErrors((prev) => prev.filter((_, rowIndex) => rowIndex !== index));
}}
aria-label={t('settings.providers.page.custom.models.remove')}
>
<Icon name="delete-bin" className="size-4" />
</Button>
</div>
</div>
))}
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => {
setForm((prev) => ({ ...prev, models: [...prev.models, createModelRow()] }));
setModelErrors((prev) => [...prev, {}]);
}}
>
{t('settings.providers.page.custom.models.add')}
</Button>
</SettingsSection>
<SettingsSection
title={t('settings.providers.page.custom.headers.title')}
contentClassName={SETTINGS_FIELDS_STACK_CLASS}
>
<p className={SETTINGS_HELPER_CLASS}>{t('settings.providers.page.custom.headers.description')}</p>
{form.headers.map((header, index) => (
<div key={header.row} className={`${SETTINGS_CONTROL_CLUSTER_CLASS} space-y-2`}>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 space-y-2">
<div>
<label className={SETTINGS_FIELD_LABEL_CLASS}>
{t('settings.providers.page.custom.headers.keyLabel')}
</label>
<Input
value={header.key}
onChange={(event) => setHeader(index, 'key', event.target.value)}
placeholder={t('settings.providers.page.custom.headers.keyPlaceholder')}
className="mt-1 h-8 rounded-md px-3 font-mono text-xs"
aria-label={t('settings.providers.page.custom.headers.keyLabel')}
/>
{headerErrors[index]?.key ? (
<p className="mt-1 typography-meta text-[var(--status-error)]">{headerErrors[index]?.key}</p>
) : null}
</div>
<div>
<label className={SETTINGS_FIELD_LABEL_CLASS}>
{t('settings.providers.page.custom.headers.valueLabel')}
</label>
<Input
value={header.value}
onChange={(event) => setHeader(index, 'value', event.target.value)}
placeholder={t('settings.providers.page.custom.headers.valuePlaceholder')}
className="mt-1 h-8 rounded-md px-3 font-mono text-xs"
aria-label={t('settings.providers.page.custom.headers.valueLabel')}
/>
{headerErrors[index]?.value ? (
<p className="mt-1 typography-meta text-[var(--status-error)]">{headerErrors[index]?.value}</p>
) : null}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className={SETTINGS_ICON_BUTTON_CLASS}
disabled={form.headers.length <= 1}
onClick={() => {
if (form.headers.length <= 1) return;
setForm((prev) => ({
...prev,
headers: prev.headers.filter((_, rowIndex) => rowIndex !== index),
}));
setHeaderErrors((prev) => prev.filter((_, rowIndex) => rowIndex !== index));
}}
aria-label={t('settings.providers.page.custom.headers.remove')}
>
<Icon name="delete-bin" className="size-4" />
</Button>
</div>
</div>
))}
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => {
setForm((prev) => ({ ...prev, headers: [...prev.headers, createHeaderRow()] }));
setHeaderErrors((prev) => [...prev, {}]);
}}
>
{t('settings.providers.page.custom.headers.add')}
</Button>
</SettingsSection>
<div className="flex flex-wrap items-center gap-2 py-4">
{onCancel ? (
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={onCancel} disabled={busy}>
{t('settings.providers.page.custom.actions.back')}
</Button>
) : null}
{onDisconnect ? (
<Button
type="button"
variant="destructive"
size="xs"
className="!font-normal"
onClick={() => void onDisconnect()}
disabled={busy}
>
{t('settings.providers.page.actions.disconnect')}
</Button>
) : null}
<Button type="submit" size="xs" className="!font-normal" disabled={busy}>
{busy
? t('settings.providers.page.actions.saving')
: isEdit
? t('settings.providers.page.custom.actions.update')
: t('settings.providers.page.custom.actions.save')}
</Button>
</div>
</form>
);
};