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 [err, setErr] = React.useState<FieldErrors>({});
|
||||||
const [modelErrors, setModelErrors] = React.useState<ModelFieldErrors[]>([]);
|
const [modelErrors, setModelErrors] = React.useState<ModelFieldErrors[]>([]);
|
||||||
const [headerErrors, setHeaderErrors] = React.useState<HeaderFieldErrors[]>([]);
|
const [headerErrors, setHeaderErrors] = React.useState<HeaderFieldErrors[]>([]);
|
||||||
|
const seededEditProviderIdRef = React.useRef<string | null>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (initialValues) {
|
if (!initialValues) {
|
||||||
setForm(initialValues);
|
return;
|
||||||
setErr({});
|
|
||||||
setModelErrors([]);
|
|
||||||
setHeaderErrors([]);
|
|
||||||
}
|
}
|
||||||
}, [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) => {
|
const setField = (key: keyof Pick<CustomProviderFormState, 'providerID' | 'name' | 'baseURL' | 'apiKey'>, value: string) => {
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ import {
|
|||||||
buildAuthSetRequest,
|
buildAuthSetRequest,
|
||||||
buildProviderUpsertRequest,
|
buildProviderUpsertRequest,
|
||||||
CUSTOM_PROVIDER_ID,
|
CUSTOM_PROVIDER_ID,
|
||||||
isCustomOpenAICompatibleProvider,
|
isConfigDefinedCustomProvider,
|
||||||
providerToCustomFormState,
|
providerToCustomFormState,
|
||||||
|
type CustomProviderFormState,
|
||||||
type CustomProviderPersistPlan,
|
type CustomProviderPersistPlan,
|
||||||
} from './custom-provider-form';
|
} from './custom-provider-form';
|
||||||
|
|
||||||
@@ -182,6 +183,7 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
const [providerSources, setProviderSources] = React.useState<Record<string, ProviderSources>>({});
|
const [providerSources, setProviderSources] = React.useState<Record<string, ProviderSources>>({});
|
||||||
const [showAuthPanel, setShowAuthPanel] = React.useState(false);
|
const [showAuthPanel, setShowAuthPanel] = React.useState(false);
|
||||||
const [editingCustomProviderId, setEditingCustomProviderId] = React.useState<string | null>(null);
|
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 [customAuthFailureHint, setCustomAuthFailureHint] = React.useState<string | null>(null);
|
||||||
const [lastCustomPersistId, setLastCustomPersistId] = React.useState<string | null>(null);
|
const [lastCustomPersistId, setLastCustomPersistId] = React.useState<string | null>(null);
|
||||||
const isAddMode = selectedProviderId === ADD_PROVIDER_ID;
|
const isAddMode = selectedProviderId === ADD_PROVIDER_ID;
|
||||||
@@ -303,6 +305,7 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
if (selectedProviderId === ADD_PROVIDER_ID) {
|
if (selectedProviderId === ADD_PROVIDER_ID) {
|
||||||
setShowAuthPanel(true);
|
setShowAuthPanel(true);
|
||||||
setEditingCustomProviderId(null);
|
setEditingCustomProviderId(null);
|
||||||
|
setEditingCustomFormInitial(null);
|
||||||
setCustomAuthFailureHint(null);
|
setCustomAuthFailureHint(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -310,9 +313,10 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
setShowAuthPanel(false);
|
setShowAuthPanel(false);
|
||||||
if (editingCustomProviderId && editingCustomProviderId !== selectedProviderId) {
|
if (editingCustomProviderId && editingCustomProviderId !== selectedProviderId) {
|
||||||
setEditingCustomProviderId(null);
|
setEditingCustomProviderId(null);
|
||||||
|
setEditingCustomFormInitial(null);
|
||||||
setCustomAuthFailureHint(null);
|
setCustomAuthFailureHint(null);
|
||||||
}
|
}
|
||||||
}, [selectedProviderId, editingCustomProviderId, t]);
|
}, [selectedProviderId, editingCustomProviderId]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) {
|
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 }));
|
toast.success(t('settings.providers.page.toast.customProviderSaved', { provider: plan.name }));
|
||||||
setCandidateProviderId('');
|
setCandidateProviderId('');
|
||||||
setEditingCustomProviderId(null);
|
setEditingCustomProviderId(null);
|
||||||
|
setEditingCustomFormInitial(null);
|
||||||
setCustomAuthFailureHint(null);
|
setCustomAuthFailureHint(null);
|
||||||
setLastCustomPersistId(null);
|
setLastCustomPersistId(null);
|
||||||
await reloadOpenCodeConfiguration({ scopes: ['providers'], mode: 'active' });
|
await reloadOpenCodeConfiguration({ scopes: ['providers'], mode: 'active' });
|
||||||
@@ -587,6 +592,7 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
await handleDisconnectProvider(providerId);
|
await handleDisconnectProvider(providerId);
|
||||||
setEditingCustomProviderId(null);
|
setEditingCustomProviderId(null);
|
||||||
|
setEditingCustomFormInitial(null);
|
||||||
setCustomAuthFailureHint(null);
|
setCustomAuthFailureHint(null);
|
||||||
setLastCustomPersistId(null);
|
setLastCustomPersistId(null);
|
||||||
setCandidateProviderId('');
|
setCandidateProviderId('');
|
||||||
@@ -902,15 +908,16 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
|
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
|
||||||
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
|
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
|
||||||
const oauthAuthMethods = providerAuthMethods.filter((method) => normalizeAuthType(method) === 'oauth');
|
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)
|
const providerEnv = Array.isArray(selectedProvider.env)
|
||||||
? selectedProvider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
? 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 hasStoredAuth = Boolean(selectedSources?.auth.exists);
|
||||||
const hasEnvCredentials = providerEnv.length > 0;
|
const hasEnvCredentials = providerEnv.length > 0;
|
||||||
const hasCredentials = hasStoredAuth || hasEnvCredentials;
|
const hasCredentials = hasStoredAuth || hasEnvCredentials;
|
||||||
const authStatusIncomplete = isCustomProvider && sourcesLoaded && !hasCredentials;
|
const authStatusIncomplete = isEditableCustomProvider && !hasCredentials;
|
||||||
|
|
||||||
const filteredModels = providerModels.filter((model) => {
|
const filteredModels = providerModels.filter((model) => {
|
||||||
const name = typeof model?.name === 'string' ? model.name : '';
|
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);
|
return name.toLowerCase().includes(query) || id.toLowerCase().includes(query);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isCustomEditMode && isCustomProvider) {
|
if (isCustomEditMode && isEditableCustomProvider && editingCustomFormInitial) {
|
||||||
const initialValues = providerToCustomFormState(selectedProvider);
|
|
||||||
return (
|
return (
|
||||||
<SettingsPageLayout
|
<SettingsPageLayout
|
||||||
title={selectedProvider.name || selectedProvider.id}
|
title={selectedProvider.name || selectedProvider.id}
|
||||||
@@ -932,12 +938,13 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
<CustomProviderForm
|
<CustomProviderForm
|
||||||
mode="edit"
|
mode="edit"
|
||||||
existingProviderIDs={connectedProviderIds}
|
existingProviderIDs={connectedProviderIds}
|
||||||
initialValues={initialValues}
|
initialValues={editingCustomFormInitial}
|
||||||
allowExistingAuth={hasCredentials}
|
allowExistingAuth={hasCredentials || !sourcesLoaded}
|
||||||
busy={authBusyKey?.startsWith('custom:') ?? false}
|
busy={authBusyKey?.startsWith('custom:') ?? false}
|
||||||
authFailureHint={customAuthFailureHint}
|
authFailureHint={customAuthFailureHint}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setEditingCustomProviderId(null);
|
setEditingCustomProviderId(null);
|
||||||
|
setEditingCustomFormInitial(null);
|
||||||
setCustomAuthFailureHint(null);
|
setCustomAuthFailureHint(null);
|
||||||
setLastCustomPersistId(null);
|
setLastCustomPersistId(null);
|
||||||
}}
|
}}
|
||||||
@@ -960,13 +967,14 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
divider={false}
|
divider={false}
|
||||||
headerAction={(
|
headerAction={(
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{isCustomProvider ? (
|
{isEditableCustomProvider ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
className="!font-normal"
|
className="!font-normal"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCustomAuthFailureHint(null);
|
setCustomAuthFailureHint(null);
|
||||||
|
setEditingCustomFormInitial(providerToCustomFormState(selectedProvider));
|
||||||
setEditingCustomProviderId(selectedProvider.id);
|
setEditingCustomProviderId(selectedProvider.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
|||||||
import {
|
import {
|
||||||
buildAuthSetRequest,
|
buildAuthSetRequest,
|
||||||
buildProviderUpsertRequest,
|
buildProviderUpsertRequest,
|
||||||
|
isConfigDefinedCustomProvider,
|
||||||
isCustomOpenAICompatibleProvider,
|
isCustomOpenAICompatibleProvider,
|
||||||
providerToCustomFormState,
|
providerToCustomFormState,
|
||||||
validateCustomProvider,
|
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.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' });
|
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 {
|
export function providerToCustomFormState(provider: ProviderLikeForCustomForm): CustomProviderFormState {
|
||||||
const options = provider.options && typeof provider.options === 'object' ? provider.options : {};
|
const options = provider.options && typeof provider.options === 'object' ? provider.options : {};
|
||||||
const baseURL = typeof options.baseURL === 'string' ? options.baseURL : '';
|
const baseURL = typeof options.baseURL === 'string' ? options.baseURL : '';
|
||||||
|
|||||||
Reference in New Issue
Block a user