Files
sencho/frontend/src/components/settings/DeveloperSection.tsx
T
Anso 49f1b49ac6 fix(settings): clear stale pending and unsaved indicators after save (#1370)
* fix(settings): clear stale pending and unsaved indicators after save

Settings sections held their saved baseline in a mutable ref and computed
the dirty count with useMemo keyed on the live values, so updating the ref
on a successful save never re-ran the calculation. The masthead pending
count and the sidebar unsaved dot stayed stale until the section remounted,
making operators think the save had failed.

Move the baseline into state behind a shared useSettingsDirty hook with
separate load (reset) and save-success (markSaved) operations. markSaved
adopts the submitted snapshot as the baseline only, so an edit made while a
save is in flight survives and a failed save stays dirty and retryable.
Migrate the five sections that used the pattern.

* test(settings): await the save-failure retry assertion to avoid a race

In the failed-save reconcile test, wait for the Save button to re-enable
after the PATCH settles instead of asserting synchronously, so the retry
check cannot race the isSaving reset.
2026-06-14 13:42:01 -04:00

147 lines
5.5 KiB
TypeScript

import { useState, useEffect } from 'react';
import { TogglePill } from '@/components/ui/toggle-pill';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/context/AuthContext';
import { RefreshCw } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
import type { SenchoSettingsChangedDetail } from '@/lib/events';
import { DEFAULT_SETTINGS } from './types';
import type { PatchableSettings } from './types';
import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { useSettingsDirty } from './useSettingsDirty';
interface DeveloperSectionProps {
onDirtyChange?: (dirty: boolean) => void;
}
function SectionSkeleton() {
return (
<div className="space-y-3 rounded-lg border border-glass-border bg-glass p-4">
<Skeleton className="h-10 w-full" />
</div>
);
}
type DeveloperFields = Pick<PatchableSettings, 'developer_mode'>;
const DEFAULT_DEVELOPER: DeveloperFields = {
developer_mode: DEFAULT_SETTINGS.developer_mode,
};
export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
const { isAdmin } = useAuth();
const { activeNode } = useNodes();
const readOnly = !isAdmin;
const { settings, setSettings, hasChanges, reset, markSaved } = useSettingsDirty<DeveloperFields>({ ...DEFAULT_DEVELOPER });
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
useMastheadStats(
isLoading
? null
: [
{
label: 'DEV MODE',
value: settings.developer_mode === '1' ? 'on' : 'off',
tone: settings.developer_mode === '1' ? 'warn' : 'subtitle',
},
],
);
useEffect(() => {
const fetchSettings = async () => {
setIsLoading(true);
try {
const nodeRes = await apiFetch('/settings');
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: DeveloperFields = {
developer_mode: (nodeData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
};
reset(safe);
} catch (e) {
console.error('Failed to fetch developer settings', e);
} finally {
setIsLoading(false);
}
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
const onSettingChange = <K extends keyof DeveloperFields>(key: K, value: DeveloperFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveSettings = async () => {
const submitted = { ...settings };
const payload = {
developer_mode: submitted.developer_mode,
};
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
return;
}
markSaved(submitted);
toast.success('Developer settings saved.');
window.dispatchEvent(new CustomEvent<SenchoSettingsChangedDetail>(SENCHO_SETTINGS_CHANGED, {
detail: { changedKeys: Object.keys(payload) },
}));
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
}
};
if (isLoading) return <SectionSkeleton />;
return (
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
<SettingsSection title="Diagnostics">
<SettingsField
label="Developer mode"
helper="Enable real-time metrics streams and verbose debug diagnostics in the UI."
>
<TogglePill
id="developer_mode"
checked={settings.developer_mode === '1'}
onChange={(c) => onSettingChange('developer_mode', c ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? 'unsaved changes' : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Saving
</>
) : (
'Save settings'
)}
</SettingsPrimaryButton>
)}
</SettingsActions>
</fieldset>
);
}