settings menu reworks (#5864)

This commit is contained in:
Anthony Stirling
2026-03-05 16:20:20 +00:00
committed by GitHub
parent ba2d10a75b
commit 0f7ee5c5b0
18 changed files with 640 additions and 580 deletions
@@ -0,0 +1,42 @@
import { Button, Group, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface SettingsStickyFooterProps {
isDirty: boolean;
saving: boolean;
loginEnabled: boolean;
onSave: () => void;
onDiscard: () => void;
}
export function SettingsStickyFooter({
isDirty,
saving,
loginEnabled,
onSave,
onDiscard,
}: SettingsStickyFooterProps) {
const { t } = useTranslation();
if (!isDirty || !loginEnabled) {
return null;
}
return (
<div className="settings-sticky-footer">
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">
{t('admin.settings.unsavedChanges.hint', 'You have unsaved changes')}
</Text>
<Group gap="sm">
<Button variant="default" onClick={onDiscard} size="sm">
{t('admin.settings.discard', 'Discard')}
</Button>
<Button onClick={onSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Group>
</div>
);
}
@@ -23,7 +23,9 @@ interface ProviderCardProps {
settings?: Record<string, any>;
onSave?: (settings: Record<string, any>) => void;
onDisconnect?: () => void;
onChange?: (settings: Record<string, any>) => void;
disabled?: boolean;
readOnly?: boolean;
}
export default function ProviderCard({
@@ -32,18 +34,19 @@ export default function ProviderCard({
settings = {},
onSave,
onDisconnect,
onChange,
disabled = false,
readOnly = false,
}: ProviderCardProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const [localSettings, setLocalSettings] = useState<Record<string, any>>(settings);
// Keep local settings in sync with incoming settings (values loaded from settings.yml)
// Update whenever parent settings change, whether expanded or not (important for Discard to work)
useEffect(() => {
if (!expanded) {
setLocalSettings(settings);
}
}, [settings, expanded]);
setLocalSettings(settings);
}, [settings]);
// Initialize local settings with defaults when opening an unconfigured provider
const handleConnectToggle = () => {
@@ -63,7 +66,12 @@ export default function ProviderCard({
const handleFieldChange = (key: string, value: any) => {
if (disabled) return; // Block changes when disabled
setLocalSettings((prev) => ({ ...prev, [key]: value }));
const updated = { ...localSettings, [key]: value };
setLocalSettings(updated);
// Notify parent of changes if onChange callback provided
if (onChange) {
onChange(updated);
}
};
const handleSave = () => {
@@ -225,22 +233,26 @@ export default function ProviderCard({
<Stack gap="md" mt="xs">
{provider.fields.map((field) => renderField(field))}
<Group justify="flex-end" mt="sm">
{onDisconnect && (
<Button
variant="outline"
color="red"
size="sm"
onClick={onDisconnect}
disabled={disabled}
>
{t('admin.settings.connections.disconnect', 'Disconnect')}
</Button>
)}
<Button size="sm" onClick={handleSave} disabled={disabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{!readOnly && (onSave || onDisconnect) && (
<Group justify="flex-end" mt="sm">
{onDisconnect && (
<Button
variant="outline"
color="red"
size="sm"
onClick={onDisconnect}
disabled={disabled}
>
{t('admin.settings.connections.disconnect', 'Disconnect')}
</Button>
)}
{onSave && (
<Button size="sm" onClick={handleSave} disabled={disabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
)}
</Group>
)}
</Stack>
</Collapse>
</Stack>
@@ -0,0 +1,79 @@
import { useEffect, useState, useRef } from 'react';
import { useUnsavedChanges } from '@app/contexts/UnsavedChangesContext';
interface UseSettingsDirtyReturn<T> {
isDirty: boolean;
resetToSnapshot: () => T;
markSaved: () => void;
}
/**
* Hook for managing dirty state in settings sections
* Handles JSON snapshot comparison and UnsavedChangesContext integration
*/
export function useSettingsDirty<T>(settings: T, loading: boolean): UseSettingsDirtyReturn<T> {
const { setIsDirty } = useUnsavedChanges();
const [originalSettingsSnapshot, setOriginalSettingsSnapshot] = useState<string>('');
const [isDirty, setLocalIsDirty] = useState(false);
const isInitialLoad = useRef(true);
const justSavedRef = useRef(false);
// Snapshot original settings after initial load OR after successful save (when refetch completes)
useEffect(() => {
if (loading || Object.keys(settings as Record<string, unknown>).length === 0) return;
// After initial load: set snapshot
if (isInitialLoad.current) {
setOriginalSettingsSnapshot(JSON.stringify(settings));
isInitialLoad.current = false;
return;
}
// After save: update snapshot to new server state so dirty tracking is accurate
if (justSavedRef.current) {
setOriginalSettingsSnapshot(JSON.stringify(settings));
setLocalIsDirty(false);
setIsDirty(false);
justSavedRef.current = false;
}
}, [loading, settings, setIsDirty]);
// Track dirty state by comparing current settings to snapshot
useEffect(() => {
if (!originalSettingsSnapshot || loading) return;
const currentSnapshot = JSON.stringify(settings);
const dirty = currentSnapshot !== originalSettingsSnapshot;
setLocalIsDirty(dirty);
setIsDirty(dirty);
}, [settings, originalSettingsSnapshot, loading, setIsDirty]);
// Clean up dirty state on unmount
useEffect(() => {
return () => {
setIsDirty(false);
};
}, [setIsDirty]);
const resetToSnapshot = (): T => {
if (originalSettingsSnapshot) {
try {
return JSON.parse(originalSettingsSnapshot) as T;
} catch (e) {
console.error('Failed to parse original settings:', e);
return settings;
}
}
return settings;
};
const markSaved = () => {
justSavedRef.current = true;
};
return {
isDirty,
resetToSnapshot,
markSaved,
};
}