"use client"; import { useEffect, useMemo, useState } from "react"; import { toast } from "@/lib/toast/sonner"; // Module-level `t`: these resolve when the save/load callback runs. import { t } from "@/lib/i18n"; import { getPluginConfig, updatePluginConfig } from "@/lib/api/domains/plugins-api"; import { SECRET_MASK, buildInitialValues, missingRequiredFields, parseConfigSchema, serializeConfigValues, } from "@/lib/plugins/config-schema"; import type { PluginRecord } from "@/lib/types/plugins"; type SaveStatus = "idle" | "loading" | "success" | "error"; type FormValues = Record; function maskSecretsIn( source: FormValues, fields: ReturnType, ): FormValues { const masked = { ...source }; for (const field of fields) { const current = masked[field.name]; if (field.secret && typeof current === "string" && current !== "") { masked[field.name] = SECRET_MASK; } } return masked; } /** * Load/edit/save state for one plugin's schema-driven settings form. * Mirrors use-plugin-actions' local-hook pattern: fetch + toast wiring lives * here, the components stay presentational. Saving PATCHes the full config * (secret fields carrying the mask keep their stored value server-side) and * then re-fetches the masked config so the form reflects what is stored. */ export function usePluginConfigForm(plugin: PluginRecord | null) { const fields = useMemo(() => parseConfigSchema(plugin?.config_schema), [plugin?.config_schema]); const [values, setValues] = useState({}); const [initialValues, setInitialValues] = useState({}); const [configLoading, setConfigLoading] = useState(false); const [configError, setConfigError] = useState(null); const [saveStatus, setSaveStatus] = useState("idle"); const pluginId = plugin?.id ?? null; const hasFields = fields.length > 0; useEffect(() => { if (!pluginId || !hasFields) return; let cancelled = false; setConfigLoading(true); setConfigError(null); getPluginConfig(pluginId, { cache: "no-store" }) .then((config) => { if (cancelled) return; const initial = buildInitialValues(fields, config); setValues(initial); setInitialValues(initial); }) .catch((err) => { if (!cancelled) { setConfigError(err instanceof Error ? err.message : t("plugins:failedToLoadSettings")); } }) .finally(() => { if (!cancelled) setConfigLoading(false); }); return () => { cancelled = true; }; // fields is derived solely from plugin.config_schema; pluginId is the // real reload trigger. // eslint-disable-next-line react-hooks/exhaustive-deps }, [pluginId, hasFields]); const isDirty = useMemo( () => fields.some((field) => values[field.name] !== initialValues[field.name]), [fields, values, initialValues], ); const missing = useMemo(() => missingRequiredFields(fields, values), [fields, values]); const handleChange = (name: string, value: string | boolean) => { setValues((prev) => ({ ...prev, [name]: value })); setSaveStatus("idle"); }; const handleSave = async () => { if (!pluginId) return; if (missing.length > 0) { // The field names come from the plugin's config_schema, so only the // "Required:" frame is copy. const message = t("plugins:requiredFields", { fields: missing.join(", ") }); toast.error(message); throw new Error(message); } setSaveStatus("loading"); try { await updatePluginConfig(pluginId, serializeConfigValues(fields, values)); } catch (err) { setSaveStatus("error"); toast.error(err instanceof Error ? err.message : t("plugins:failedToSaveSettings")); throw err; } // The config IS persisted from here on — a refetch failure (e.g. a // transient hiccup while the plugin restarts) must not be reported as a // save failure, and the typed cleartext secret must not stay on screen. try { const refreshed = await getPluginConfig(pluginId, { cache: "no-store" }); const initial = buildInitialValues(fields, refreshed); setValues(initial); setInitialValues(initial); toast.success(t("plugins:settingsSaved")); } catch { const masked = maskSecretsIn(values, fields); setValues(masked); setInitialValues(masked); toast.warning(t("plugins:settingsSavedReloadFailed")); } setSaveStatus("success"); }; return { fields, values, initialValues, configLoading, configError, saveStatus, isDirty, canSave: missing.length === 0, invalidReason: missing.length > 0 ? t("plugins:requiredFields", { fields: missing.join(", ") }) : undefined, revision: JSON.stringify(values), handleChange, handleSave, discard: () => { setValues(initialValues); setSaveStatus("idle"); }, }; }