This commit is contained in:
@@ -52,6 +52,7 @@ function PreferencesFormWrapper() {
|
||||
user={{
|
||||
defaultVisibility: auth.user.defaultVisibility ?? 'private',
|
||||
defaultVisibilityOrgId: auth.user.defaultVisibilityOrgId ?? null,
|
||||
hasLocalCredential: auth.user.hasLocalCredential,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -161,14 +162,39 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [toastIsError, setToastIsError] = useState(false);
|
||||
// Field keys currently flagged invalid via SectionFormProps.onValidityChange.
|
||||
// While non-empty, Save & Apply is disabled: an invalid draft never reaches
|
||||
// the config draft (the field withholds onChange), so allowing a save from
|
||||
// another dirty field would silently discard the invalid field's edit while
|
||||
// looking saved. Reporting components clear their key on unmount (see the
|
||||
// contract on SectionFormProps), so section switches / row removals cannot
|
||||
// leave Save bricked by a stale key.
|
||||
const [invalidKeys, setInvalidKeys] = useState<ReadonlySet<string>>(new Set());
|
||||
// Bumped whenever the draft is replaced wholesale out from under any
|
||||
// in-progress local field state (Discard Changes, or a fresh `data`
|
||||
// load/refetch below). Section forms with local "in-progress draft"
|
||||
// state that doesn't purely derive from the `value` prop (see
|
||||
// ExtraBodyField in LlmWorkersForm) fold this into their row keys to
|
||||
// force a remount — see SectionFormProps.resetToken for the contract.
|
||||
const [resetToken, setResetToken] = useState(0);
|
||||
|
||||
// Sync fetched config into draft
|
||||
// Sync fetched config into draft. This effect only re-runs when the
|
||||
// `data` object identity changes — i.e. the initial load and any
|
||||
// subsequent refetch (e.g. after a save-conflict reload) — never on
|
||||
// every render, so bumping resetToken here only fires on a genuine
|
||||
// external replacement of the draft, matching the Discard Changes case
|
||||
// below. Also clears invalidKeys: any field-local invalid draft that
|
||||
// hasn't reached this fresh `data` (and therefore never reached the
|
||||
// server) is being discarded along with the rest of the draft, so a
|
||||
// phantom invalid key must not survive to permanently block Save.
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setDraft(data.config);
|
||||
setEtag(data.etag);
|
||||
setOverriddenByEnv(data.overriddenByEnv);
|
||||
setIsDirty(false);
|
||||
setInvalidKeys(new Set());
|
||||
setResetToken(t => t + 1);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
@@ -177,10 +203,27 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleValidityChange = useCallback((fieldKey: string, valid: boolean) => {
|
||||
setInvalidKeys(prev => {
|
||||
if (valid ? !prev.has(fieldKey) : prev.has(fieldKey)) return prev; // no-op → keep identity
|
||||
const next = new Set(prev);
|
||||
if (valid) next.delete(fieldKey);
|
||||
else next.add(fieldKey);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (data) {
|
||||
setDraft(data.config);
|
||||
setIsDirty(false);
|
||||
// Discard doesn't change `data`, so the [data] sync effect above
|
||||
// won't fire — clear any phantom invalid key and bump resetToken
|
||||
// here directly so field-local draft state (e.g. ExtraBodyField's
|
||||
// in-progress textarea + JSON error) remounts from the reverted
|
||||
// value instead of getting stuck showing a stale error forever.
|
||||
setInvalidKeys(new Set());
|
||||
setResetToken(t => t + 1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -218,7 +261,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
if (error) return <div className="text-sm text-red-500">{t('configForm.loadError')}</div>;
|
||||
if (!draft) return null;
|
||||
|
||||
const formProps = { config: draft, onChange: handleChange, overriddenByEnv };
|
||||
const formProps = { config: draft, onChange: handleChange, overriddenByEnv, onValidityChange: handleValidityChange, resetToken };
|
||||
|
||||
const sectionForm = (() => {
|
||||
switch (section) {
|
||||
@@ -290,6 +333,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
})();
|
||||
|
||||
const dirty = dirtyCount > 0;
|
||||
const blockedByInvalid = invalidKeys.size > 0;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl pb-20">
|
||||
@@ -309,6 +353,11 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
<span className={`text-2xs mr-auto ${toastIsError ? 'text-red-600' : 'text-emerald-700 dark:text-emerald-300'}`}>
|
||||
{toast}
|
||||
</span>
|
||||
) : blockedByInvalid ? (
|
||||
<span className="text-xs mr-auto text-red-600 dark:text-red-400 flex items-center gap-1.5 font-medium min-w-0">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-red-500 flex-shrink-0" aria-hidden />
|
||||
<span className="truncate">{t('configForm.invalidBlocked')}</span>
|
||||
</span>
|
||||
) : dirty ? (
|
||||
<span className="text-xs mr-auto text-amber-800 dark:text-amber-300 flex items-center gap-1.5 font-medium min-w-0">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse flex-shrink-0" aria-hidden />
|
||||
@@ -328,7 +377,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
disabled={!dirty || saving || blockedByInvalid}
|
||||
className="px-3 h-8 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50 transition-colors whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{saving ? 'Saving...' : (
|
||||
|
||||
Reference in New Issue
Block a user