sync: update from private repo (9a86f49b)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useConfig } from '../../hooks/useConfig';
|
||||
@@ -33,13 +33,18 @@ import { AuthForm } from './AuthForm';
|
||||
import { OrgsForm } from './OrgsForm';
|
||||
import { PetsForm } from './PetsForm';
|
||||
import { A2aDelegationsForm } from './A2aDelegationsForm';
|
||||
import { ChatConnectorsForm } from './ChatConnectorsForm';
|
||||
import { useToast } from '../../hooks/useToast';
|
||||
import { settingsFieldId, type ConfigDraftStatus } from './types';
|
||||
|
||||
import { useAuthState } from '../../App';
|
||||
|
||||
interface ConfigFormProps {
|
||||
section: string;
|
||||
isAdmin: boolean;
|
||||
focusFieldKey?: string;
|
||||
focusFieldRequestId?: number;
|
||||
onDraftStatusChange?: (status: ConfigDraftStatus) => void;
|
||||
}
|
||||
|
||||
function PreferencesFormWrapper() {
|
||||
@@ -116,8 +121,14 @@ function countDiff(a: any, b: any): number {
|
||||
return norm(a) === norm(b) ? 0 : 1;
|
||||
}
|
||||
|
||||
export function ConfigForm({ section, isAdmin }: ConfigFormProps) {
|
||||
export function ConfigForm({ section, isAdmin, onDraftStatusChange, focusFieldKey, focusFieldRequestId }: ConfigFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const usesAdminDraft = isAdmin && !['preferences', 'notifications', 'memory-learning', 'pets', 'a2a-delegations', 'chat-connectors', 'organizations'].includes(section);
|
||||
|
||||
useEffect(() => {
|
||||
if (!usesAdminDraft) onDraftStatusChange?.({ dirtySectionIds: new Set() });
|
||||
}, [onDraftStatusChange, usesAdminDraft]);
|
||||
|
||||
// User-scoped sections use their own per-user APIs and should not load the
|
||||
// admin /api/config draft. Render them stand-alone without the global save bar.
|
||||
if (section === 'preferences') {
|
||||
@@ -143,14 +154,20 @@ export function ConfigForm({ section, isAdmin }: ConfigFormProps) {
|
||||
if (section === 'organizations') {
|
||||
return <OrgsForm />;
|
||||
}
|
||||
// Chat connectors: admin-managed via /api/admin/chat/bindings (not
|
||||
// config.yaml), so render stand-alone without the global save bar —
|
||||
// mirrors a2a-delegations/organizations above.
|
||||
if (section === 'chat-connectors') {
|
||||
return <div className="max-w-2xl"><ChatConnectorsForm /></div>;
|
||||
}
|
||||
// Step 8: 'gateway-keys' bookmarks are redirected to 'gateway-server'
|
||||
// by SettingsPage via LEGACY_SECTION_REDIRECT, so we no longer need a
|
||||
// dedicated branch here. The keys UI lives inside GatewayServerForm
|
||||
// as the Virtual Keys section.
|
||||
return <ConfigFormInner section={section} isAdmin={isAdmin} />;
|
||||
return <ConfigFormInner section={section} isAdmin={isAdmin} onDraftStatusChange={onDraftStatusChange} focusFieldKey={focusFieldKey} focusFieldRequestId={focusFieldRequestId} />;
|
||||
}
|
||||
|
||||
function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
function ConfigFormInner({ section, onDraftStatusChange, focusFieldKey, focusFieldRequestId }: ConfigFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const { data, isLoading, error, refetch } = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -170,6 +187,8 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
// contract on SectionFormProps), so section switches / row removals cannot
|
||||
// leave Save bricked by a stale key.
|
||||
const [invalidKeys, setInvalidKeys] = useState<ReadonlySet<string>>(new Set());
|
||||
const [dirtySectionIds, setDirtySectionIds] = useState<ReadonlySet<string>>(new Set());
|
||||
const completedFocusRequestRef = useRef<number | undefined>();
|
||||
// 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"
|
||||
@@ -194,6 +213,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
setOverriddenByEnv(data.overriddenByEnv);
|
||||
setIsDirty(false);
|
||||
setInvalidKeys(new Set());
|
||||
setDirtySectionIds(new Set());
|
||||
setResetToken(t => t + 1);
|
||||
}
|
||||
}, [data]);
|
||||
@@ -201,7 +221,8 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
const handleChange = useCallback((path: string, value: any) => {
|
||||
setDraft((prev: any) => setNestedValue(prev, path, value));
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
setDirtySectionIds(prev => prev.has(section) ? prev : new Set(prev).add(section));
|
||||
}, [section]);
|
||||
|
||||
const handleValidityChange = useCallback((fieldKey: string, valid: boolean) => {
|
||||
setInvalidKeys(prev => {
|
||||
@@ -213,6 +234,32 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleNavigateToInvalid = () => {
|
||||
const fieldKey = invalidKeys.values().next().value;
|
||||
if (!fieldKey) return;
|
||||
|
||||
const field = document.getElementById(settingsFieldId(fieldKey));
|
||||
if (!(field instanceof HTMLElement)) return;
|
||||
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
field.scrollIntoView({ block: 'center', behavior: reduceMotion ? 'auto' : 'smooth' });
|
||||
field.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusFieldKey) return;
|
||||
if (completedFocusRequestRef.current === focusFieldRequestId) return;
|
||||
const field = document.getElementById(settingsFieldId(focusFieldKey));
|
||||
if (!(field instanceof HTMLElement)) return;
|
||||
completedFocusRequestRef.current = focusFieldRequestId;
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
field.scrollIntoView({ block: 'center', behavior: reduceMotion ? 'auto' : 'smooth' });
|
||||
field.focus({ preventScroll: true });
|
||||
field.classList.add('ring-2', 'ring-amber-400');
|
||||
const timer = window.setTimeout(() => field.classList.remove('ring-2', 'ring-amber-400'), 1600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [focusFieldKey, focusFieldRequestId, draft, section]);
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (data) {
|
||||
setDraft(data.config);
|
||||
@@ -223,6 +270,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
// in-progress textarea + JSON error) remounts from the reverted
|
||||
// value instead of getting stuck showing a stale error forever.
|
||||
setInvalidKeys(new Set());
|
||||
setDirtySectionIds(new Set());
|
||||
setResetToken(t => t + 1);
|
||||
}
|
||||
};
|
||||
@@ -240,6 +288,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
setIsDirty(false);
|
||||
setDirtySectionIds(new Set());
|
||||
setToastIsError(false);
|
||||
setToast(t('configForm.saved'));
|
||||
setTimeout(() => setToast(null), 2000);
|
||||
@@ -253,6 +302,15 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
};
|
||||
|
||||
const dirtyCount = isDirty && data ? countDiff(data.config, draft) : 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (dirtyCount === 0 && dirtySectionIds.size > 0) setDirtySectionIds(new Set());
|
||||
}, [dirtyCount, dirtySectionIds]);
|
||||
|
||||
useEffect(() => {
|
||||
onDraftStatusChange?.({ dirtySectionIds: dirtyCount > 0 ? dirtySectionIds : new Set() });
|
||||
}, [dirtyCount, dirtySectionIds, onDraftStatusChange]);
|
||||
|
||||
// Arm beforeunload + register an in-app guard so navigating elsewhere
|
||||
// (e.g. clicking a TopBar tab) prompts when there are unsaved fields.
|
||||
useUnsavedGuard(dirtyCount > 0);
|
||||
@@ -354,15 +412,24 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
{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>
|
||||
<div className="mr-auto flex min-w-0 items-center gap-2">
|
||||
<span className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1.5 font-medium min-w-0" aria-live="polite">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNavigateToInvalid}
|
||||
className="h-8 flex-shrink-0 rounded-md border border-red-300 bg-canvas px-2 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
|
||||
>
|
||||
{t('configForm.showInvalidField')}
|
||||
</button>
|
||||
</div>
|
||||
) : 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 />
|
||||
<span className="truncate">
|
||||
<span className="hidden sm:inline">{t('configForm.unsaved', { count: dirtyCount })}</span>
|
||||
<span className="hidden sm:inline">{t('configForm.unsavedAdmin', { count: dirtyCount })}</span>
|
||||
<span className="sm:hidden">{t('configForm.unsavedShort', { count: dirtyCount })}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user