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:
@@ -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,17 +111,19 @@ export default function PluginSchemaForm({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
{!noSaveButton && (
|
||||||
<Button onClick={() => onSave(form)} disabled={isPending} size="sm">
|
<div className="flex items-center gap-3 pt-2">
|
||||||
{isPending ? "Saving…" : "Save changes"}
|
<Button onClick={() => onSave(form)} disabled={isPending} size="sm">
|
||||||
</Button>
|
{isPending ? "Saving…" : "Save changes"}
|
||||||
{isError && (
|
</Button>
|
||||||
<span className="text-sm text-destructive">Failed to save. Please try again.</span>
|
{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>
|
{isSuccess && !isPending && (
|
||||||
)}
|
<span className="text-sm text-green-600 dark:text-green-400">Saved successfully.</span>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,133 +21,130 @@ 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]);
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
|
// ── Watch directory ──────────────────────────────────────────────────────────
|
||||||
const { data: manifest, isLoading: manifestLoading } = useQuery({
|
const { data: manifest, isLoading: manifestLoading } = useQuery({
|
||||||
queryKey: ["plugin-manifest", "doc-service"],
|
queryKey: ["plugin-manifest", "doc-service"],
|
||||||
queryFn: () => getPluginManifest("doc-service"),
|
queryFn: () => getPluginManifest("doc-service"),
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
const { data: watchData, isLoading: watchLoading } = useQuery({
|
||||||
const { data: settingsValues, isLoading: settingsLoading } = useQuery({
|
|
||||||
queryKey: ["plugin-settings", "doc-service"],
|
queryKey: ["plugin-settings", "doc-service"],
|
||||||
queryFn: () => getPluginSettings("doc-service"),
|
queryFn: () => getPluginSettings("doc-service"),
|
||||||
retry: false,
|
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),
|
mutationFn: (values: Record<string, unknown>) => updatePluginSettings("doc-service", values),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.setQueryData(["plugin-settings", "doc-service"], 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) {
|
const handleSave = () => {
|
||||||
return (
|
limitsMut.mutate(maxPdfMb);
|
||||||
<div style={{ fontSize: 13, color: "rgb(var(--color-text-muted))" }}>
|
if (manifest?.settings_schema) {
|
||||||
Watch directory settings unavailable. Ensure the doc-service is running.
|
watchMut.mutate(watchValuesRef.current);
|
||||||
</div>
|
}
|
||||||
);
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div style={{ padding: 32, maxWidth: 700, margin: "0 auto" }}>
|
<div style={{ padding: 32, maxWidth: 700, margin: "0 auto" }}>
|
||||||
<h1 style={{ fontSize: 24, marginBottom: 32 }}>Documents — Settings</h1>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user