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>
This commit is contained in:
co-authored by
Serhii Dziupin
parent
d40bb9e5a0
commit
b7c09ee137
@@ -58,15 +58,23 @@ export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
|
||||
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) {
|
||||
setForm(initialValues);
|
||||
setErr({});
|
||||
setModelErrors([]);
|
||||
setHeaderErrors([]);
|
||||
if (!initialValues) {
|
||||
return;
|
||||
}
|
||||
}, [initialValues]);
|
||||
// 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 }));
|
||||
|
||||
@@ -31,8 +31,9 @@ import {
|
||||
buildAuthSetRequest,
|
||||
buildProviderUpsertRequest,
|
||||
CUSTOM_PROVIDER_ID,
|
||||
isCustomOpenAICompatibleProvider,
|
||||
isConfigDefinedCustomProvider,
|
||||
providerToCustomFormState,
|
||||
type CustomProviderFormState,
|
||||
type CustomProviderPersistPlan,
|
||||
} from './custom-provider-form';
|
||||
|
||||
@@ -182,6 +183,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
const [providerSources, setProviderSources] = React.useState<Record<string, ProviderSources>>({});
|
||||
const [showAuthPanel, setShowAuthPanel] = React.useState(false);
|
||||
const [editingCustomProviderId, setEditingCustomProviderId] = React.useState<string | null>(null);
|
||||
const [editingCustomFormInitial, setEditingCustomFormInitial] = React.useState<CustomProviderFormState | null>(null);
|
||||
const [customAuthFailureHint, setCustomAuthFailureHint] = React.useState<string | null>(null);
|
||||
const [lastCustomPersistId, setLastCustomPersistId] = React.useState<string | null>(null);
|
||||
const isAddMode = selectedProviderId === ADD_PROVIDER_ID;
|
||||
@@ -303,6 +305,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
if (selectedProviderId === ADD_PROVIDER_ID) {
|
||||
setShowAuthPanel(true);
|
||||
setEditingCustomProviderId(null);
|
||||
setEditingCustomFormInitial(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
return;
|
||||
}
|
||||
@@ -310,9 +313,10 @@ export const ProvidersPage: React.FC = () => {
|
||||
setShowAuthPanel(false);
|
||||
if (editingCustomProviderId && editingCustomProviderId !== selectedProviderId) {
|
||||
setEditingCustomProviderId(null);
|
||||
setEditingCustomFormInitial(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
}
|
||||
}, [selectedProviderId, editingCustomProviderId, t]);
|
||||
}, [selectedProviderId, editingCustomProviderId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) {
|
||||
@@ -427,6 +431,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
toast.success(t('settings.providers.page.toast.customProviderSaved', { provider: plan.name }));
|
||||
setCandidateProviderId('');
|
||||
setEditingCustomProviderId(null);
|
||||
setEditingCustomFormInitial(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
setLastCustomPersistId(null);
|
||||
await reloadOpenCodeConfiguration({ scopes: ['providers'], mode: 'active' });
|
||||
@@ -587,6 +592,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
}
|
||||
await handleDisconnectProvider(providerId);
|
||||
setEditingCustomProviderId(null);
|
||||
setEditingCustomFormInitial(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
setLastCustomPersistId(null);
|
||||
setCandidateProviderId('');
|
||||
@@ -902,15 +908,16 @@ export const ProvidersPage: React.FC = () => {
|
||||
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
|
||||
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
|
||||
const oauthAuthMethods = providerAuthMethods.filter((method) => normalizeAuthType(method) === 'oauth');
|
||||
const isCustomProvider = isCustomOpenAICompatibleProvider(selectedProvider);
|
||||
const sourcesLoaded = Boolean(selectedSources);
|
||||
const isEditableCustomProvider = sourcesLoaded
|
||||
&& isConfigDefinedCustomProvider(selectedProvider, selectedSources);
|
||||
const providerEnv = Array.isArray(selectedProvider.env)
|
||||
? selectedProvider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
||||
: [];
|
||||
const sourcesLoaded = Boolean(selectedSources);
|
||||
const hasStoredAuth = Boolean(selectedSources?.auth.exists);
|
||||
const hasEnvCredentials = providerEnv.length > 0;
|
||||
const hasCredentials = hasStoredAuth || hasEnvCredentials;
|
||||
const authStatusIncomplete = isCustomProvider && sourcesLoaded && !hasCredentials;
|
||||
const authStatusIncomplete = isEditableCustomProvider && !hasCredentials;
|
||||
|
||||
const filteredModels = providerModels.filter((model) => {
|
||||
const name = typeof model?.name === 'string' ? model.name : '';
|
||||
@@ -920,8 +927,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
return name.toLowerCase().includes(query) || id.toLowerCase().includes(query);
|
||||
});
|
||||
|
||||
if (isCustomEditMode && isCustomProvider) {
|
||||
const initialValues = providerToCustomFormState(selectedProvider);
|
||||
if (isCustomEditMode && isEditableCustomProvider && editingCustomFormInitial) {
|
||||
return (
|
||||
<SettingsPageLayout
|
||||
title={selectedProvider.name || selectedProvider.id}
|
||||
@@ -932,12 +938,13 @@ export const ProvidersPage: React.FC = () => {
|
||||
<CustomProviderForm
|
||||
mode="edit"
|
||||
existingProviderIDs={connectedProviderIds}
|
||||
initialValues={initialValues}
|
||||
allowExistingAuth={hasCredentials}
|
||||
initialValues={editingCustomFormInitial}
|
||||
allowExistingAuth={hasCredentials || !sourcesLoaded}
|
||||
busy={authBusyKey?.startsWith('custom:') ?? false}
|
||||
authFailureHint={customAuthFailureHint}
|
||||
onCancel={() => {
|
||||
setEditingCustomProviderId(null);
|
||||
setEditingCustomFormInitial(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
setLastCustomPersistId(null);
|
||||
}}
|
||||
@@ -960,13 +967,14 @@ export const ProvidersPage: React.FC = () => {
|
||||
divider={false}
|
||||
headerAction={(
|
||||
<div className="flex items-center gap-1">
|
||||
{isCustomProvider ? (
|
||||
{isEditableCustomProvider ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => {
|
||||
setCustomAuthFailureHint(null);
|
||||
setEditingCustomFormInitial(providerToCustomFormState(selectedProvider));
|
||||
setEditingCustomProviderId(selectedProvider.id);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
buildAuthSetRequest,
|
||||
buildProviderUpsertRequest,
|
||||
isConfigDefinedCustomProvider,
|
||||
isCustomOpenAICompatibleProvider,
|
||||
providerToCustomFormState,
|
||||
validateCustomProvider,
|
||||
@@ -288,4 +289,24 @@ describe('provider edit helpers', () => {
|
||||
expect(state.models[0]).toEqual({ row: state.models[0].row, id: 'fast', name: 'Fast' });
|
||||
expect(state.headers[0]).toEqual({ row: state.headers[0].row, key: 'X-Campus', value: '1' });
|
||||
});
|
||||
|
||||
test('requires a config-layer source before treating a provider as editable custom', () => {
|
||||
const catalogLike = {
|
||||
id: 'openai',
|
||||
options: { baseURL: 'https://api.openai.com/v1' },
|
||||
models: [{ id: 'gpt-4o', name: 'GPT-4o', api: { npm: '@ai-sdk/openai-compatible' } }],
|
||||
};
|
||||
|
||||
expect(isCustomOpenAICompatibleProvider(catalogLike)).toBe(true);
|
||||
expect(isConfigDefinedCustomProvider(catalogLike, undefined)).toBe(false);
|
||||
expect(isConfigDefinedCustomProvider(catalogLike, {
|
||||
user: { exists: false },
|
||||
project: { exists: false },
|
||||
custom: { exists: false },
|
||||
})).toBe(false);
|
||||
expect(isConfigDefinedCustomProvider(catalogLike, {
|
||||
user: { exists: true },
|
||||
project: { exists: false },
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,6 +163,30 @@ export function isCustomOpenAICompatibleProvider(provider: ProviderLikeForCustom
|
||||
});
|
||||
}
|
||||
|
||||
export type ProviderConfigSourcesLike = {
|
||||
user?: { exists?: boolean };
|
||||
project?: { exists?: boolean };
|
||||
custom?: { exists?: boolean };
|
||||
};
|
||||
|
||||
/**
|
||||
* True when a provider both looks OpenAI-compatible-custom and is defined in a
|
||||
* user/project/custom OpenCode config layer. Catalog-only providers often share
|
||||
* the same npm/baseURL signals and must not get Edit / config overrides.
|
||||
*/
|
||||
export function isConfigDefinedCustomProvider(
|
||||
provider: ProviderLikeForCustomForm,
|
||||
sources: ProviderConfigSourcesLike | null | undefined,
|
||||
): boolean {
|
||||
if (!sources) {
|
||||
return false;
|
||||
}
|
||||
const inConfigLayer = Boolean(
|
||||
sources.user?.exists || sources.project?.exists || sources.custom?.exists,
|
||||
);
|
||||
return inConfigLayer && isCustomOpenAICompatibleProvider(provider);
|
||||
}
|
||||
|
||||
export function providerToCustomFormState(provider: ProviderLikeForCustomForm): CustomProviderFormState {
|
||||
const options = provider.options && typeof provider.options === 'object' ? provider.options : {};
|
||||
const baseURL = typeof options.baseURL === 'string' ? options.baseURL : '';
|
||||
|
||||
Reference in New Issue
Block a user