Files
silo-server/web/src/hooks/useSettingsForm.ts
T

165 lines
5.4 KiB
TypeScript

import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import type { AdminSettingsConnectionCheckRequest } from "@/api/types";
import {
useAdminServerSettings,
useUpdateServerSettings,
useAdminSensitiveStatus,
} from "@/hooks/queries/admin/settings";
interface UseSettingsFormOptions {
/** Setting keys this section manages */
keys: string[];
}
export function useSettingsForm({ keys }: UseSettingsFormOptions) {
const { data: settings, isLoading } = useAdminServerSettings();
const { data: sensitiveData } = useAdminSensitiveStatus();
const updateSettings = useUpdateServerSettings();
const [localValues, setLocalValues] = useState<Record<string, string>>({});
const [dirty, setDirty] = useState<Set<string>>(new Set());
const [restartRequired, setRestartRequired] = useState(false);
const editVersions = useRef(new Map<string, number>());
const dirtyRef = useRef(dirty);
useEffect(() => {
dirtyRef.current = dirty;
}, [dirty]);
// Treat equivalent key lists as stable even if a caller constructs the
// array inline. This keeps server hydration tied to actual query/key changes
// instead of every render.
const keySignature = keys.join("\u0000");
const stableKeys = useMemo(
() => (keySignature === "" ? [] : keySignature.split("\u0000")),
[keySignature],
);
// Sync from server when settings load
useEffect(() => {
if (!settings) return;
setLocalValues((prev) => {
const next = { ...prev };
let changed = false;
for (const key of stableKeys) {
// Only set if not dirty (user hasn't edited)
if (!dirtyRef.current.has(key)) {
const serverValue = settings[key] ?? "";
if (next[key] !== serverValue) {
next[key] = serverValue;
changed = true;
}
}
}
return changed ? next : prev;
});
}, [settings, stableKeys]);
const getValue = useCallback(
(key: string) => localValues[key] ?? settings?.[key] ?? "",
[localValues, settings],
);
const setValue = useCallback((key: string, value: string) => {
editVersions.current.set(key, (editVersions.current.get(key) ?? 0) + 1);
setLocalValues((prev) => ({ ...prev, [key]: value }));
setDirty((prev) => new Set(prev).add(key));
}, []);
// Revert one staged field without disturbing other edits. This is also how
// a redacted secret toggle can cancel a pending clear without needing the
// server to send the secret back to the browser.
const resetValue = useCallback(
(key: string) => {
editVersions.current.set(key, (editVersions.current.get(key) ?? 0) + 1);
setLocalValues((prev) => ({ ...prev, [key]: settings?.[key] ?? "" }));
setDirty((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
},
[settings],
);
const dirtyCount = dirty.size;
const dirtyKeys = useMemo(() => Array.from(dirty), [dirty]);
const isDirty = useCallback((key: string) => dirty.has(key), [dirty]);
const buildConnectionCheckRequest = useCallback(
(selectedKeys: string[] = keys): AdminSettingsConnectionCheckRequest => ({
values: Object.fromEntries(
selectedKeys.map((key) => [key, localValues[key] ?? settings?.[key] ?? ""]),
),
dirty_keys: selectedKeys.filter((key) => dirty.has(key)),
}),
[dirty, keys, localValues, settings],
);
const save = useCallback(async () => {
if (dirty.size === 0) return;
const submittedKeys = Array.from(dirty);
const values = Object.fromEntries(submittedKeys.map((key) => [key, localValues[key] ?? ""]));
const submittedVersions = new Map(
submittedKeys.map((key) => [key, editVersions.current.get(key) ?? 0]),
);
const result = await updateSettings.mutateAsync(values);
const settledKeys = submittedKeys.filter(
(key) => (editVersions.current.get(key) ?? 0) === submittedVersions.get(key),
);
setLocalValues((previous) => {
const next = { ...previous };
for (const key of settledKeys) {
// The server returns canonical non-secret values. Sensitive values are
// intentionally omitted, so erase those drafts after a successful save
// instead of retaining plaintext credentials in component state.
next[key] = result.values[key] ?? "";
}
return next;
});
setDirty((current) => {
const next = new Set(current);
for (const key of settledKeys) {
next.delete(key);
}
return next;
});
// Once a restart-required batch was saved, keep the banner up until the
// server actually restarts.
if (result.restart_required) {
setRestartRequired(true);
}
}, [dirty, localValues, updateSettings]);
const discard = useCallback(() => {
if (!settings) return;
const reset: Record<string, string> = {};
for (const key of keys) {
editVersions.current.set(key, (editVersions.current.get(key) ?? 0) + 1);
reset[key] = settings[key] ?? "";
}
setLocalValues((prev) => ({ ...prev, ...reset }));
setDirty(new Set());
}, [settings, keys]);
const sensitiveConfigured = sensitiveData?.configured ?? [];
const sensitiveManagedByEnv = sensitiveData?.managed_by_env ?? [];
return {
isLoading,
getValue,
setValue,
resetValue,
dirtyCount,
dirtyKeys,
isDirty,
save,
discard,
isSaving: updateSettings.isPending,
restartRequired,
sensitiveConfigured,
sensitiveManagedByEnv,
buildConnectionCheckRequest,
};
}