import { useEffect, useRef, useState } from 'react'; import type { ConfigResponse, ConfigUpdateRequest } from '../../../../../packages/api-contracts/src/index.js'; import { useUnsavedChangesFlag } from '../../components/unsaved-changes.js'; import { useSaveSettingsMutation } from './settings-queries.js'; export interface ConfigCardValidation { /** Absent when the draft is invalid; `errors` explains why. */ payload?: ConfigUpdateRequest; errors: Record; } export interface ConfigCardSpec { /** Stable id used for the page-level unsaved-changes guard. */ id: string; toDraft(config: ConfigResponse): Draft; validate(draft: Draft): ConfigCardValidation; /** * Consequences the operator must confirm before this save is applied. * Compared against the last persisted draft so re-saving an already-live * risky value does not prompt again. */ risks?(draft: Draft, persisted: Draft): string[]; } export interface ConfigCard { draft: Draft; persisted: Draft; errors: Record; errorCount: number; isDirty: boolean; /** The server changed this card's values while the operator had edits open. */ hasServerChanged: boolean; isSaving: boolean; isSaved: boolean; saveError: Error | null; /** Non-null while a risky save waits on confirmation. */ pendingRisks: string[] | null; update(next: Draft): void; reset(): void; save(): void; confirmRisks(): void; cancelRisks(): void; } function isSame(left: unknown, right: unknown): boolean { return JSON.stringify(left) === JSON.stringify(right); } interface CardState { persisted: Draft; draft: Draft; hasServerChanged: boolean; } /** * Owns one card's draft lifecycle: seed from the server config, track dirty * state, validate, confirm risk, and save a *partial* config update. * * Cards save independently, so a card only ever submits the keys it owns. An * invalid value in one card can never block another card's save. */ export function useConfigCard(config: ConfigResponse, spec: ConfigCardSpec): ConfigCard { const specRef = useRef(spec); specRef.current = spec; const [state, setState] = useState>(() => { const draft = spec.toDraft(config); return { persisted: draft, draft, hasServerChanged: false }; }); const [errors, setErrors] = useState>({}); const [pendingRisks, setPendingRisks] = useState<{ payload: ConfigUpdateRequest; reasons: string[] } | null>(null); const saveMutation = useSaveSettingsMutation(); const seededConfig = useRef(config); const isDirty = !isSame(state.draft, state.persisted); useUnsavedChangesFlag(spec.id, isDirty); // Reconcile server refetches. A sibling card's save also produces a new // config object, so adopt silently whenever the server's values for *this* // card match what is on screen; only warn when they genuinely diverge. useEffect(() => { if (seededConfig.current === config) return; seededConfig.current = config; const serverDraft = specRef.current.toDraft(config); setState((current) => { // Nothing this card owns moved — a sibling card saved, or an unrelated // refetch landed. Leave the operator's edits completely alone. if (isSame(serverDraft, current.persisted)) return current; // The server now agrees with what is on screen, so there is no conflict. if (isSame(serverDraft, current.draft)) { return { persisted: serverDraft, draft: current.draft, hasServerChanged: false }; } // No local edits to protect: adopt the server's values silently. if (isSame(current.draft, current.persisted)) { return { persisted: serverDraft, draft: serverDraft, hasServerChanged: false }; } return { ...current, persisted: serverDraft, hasServerChanged: true }; }); }, [config]); function submit(payload: ConfigUpdateRequest) { saveMutation.mutate(payload, { onSuccess() { setPendingRisks(null); setState((current) => ({ persisted: current.draft, draft: current.draft, hasServerChanged: false })); }, }); } return { draft: state.draft, persisted: state.persisted, errors, errorCount: Object.keys(errors).length, isDirty, hasServerChanged: state.hasServerChanged, isSaving: saveMutation.isPending, isSaved: saveMutation.isSuccess && !isDirty, saveError: saveMutation.isError ? saveMutation.error : null, pendingRisks: pendingRisks?.reasons ?? null, update(next: Draft) { saveMutation.reset(); setErrors({}); setState((current) => ({ ...current, draft: next })); }, reset() { saveMutation.reset(); setErrors({}); setState((current) => ({ ...current, draft: current.persisted, hasServerChanged: false })); }, save() { saveMutation.reset(); const validation = specRef.current.validate(state.draft); setErrors(validation.errors); if (!validation.payload) return; const reasons = specRef.current.risks?.(state.draft, state.persisted) ?? []; if (reasons.length > 0) { setPendingRisks({ payload: validation.payload, reasons }); return; } submit(validation.payload); }, confirmRisks() { if (pendingRisks) submit(pendingRisks.payload); }, cancelRisks() { setPendingRisks(null); }, }; }