f2510dfcb5
Co-authored-by: Kandev Agent <agent@kandev.dev>
146 lines
4.9 KiB
TypeScript
146 lines
4.9 KiB
TypeScript
"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<string, string | boolean>;
|
|
|
|
function maskSecretsIn(
|
|
source: FormValues,
|
|
fields: ReturnType<typeof parseConfigSchema>,
|
|
): 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<FormValues>({});
|
|
const [initialValues, setInitialValues] = useState<FormValues>({});
|
|
const [configLoading, setConfigLoading] = useState(false);
|
|
const [configError, setConfigError] = useState<string | null>(null);
|
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>("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");
|
|
},
|
|
};
|
|
}
|