mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +00:00
fix(settings): toast and block save when node settings fail to load (#1654)
* fix(settings): toast and block save when node settings fail to load Failed GET /settings no longer seeds defaults as the dirty baseline. Shared loader pins node identity, shows an error callout until an authoritative load succeeds, and keeps Save disabled across all eight node-scoped sections. * test(settings): cover load-failure toast and App Store refresh pin Remove the incorrect settings-load-failure docs image. Assert toast ownership for current vs stale/aborted loads, and pin App Store cache refresh to the PATCH node across a mid-save switch. * fix(settings): reject malformed settings bodies and skip pre-node bootstrap load Treat non-object 200 /settings payloads as load failures so null or array bodies cannot seed editable defaults. Do not fetch or toast while the active node id is still undefined, so hard refresh settles to a single failure toast.
This commit is contained in:
@@ -15,6 +15,8 @@ import { SettingsField } from './SettingsField';
|
||||
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
|
||||
import { useMastheadStats } from './MastheadStatsContext';
|
||||
import { useSettingsDirty } from './useSettingsDirty';
|
||||
import { useNodeSettingsLoad } from './useNodeSettingsLoad';
|
||||
import { SettingsLoadGate } from './SettingsLoadError';
|
||||
|
||||
interface DeveloperSectionProps {
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
@@ -39,15 +41,17 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const readOnly = !isAdmin;
|
||||
const { settings, setSettings, hasChanges, reset, markSaved } = useSettingsDirty<DeveloperFields>({ ...DEFAULT_DEVELOPER });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const reportDirty = isCurrentNodeLoaded && hasChanges;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(hasChanges);
|
||||
}, [hasChanges, onDirtyChange]);
|
||||
onDirtyChange?.(reportDirty);
|
||||
}, [reportDirty, onDirtyChange]);
|
||||
|
||||
useMastheadStats(
|
||||
isLoading
|
||||
!isCurrentNodeLoaded
|
||||
? null
|
||||
: [
|
||||
{
|
||||
@@ -59,30 +63,27 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
let cancelled = false;
|
||||
setIsSaving(false);
|
||||
void (async () => {
|
||||
const nodeData = await load();
|
||||
if (cancelled || !nodeData) return;
|
||||
const safe: DeveloperFields = {
|
||||
developer_mode: (nodeData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
|
||||
};
|
||||
reset(safe);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
fetchSettings();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeNode?.id]);
|
||||
}, [activeNode?.id, load, reset]);
|
||||
|
||||
const onSettingChange = <K extends keyof DeveloperFields>(key: K, value: DeveloperFields[K]) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
const saveGuard = captureSaveGuard();
|
||||
const submitted = { ...settings };
|
||||
const payload = {
|
||||
developer_mode: submitted.developer_mode,
|
||||
@@ -91,8 +92,10 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
|
||||
try {
|
||||
const res = await apiFetch('/settings', {
|
||||
method: 'PATCH',
|
||||
nodeId: saveGuard.nodeId,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!isSaveOwner(saveGuard)) return;
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || err?.message || 'Failed to save settings.');
|
||||
@@ -104,15 +107,15 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
|
||||
detail: { changedKeys: Object.keys(payload) },
|
||||
}));
|
||||
} catch (e: unknown) {
|
||||
if (!isSaveOwner(saveGuard)) return;
|
||||
toast.error((e as Error)?.message || 'Something went wrong.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
if (isSaveOwner(saveGuard)) setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <SectionSkeleton />;
|
||||
|
||||
return (
|
||||
<SettingsLoadGate phase={phase} isCurrentNodeLoaded={isCurrentNodeLoaded} skeleton={<SectionSkeleton />}>
|
||||
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
|
||||
<SettingsSection title="Diagnostics">
|
||||
<SettingsField
|
||||
@@ -129,7 +132,7 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
|
||||
|
||||
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? 'unsaved changes' : undefined)}>
|
||||
{!readOnly && (
|
||||
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
|
||||
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
@@ -142,5 +145,6 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
|
||||
)}
|
||||
</SettingsActions>
|
||||
</fieldset>
|
||||
</SettingsLoadGate>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user