import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { XIcon } from "lucide-react"; import { type ComponentProps, useState } from "react"; import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; import { SecretInput } from "./SecretInput"; import { type PluginSettingsField, pluginSettingsForm } from "./plugins/pluginSettingsForm"; import { SettingResetButton, SettingsRow } from "./settingsLayout"; // `null` clears a saved value, so the plugin gets its default again. type DraftValue = string | boolean | null; export type Draft = Readonly>; const draftValue = (field: PluginSettingsField, value: Schema.Json | undefined) => { switch (field.kind) { case "boolean": return typeof value === "boolean" ? value : (field.defaultValue ?? false); case "secret": return ""; case "list": return Array.isArray(value) ? value.join("\n") : ""; default: return typeof value === "string" || typeof value === "number" ? String(value) : ""; } }; /** The changed fields as a settings patch, or the first problem a user can fix before saving. */ export const toPatch = ( fields: ReadonlyArray, saved: Draft, draft: Draft, ): { readonly patch: Schema.JsonObject } | { readonly problem: string } => { const patch: Record = {}; for (const field of fields) { const value = draft[field.key]; if (value === saved[field.key] || value === undefined) continue; if (value === null) { patch[field.key] = null; } else if (typeof value === "boolean") { patch[field.key] = value; } else if (field.kind === "secret") { if (value !== "") patch[field.key] = value; } else if (value.trim() === "") { patch[field.key] = null; } else if (field.kind === "number") { const parsed = Number(value); if (!Number.isFinite(parsed) || (field.integer && !Number.isInteger(parsed))) { return { problem: `${field.label} must be ${field.integer ? "a whole number" : "a number"}.`, }; } if (field.minimum !== undefined && parsed < field.minimum) { return { problem: `${field.label} must be at least ${field.minimum}.` }; } if (field.maximum !== undefined && parsed > field.maximum) { return { problem: `${field.label} must be at most ${field.maximum}.` }; } patch[field.key] = parsed; } else if (field.kind === "select") { patch[field.key] = field.options.find((option) => String(option) === value) ?? value; } else if (field.kind === "list") { patch[field.key] = value .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0); } else { patch[field.key] = value; } } return { patch }; }; const parseJsonObject = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.JsonObject)); /** The edited JSON as a settings patch, with `null` for each saved key it no longer has. */ export const toJsonPatch = ( saved: Schema.JsonObject, json: string, ): { readonly patch: Schema.JsonObject } | { readonly problem: string } => { const parsed = parseJsonObject(json); if (Option.isNone(parsed)) return { problem: "Settings must be a JSON object." }; const removed = Object.keys(saved).filter((key) => !Object.hasOwn(parsed.value, key)); return { patch: { ...Object.fromEntries(removed.map((key) => [key, null])), ...parsed.value } }; }; const without = (record: Readonly>, key: string) => Object.fromEntries(Object.entries(record).filter(([entry]) => entry !== key)); /** * Settings rows drawn from a plugin's JSON Schema. Each field saves on its own, as T3's own * settings rows do. `onSave` gets that field as a patch, `null` when cleared, and resolves with the * settings as saved or the reason they were not. */ export function JsonSchemaSettingsForm({ idPrefix, label, schema, settings, canSave, onSave, }: { readonly idPrefix: string; readonly label: string; readonly schema: Schema.JsonObject | undefined; readonly settings: Schema.JsonObject; readonly canSave: boolean; readonly onSave: ( patch: Schema.JsonObject, ) => Promise<{ readonly settings: Schema.JsonObject } | { readonly problem: string }>; }) { const form = pluginSettingsForm(schema); const [saved, setSaved] = useState(settings); // What the user just set, shown until it saves; it stays, with its problem, when it can't. const [pending, setPending] = useState>>({}); const [problems, setProblems] = useState>>({}); const settle = (key: string, problem: string | null) => { setProblems((all) => (problem === null ? without(all, key) : { ...all, [key]: problem })); if (problem === null) setPending((all) => without(all, key)); }; if (form.kind === "json") { return ( ); } if (form.kind !== "fields") return null; const commit = async (field: PluginSettingsField, value: DraftValue) => { const current = draftValue(field, saved[field.key]); const next = toPatch([field], { [field.key]: current }, { [field.key]: value }); setPending((all) => ({ ...all, [field.key]: value })); if ("problem" in next) return settle(field.key, next.problem); if (Object.keys(next.patch).length === 0) return settle(field.key, null); const result = await onSave(next.patch); if ("problem" in result) return settle(field.key, result.problem); setSaved(result.settings); settle(field.key, null); }; return ( <> {form.fields.map((field) => ( void commit(field, value)} /> ))} ); } /** For settings whose schema doesn't fit a form: the settings as JSON, saved together. */ function JsonSettingsRow({ label, settings, canSave, onSave, onSaved, }: { readonly label: string; readonly settings: Schema.JsonObject; readonly canSave: boolean; readonly onSave: ( patch: Schema.JsonObject, ) => Promise<{ readonly settings: Schema.JsonObject } | { readonly problem: string }>; readonly onSaved: (settings: Schema.JsonObject) => void; }) { const [json, setJson] = useState(() => JSON.stringify(settings, null, 2)); const [problem, setProblem] = useState(null); const [busy, setBusy] = useState(false); const save = async () => { const next = toJsonPatch(settings, json); if ("problem" in next) return setProblem(next.problem); setBusy(true); const result = await onSave(next.patch); setBusy(false); if ("problem" in result) return setProblem(result.problem); setProblem(null); onSaved(result.settings); setJson(JSON.stringify(result.settings, null, 2)); }; return ( {problem} : undefined} >