Consolidate doc-service settings to a single Save changes button

Lift state to page level, fire both upload-limits and watch-directory
mutations from one button. Add noSaveButton and onChange props to
PluginSchemaForm to support this pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-18 10:49:46 +02:00
parent c45236651b
commit f16c290b92
2 changed files with 109 additions and 101 deletions
+11 -1
View File
@@ -17,6 +17,10 @@ interface PluginSchemaFormProps {
isPending?: boolean; isPending?: boolean;
isError?: boolean; isError?: boolean;
isSuccess?: boolean; isSuccess?: boolean;
/** When true, the built-in save button row is hidden (caller renders its own). */
noSaveButton?: boolean;
/** Expose current form state to the parent via callback on every change. */
onChange?: (values: Record<string, unknown>) => void;
} }
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
@@ -48,6 +52,8 @@ export default function PluginSchemaForm({
isPending, isPending,
isError, isError,
isSuccess, isSuccess,
noSaveButton,
onChange,
}: PluginSchemaFormProps) { }: PluginSchemaFormProps) {
const [form, setForm] = useState<Record<string, unknown>>(values); const [form, setForm] = useState<Record<string, unknown>>(values);
@@ -56,7 +62,9 @@ export default function PluginSchemaForm({
}, [values]); }, [values]);
const setField = (key: string, value: unknown) => { const setField = (key: string, value: unknown) => {
setForm((prev) => ({ ...prev, [key]: value })); const next = { ...form, [key]: value };
setForm(next);
onChange?.(next);
}; };
return ( return (
@@ -103,6 +111,7 @@ export default function PluginSchemaForm({
</div> </div>
))} ))}
{!noSaveButton && (
<div className="flex items-center gap-3 pt-2"> <div className="flex items-center gap-3 pt-2">
<Button onClick={() => onSave(form)} disabled={isPending} size="sm"> <Button onClick={() => onSave(form)} disabled={isPending} size="sm">
{isPending ? "Saving…" : "Save changes"} {isPending ? "Saving…" : "Save changes"}
@@ -114,6 +123,7 @@ export default function PluginSchemaForm({
<span className="text-sm text-green-600 dark:text-green-400">Saved successfully.</span> <span className="text-sm text-green-600 dark:text-green-400">Saved successfully.</span>
)} )}
</div> </div>
)}
</div> </div>
); );
} }
+82 -84
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
getDocumentLimits, getDocumentLimits,
@@ -8,6 +8,7 @@ import {
getPluginManifest, getPluginManifest,
} from "../api/client"; } from "../api/client";
import PluginSchemaForm from "../components/PluginSchemaForm"; import PluginSchemaForm from "../components/PluginSchemaForm";
import { Button } from "@/components/ui/button";
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
width: 120, width: 120,
@@ -20,39 +21,88 @@ const inputStyle: React.CSSProperties = {
color: "rgb(var(--color-text-primary))", color: "rgb(var(--color-text-primary))",
}; };
function Section({ title, children }: { title: string; children: React.ReactNode }) { function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
return ( return (
<section style={{ marginBottom: 36 }}> <section style={{ marginBottom: 36 }}>
<h2 style={{ fontSize: 18, marginBottom: 16 }}>{title}</h2> <h2 style={{ fontSize: 18, marginBottom: description ? 8 : 16 }}>{title}</h2>
{description && (
<p style={{ fontSize: 13, color: "rgb(var(--color-text-muted))", marginBottom: 16 }}>
{description}
</p>
)}
{children} {children}
</section> </section>
); );
} }
function UploadLimitsSection() { export default function DocServiceSettingsPage() {
const { data: rawSettings, isLoading } = useQuery({ const queryClient = useQueryClient();
// ── Upload limits ────────────────────────────────────────────────────────────
const { data: limitsData, isLoading: limitsLoading } = useQuery({
queryKey: ["docLimits"], queryKey: ["docLimits"],
queryFn: getDocumentLimits, queryFn: getDocumentLimits,
}); });
const [maxPdfMb, setMaxPdfMb] = useState(20); const [maxPdfMb, setMaxPdfMb] = useState(20);
useEffect(() => { useEffect(() => {
if (!rawSettings) return; if (!limitsData) return;
const s = rawSettings as Record<string, unknown>; const s = limitsData as Record<string, unknown>;
const docs = s.documents as Record<string, unknown> | undefined; const docs = s.documents as Record<string, unknown> | undefined;
if (typeof docs?.max_pdf_bytes === "number") { if (typeof docs?.max_pdf_bytes === "number") {
setMaxPdfMb(Math.round((docs.max_pdf_bytes as number) / (1024 * 1024))); setMaxPdfMb(Math.round((docs.max_pdf_bytes as number) / (1024 * 1024)));
} }
}, [rawSettings]); }, [limitsData]);
// ── Watch directory ──────────────────────────────────────────────────────────
const { data: manifest, isLoading: manifestLoading } = useQuery({
queryKey: ["plugin-manifest", "doc-service"],
queryFn: () => getPluginManifest("doc-service"),
retry: false,
});
const { data: watchData, isLoading: watchLoading } = useQuery({
queryKey: ["plugin-settings", "doc-service"],
queryFn: () => getPluginSettings("doc-service"),
retry: false,
});
// Ref so PluginSchemaForm can push its current values to us on change
const watchValuesRef = useRef<Record<string, unknown>>({});
useEffect(() => {
if (watchData) watchValuesRef.current = watchData as Record<string, unknown>;
}, [watchData]);
// ── Mutations ────────────────────────────────────────────────────────────────
const limitsMut = useMutation({ const limitsMut = useMutation({
mutationFn: (mb: number) => updateDocumentLimits(mb), mutationFn: (mb: number) => updateDocumentLimits(mb),
}); });
const watchMut = useMutation({
mutationFn: (values: Record<string, unknown>) => updatePluginSettings("doc-service", values),
onSuccess: (data) => {
queryClient.setQueryData(["plugin-settings", "doc-service"], data);
},
});
if (isLoading) return <div>Loading</div>; const isPending = limitsMut.isPending || watchMut.isPending;
const isError = limitsMut.isError || watchMut.isError;
const isSuccess = limitsMut.isSuccess && watchMut.isSuccess;
const handleSave = () => {
limitsMut.mutate(maxPdfMb);
if (manifest?.settings_schema) {
watchMut.mutate(watchValuesRef.current);
}
};
const isLoading = limitsLoading || manifestLoading || watchLoading;
if (isLoading) {
return <div style={{ padding: 32 }}>Loading</div>;
}
return ( return (
<div style={{ padding: 32, maxWidth: 700, margin: "0 auto" }}>
<h1 style={{ fontSize: 24, marginBottom: 32 }}>Documents Settings</h1>
<Section title="Upload Limits"> <Section title="Upload Limits">
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "rgb(var(--color-text-muted))" }}> <label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "rgb(var(--color-text-muted))" }}>
@@ -67,86 +117,34 @@ function UploadLimitsSection() {
style={inputStyle} style={inputStyle}
/> />
</div> </div>
<button
onClick={() => limitsMut.mutate(maxPdfMb)}
disabled={limitsMut.isPending}
style={{
padding: "8px 16px",
cursor: "pointer",
background: "#222",
color: "#fff",
borderRadius: 4,
border: "none",
marginTop: 8,
}}
>
{limitsMut.isPending ? "Saving…" : "Save"}
</button>
{limitsMut.isSuccess && (
<p style={{ marginTop: 8, fontSize: 13, color: "#2a9d8f" }}>Limits saved.</p>
)}
{limitsMut.isError && (
<p style={{ marginTop: 8, fontSize: 13, color: "#e76f51" }}>Failed to save.</p>
)}
</Section> </Section>
);
}
function WatchDirectorySection() { {manifest?.settings_schema && watchData ? (
const queryClient = useQueryClient(); <Section
title="Watch Directory"
const { data: manifest, isLoading: manifestLoading } = useQuery({ description="Automatically ingest PDF files dropped into the watched directory. Subfolders are mapped to document categories."
queryKey: ["plugin-manifest", "doc-service"], >
queryFn: () => getPluginManifest("doc-service"),
retry: false,
});
const { data: settingsValues, isLoading: settingsLoading } = useQuery({
queryKey: ["plugin-settings", "doc-service"],
queryFn: () => getPluginSettings("doc-service"),
retry: false,
});
const updateMut = useMutation({
mutationFn: (values: Record<string, unknown>) => updatePluginSettings("doc-service", values),
onSuccess: (data) => {
queryClient.setQueryData(["plugin-settings", "doc-service"], data);
},
});
if (manifestLoading || settingsLoading) return <div>Loading</div>;
if (!manifest?.settings_schema || !settingsValues) {
return (
<div style={{ fontSize: 13, color: "rgb(var(--color-text-muted))" }}>
Watch directory settings unavailable. Ensure the doc-service is running.
</div>
);
}
return (
<Section title="Watch Directory">
<p style={{ fontSize: 13, color: "rgb(var(--color-text-muted))", marginBottom: 20 }}>
Automatically ingest PDF files dropped into the watched directory. Subfolders are mapped to document categories.
</p>
<PluginSchemaForm <PluginSchemaForm
schema={manifest.settings_schema} schema={manifest.settings_schema}
values={settingsValues as Record<string, unknown>} values={watchData as Record<string, unknown>}
onSave={(values) => updateMut.mutate(values)} onSave={() => {}}
isPending={updateMut.isPending} onChange={(values) => { watchValuesRef.current = values; }}
isError={updateMut.isError} noSaveButton
isSuccess={updateMut.isSuccess}
/> />
</Section> </Section>
); ) : null}
}
export default function DocServiceSettingsPage() { <div className="flex items-center gap-3 pt-2">
return ( <Button onClick={handleSave} disabled={isPending} size="sm">
<div style={{ padding: 32, maxWidth: 700, margin: "0 auto" }}> {isPending ? "Saving…" : "Save changes"}
<h1 style={{ fontSize: 24, marginBottom: 32 }}>Documents Settings</h1> </Button>
<UploadLimitsSection /> {isError && (
<WatchDirectorySection /> <span className="text-sm text-destructive">Failed to save. Please try again.</span>
)}
{isSuccess && !isPending && (
<span className="text-sm text-green-600 dark:text-green-400">Saved successfully.</span>
)}
</div>
</div> </div>
); );
} }