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
+22 -12
View File
@@ -17,6 +17,10 @@ interface PluginSchemaFormProps {
isPending?: boolean;
isError?: 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 }) {
@@ -48,6 +52,8 @@ export default function PluginSchemaForm({
isPending,
isError,
isSuccess,
noSaveButton,
onChange,
}: PluginSchemaFormProps) {
const [form, setForm] = useState<Record<string, unknown>>(values);
@@ -56,7 +62,9 @@ export default function PluginSchemaForm({
}, [values]);
const setField = (key: string, value: unknown) => {
setForm((prev) => ({ ...prev, [key]: value }));
const next = { ...form, [key]: value };
setForm(next);
onChange?.(next);
};
return (
@@ -103,17 +111,19 @@ export default function PluginSchemaForm({
</div>
))}
<div className="flex items-center gap-3 pt-2">
<Button onClick={() => onSave(form)} disabled={isPending} size="sm">
{isPending ? "Saving…" : "Save changes"}
</Button>
{isError && (
<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>
{!noSaveButton && (
<div className="flex items-center gap-3 pt-2">
<Button onClick={() => onSave(form)} disabled={isPending} size="sm">
{isPending ? "Saving…" : "Save changes"}
</Button>
{isError && (
<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>
);
}
+87 -89
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 {
getDocumentLimits,
@@ -8,6 +8,7 @@ import {
getPluginManifest,
} from "../api/client";
import PluginSchemaForm from "../components/PluginSchemaForm";
import { Button } from "@/components/ui/button";
const inputStyle: React.CSSProperties = {
width: 120,
@@ -20,133 +21,130 @@ const inputStyle: React.CSSProperties = {
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 (
<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}
</section>
);
}
function UploadLimitsSection() {
const { data: rawSettings, isLoading } = useQuery({
export default function DocServiceSettingsPage() {
const queryClient = useQueryClient();
// ── Upload limits ────────────────────────────────────────────────────────────
const { data: limitsData, isLoading: limitsLoading } = useQuery({
queryKey: ["docLimits"],
queryFn: getDocumentLimits,
});
const [maxPdfMb, setMaxPdfMb] = useState(20);
useEffect(() => {
if (!rawSettings) return;
const s = rawSettings as Record<string, unknown>;
if (!limitsData) return;
const s = limitsData as Record<string, unknown>;
const docs = s.documents as Record<string, unknown> | undefined;
if (typeof docs?.max_pdf_bytes === "number") {
setMaxPdfMb(Math.round((docs.max_pdf_bytes as number) / (1024 * 1024)));
}
}, [rawSettings]);
const limitsMut = useMutation({
mutationFn: (mb: number) => updateDocumentLimits(mb),
});
if (isLoading) return <div>Loading</div>;
return (
<Section title="Upload Limits">
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "rgb(var(--color-text-muted))" }}>
Max file size (MB)
</label>
<input
type="number"
min={1}
max={200}
value={maxPdfMb}
onChange={(e) => setMaxPdfMb(Number(e.target.value))}
style={inputStyle}
/>
</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>
);
}
function WatchDirectorySection() {
const queryClient = useQueryClient();
}, [limitsData]);
// ── Watch directory ──────────────────────────────────────────────────────────
const { data: manifest, isLoading: manifestLoading } = useQuery({
queryKey: ["plugin-manifest", "doc-service"],
queryFn: () => getPluginManifest("doc-service"),
retry: false,
});
const { data: settingsValues, isLoading: settingsLoading } = useQuery({
const { data: watchData, isLoading: watchLoading } = useQuery({
queryKey: ["plugin-settings", "doc-service"],
queryFn: () => getPluginSettings("doc-service"),
retry: false,
});
const updateMut = useMutation({
// 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({
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 (manifestLoading || settingsLoading) return <div>Loading</div>;
const isPending = limitsMut.isPending || watchMut.isPending;
const isError = limitsMut.isError || watchMut.isError;
const isSuccess = limitsMut.isSuccess && watchMut.isSuccess;
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>
);
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 (
<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
schema={manifest.settings_schema}
values={settingsValues as Record<string, unknown>}
onSave={(values) => updateMut.mutate(values)}
isPending={updateMut.isPending}
isError={updateMut.isError}
isSuccess={updateMut.isSuccess}
/>
</Section>
);
}
export default function DocServiceSettingsPage() {
return (
<div style={{ padding: 32, maxWidth: 700, margin: "0 auto" }}>
<h1 style={{ fontSize: 24, marginBottom: 32 }}>Documents Settings</h1>
<UploadLimitsSection />
<WatchDirectorySection />
<Section title="Upload Limits">
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "rgb(var(--color-text-muted))" }}>
Max file size (MB)
</label>
<input
type="number"
min={1}
max={200}
value={maxPdfMb}
onChange={(e) => setMaxPdfMb(Number(e.target.value))}
style={inputStyle}
/>
</div>
</Section>
{manifest?.settings_schema && watchData ? (
<Section
title="Watch Directory"
description="Automatically ingest PDF files dropped into the watched directory. Subfolders are mapped to document categories."
>
<PluginSchemaForm
schema={manifest.settings_schema}
values={watchData as Record<string, unknown>}
onSave={() => {}}
onChange={(values) => { watchValuesRef.current = values; }}
noSaveButton
/>
</Section>
) : null}
<div className="flex items-center gap-3 pt-2">
<Button onClick={handleSave} disabled={isPending} size="sm">
{isPending ? "Saving…" : "Save changes"}
</Button>
{isError && (
<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>
);
}