Polish memory provider config form

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-20 07:31:52 -05:00
1 parent e654c4efd2
commit 0bdee434b3
2 files changed
+315 -105

No files matched your search

@@ -35,6 +35,7 @@ const provider: MemoryProviderDescriptor = {
label: "Search mode",
description: "How QMD should retrieve matching memory files.",
input: "select",
suggestedValue: "query",
options: [
{ value: "query", label: "Query" },
{ value: "search", label: "Search" },
@@ -45,6 +46,7 @@ const provider: MemoryProviderDescriptor = {
label: "Hydration snippets",
description: "Maximum snippets to include in a run preamble.",
input: "number",
suggestedValue: 5,
min: 1,
max: 25,
},
@@ -53,11 +55,13 @@ const provider: MemoryProviderDescriptor = {
label: "API token",
input: "secret",
required: true,
suggestedValue: "",
},
{
key: "autoIndexOnWrite",
label: "Auto-index on write",
input: "boolean",
suggestedValue: true,
},
],
pathSuggestions: [
@@ -79,6 +83,17 @@ const provider: MemoryProviderDescriptor = {
},
};
/** Lets React detect a DOM value change on controlled textareas (see React #10140). */
function setNativeTextareaValue(textarea: HTMLTextAreaElement, value: string) {
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
const previous = textarea.value;
valueSetter?.call(textarea, value);
const tracker = (textarea as HTMLTextAreaElement & { _valueTracker?: { setValue: (v: string) => void } })
._valueTracker;
tracker?.setValue(previous);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}
describe("MemoryProviderConfigForm", () => {
let container: HTMLDivElement;
@@ -119,9 +134,27 @@ describe("MemoryProviderConfigForm", () => {
expect(container.textContent).toContain("Binding directory");
expect(container.textContent).toContain("/tmp/paperclip/qmd/default");
expect(container.textContent).toContain("QMD binary");
expect(container.textContent).toContain("warning");
expect(container.textContent).toContain("idle");
expect(container.textContent).toContain("Common paths");
expect(container.querySelector("select")).toBeNull();
expect(container.querySelector('input[type="password"]')).not.toBeNull();
const statusIndex = container.textContent?.indexOf("Status") ?? -1;
const fieldIndex = container.textContent?.indexOf("Search mode") ?? -1;
expect(statusIndex).toBeGreaterThanOrEqual(0);
expect(fieldIndex).toBeGreaterThan(statusIndex);
const showSecretButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.getAttribute("aria-label") === "Show API token"
);
expect(showSecretButton).toBeTruthy();
await act(async () => {
showSecretButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.querySelector('input[type="text"]')?.getAttribute("id")).toContain("apiToken");
const advancedButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Advanced JSON")
);
@@ -164,10 +197,115 @@ describe("MemoryProviderConfigForm", () => {
expect(container.textContent).toContain("Hydration snippets must be at most 25.");
expect(container.textContent).toContain("API token is required.");
expect(container.textContent).toContain("Auto-index on write must be on or off.");
expect(container.querySelectorAll('[role="alert"]').length).toBeGreaterThanOrEqual(4);
expect(onValidationChange).toHaveBeenLastCalledWith(false);
await act(async () => {
root.unmount();
});
});
it("resets diverged fields to suggested values from one global action", async () => {
const onChange = vi.fn();
const root = createRoot(container);
await act(async () => {
root.render(
<MemoryProviderConfigForm
provider={provider}
value={{
searchMode: "search",
topK: 10,
apiToken: "custom-token",
autoIndexOnWrite: false,
}}
onChange={onChange}
/>,
);
await Promise.resolve();
});
const resetButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Reset to suggested")
);
expect(resetButton).toBeTruthy();
expect(container.textContent).not.toContain("Use suggested");
await act(async () => {
resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onChange).toHaveBeenLastCalledWith({
searchMode: "query",
topK: 5,
apiToken: "",
autoIndexOnWrite: true,
});
await act(async () => {
root.unmount();
});
});
it("keeps advanced JSON edits local until blur", async () => {
const onChange = vi.fn();
const onValidationChange = vi.fn();
const root = createRoot(container);
await act(async () => {
root.render(
<MemoryProviderConfigForm
provider={provider}
value={{
searchMode: "query",
topK: 5,
apiToken: "secret-token",
autoIndexOnWrite: true,
}}
onChange={onChange}
onValidationChange={onValidationChange}
/>,
);
await Promise.resolve();
});
const advancedButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Advanced JSON")
);
await act(async () => {
advancedButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const textarea = container.querySelector("textarea");
expect(textarea).not.toBeNull();
await act(async () => {
setNativeTextareaValue(textarea!, '{"topK":');
});
expect(onChange).not.toHaveBeenCalled();
expect(container.textContent).not.toContain("Unexpected end");
await act(async () => {
textarea?.focus();
textarea?.blur();
});
expect(onChange).not.toHaveBeenCalled();
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Unexpected end");
expect(onValidationChange).toHaveBeenLastCalledWith(false);
await act(async () => {
setNativeTextareaValue(textarea!, '{"topK": 7}');
textarea?.focus();
textarea?.blur();
});
expect(onChange).toHaveBeenLastCalledWith({ topK: 7 });
await act(async () => {
root.unmount();
});
});
});
+176 -104
View File
@@ -1,14 +1,19 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useId, useMemo, useState } from "react";
import type { MemoryProviderConfigFieldMetadata, MemoryProviderDescriptor } from "@paperclipai/shared";
import { ChevronDown, Database, FileSearch } from "lucide-react";
import { ChevronDown, Database, Eye, EyeOff, FileSearch, RotateCcw } from "lucide-react";
import {
getMemoryConfigFields,
parseMemoryConfigJson,
prettyMemoryConfig,
validateMemoryProviderConfig,
} from "../lib/memory-config-schema";
import { StatusBadge } from "./StatusBadge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { cn } from "../lib/utils";
@@ -29,6 +34,16 @@ function coerceSelectValue(field: MemoryProviderConfigFieldMetadata, value: stri
return option ? option.value : value;
}
function healthStatus(status: string) {
if (status === "ok") return "active";
if (status === "error") return "failed";
return "idle";
}
function fieldDomId(prefix: string, key: string, suffix?: string) {
return `${prefix}-${key.replace(/[^a-zA-Z0-9_-]/g, "-")}${suffix ? `-${suffix}` : ""}`;
}
function fieldGridClass(fields: MemoryProviderConfigFieldMetadata[]) {
return fields.some((field) => field.input === "boolean")
? "grid gap-3 lg:grid-cols-2"
@@ -48,9 +63,16 @@ export function MemoryProviderConfigForm({
}) {
const fields = useMemo(() => getMemoryConfigFields(provider), [provider]);
const validation = useMemo(() => validateMemoryProviderConfig(provider, value), [provider, value]);
const fieldIdPrefix = useId();
const [advancedOpen, setAdvancedOpen] = useState(false);
const [jsonText, setJsonText] = useState(() => prettyMemoryConfig(value));
const [jsonError, setJsonError] = useState<string | null>(null);
const [visibleSecrets, setVisibleSecrets] = useState<Record<string, boolean>>({});
const hasSuggestedDivergence = useMemo(
() => fields.some((field) => field.suggestedValue !== undefined && !Object.is(value[field.key], field.suggestedValue)),
[fields, value],
);
useEffect(() => {
setJsonText(prettyMemoryConfig(value));
@@ -65,35 +87,52 @@ export function MemoryProviderConfigForm({
onChange({ ...value, [key]: nextValue });
}
function resetSuggestedFields() {
const nextValue = { ...value };
for (const field of fields) {
if (field.suggestedValue !== undefined) {
nextValue[field.key] = field.suggestedValue;
}
}
onChange(nextValue);
}
function applyJsonText() {
try {
onChange(parseMemoryConfigJson(jsonText));
setJsonError(null);
} catch (error) {
setJsonError(error instanceof Error ? error.message : "Config must be valid JSON");
}
}
function renderField(field: MemoryProviderConfigFieldMetadata) {
const error = validation.fieldErrors[field.key];
const fieldId = fieldDomId(fieldIdPrefix, field.key);
const descriptionId = field.description ? fieldDomId(fieldIdPrefix, field.key, "description") : undefined;
const errorId = error ? fieldDomId(fieldIdPrefix, field.key, "error") : undefined;
const describedBy = [descriptionId, errorId].filter(Boolean).join(" ") || undefined;
const common = (
<>
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-medium text-muted-foreground">{field.label}</label>
{field.suggestedValue !== undefined && value[field.key] !== field.suggestedValue ? (
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 px-2 text-[11px]"
onClick={() => updateField(field.key, field.suggestedValue)}
>
Use suggested
</Button>
) : null}
</div>
{field.description ? <p className="text-xs text-muted-foreground">{field.description}</p> : null}
{error ? <p className="text-xs text-destructive">{error}</p> : null}
<Label htmlFor={fieldId} className="text-xs text-muted-foreground">{field.label}</Label>
{field.description ? (
<p id={descriptionId} className="text-xs text-muted-foreground">{field.description}</p>
) : null}
{error ? (
<p id={errorId} role="alert" className="text-xs text-destructive">{error}</p>
) : null}
</>
);
if (field.input === "boolean") {
return (
<div key={field.key} className="rounded-md border border-border px-3 py-3">
<div key={field.key} className="rounded-md border border-border bg-card/40 px-3 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">{common}</div>
<ToggleSwitch
id={fieldId}
aria-describedby={describedBy}
aria-invalid={Boolean(error)}
checked={Boolean(value[field.key])}
onCheckedChange={(checked) => updateField(field.key, checked)}
/>
@@ -106,45 +145,104 @@ export function MemoryProviderConfigForm({
return (
<div key={field.key} className="space-y-1">
{common}
<select
<Select
value={fieldValue(value[field.key])}
onChange={(event) => updateField(field.key, coerceSelectValue(field, event.target.value))}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none"
onValueChange={(nextValue) => updateField(field.key, coerceSelectValue(field, nextValue))}
>
{(field.options ?? []).map((option) => (
<option key={String(option.value)} value={fieldValue(option.value)}>
{option.label}
</option>
))}
</select>
<SelectTrigger
id={fieldId}
aria-describedby={describedBy}
aria-invalid={Boolean(error)}
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(field.options ?? []).map((option) => (
<SelectItem key={String(option.value)} value={fieldValue(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
const isSecret = field.input === "secret";
const secretVisible = Boolean(visibleSecrets[field.key]);
return (
<div key={field.key} className="space-y-1">
{common}
<Input
type={field.input === "number" ? "number" : field.input === "secret" ? "password" : "text"}
min={field.min ?? undefined}
max={field.max ?? undefined}
value={fieldValue(value[field.key])}
placeholder={field.placeholder ?? undefined}
onChange={(event) => {
const raw = event.target.value;
if (field.input === "number") {
updateField(field.key, coerceNumber(raw));
} else {
updateField(field.key, raw.trim() === "" && field.defaultValue === null ? null : raw);
}
}}
/>
<div className="relative">
<Input
id={fieldId}
aria-describedby={describedBy}
aria-invalid={Boolean(error)}
type={field.input === "number" ? "number" : isSecret && !secretVisible ? "password" : "text"}
min={field.min ?? undefined}
max={field.max ?? undefined}
value={fieldValue(value[field.key])}
placeholder={field.placeholder ?? undefined}
className={cn(isSecret && "pr-10")}
onChange={(event) => {
const raw = event.target.value;
if (field.input === "number") {
updateField(field.key, coerceNumber(raw));
} else {
updateField(field.key, raw.trim() === "" && field.defaultValue === null ? null : raw);
}
}}
/>
{isSecret ? (
<Button
type="button"
size="icon-xs"
variant="ghost"
aria-label={secretVisible ? `Hide ${field.label}` : `Show ${field.label}`}
className="absolute right-2 top-1/2 -translate-y-1/2"
onClick={() => setVisibleSecrets((current) => ({ ...current, [field.key]: !secretVisible }))}
>
{secretVisible ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
</Button>
) : null}
</div>
</div>
);
}
return (
<div className="space-y-3">
{provider?.configMetadata?.healthChecks?.length ? (
<section className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<Database className="h-3.5 w-3.5" />
Status
</div>
<div className="grid gap-2 md:grid-cols-2">
{provider.configMetadata.healthChecks.map((check) => (
<div key={check.key} className="rounded-md border border-border bg-card px-3 py-3 text-xs">
<div className="flex items-center justify-between gap-2 font-medium">
<span>{check.label}</span>
<StatusBadge status={healthStatus(check.status)} />
</div>
{check.message ? <div className="mt-1 text-muted-foreground">{check.message}</div> : null}
</div>
))}
</div>
</section>
) : null}
{hasSuggestedDivergence ? (
<div className="flex justify-end">
<Button type="button" size="sm" variant="ghost" onClick={resetSuggestedFields}>
<RotateCcw className="size-3.5" />
Reset to suggested
</Button>
</div>
) : null}
{fields.length > 0 ? (
<div className={fieldGridClass(fields)}>{fields.map((field) => renderField(field))}</div>
) : (
@@ -154,74 +252,48 @@ export function MemoryProviderConfigForm({
)}
{provider?.configMetadata?.pathSuggestions?.length ? (
<div className="grid gap-2 md:grid-cols-2">
{provider.configMetadata.pathSuggestions.map((suggestion) => (
<div key={suggestion.key} className="rounded-md border border-border px-3 py-3 text-xs">
<div className="flex items-center gap-2 font-medium">
<FileSearch className="h-3.5 w-3.5 text-muted-foreground" />
{suggestion.label}
<section className="space-y-2">
<Separator />
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<FileSearch className="h-3.5 w-3.5" />
Common paths
</div>
<div className="grid gap-2 md:grid-cols-2">
{provider.configMetadata.pathSuggestions.map((suggestion) => (
<div key={suggestion.key} className="rounded-md border border-dashed border-border px-3 py-2 text-xs">
<div className="font-medium">{suggestion.label}</div>
<div className="mt-1 break-all font-mono text-muted-foreground">{suggestion.path}</div>
{suggestion.description ? <div className="mt-1 text-muted-foreground">{suggestion.description}</div> : null}
</div>
<div className="mt-1 font-mono text-muted-foreground">{suggestion.path}</div>
{suggestion.description ? <div className="mt-1 text-muted-foreground">{suggestion.description}</div> : null}
</div>
))}
</div>
))}
</div>
</section>
) : null}
{provider?.configMetadata?.healthChecks?.length ? (
<div className="grid gap-2 md:grid-cols-2">
{provider.configMetadata.healthChecks.map((check) => (
<div key={check.key} className="rounded-md border border-border px-3 py-3 text-xs">
<div className="flex items-center gap-2 font-medium">
<Database className="h-3.5 w-3.5 text-muted-foreground" />
{check.label}
<span className={cn(
"rounded-full px-2 py-0.5 text-[10px] uppercase",
check.status === "ok"
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: check.status === "error"
? "bg-destructive/10 text-destructive"
: "bg-accent text-muted-foreground",
)}>
{check.status}
</span>
</div>
{check.message ? <div className="mt-1 text-muted-foreground">{check.message}</div> : null}
</div>
))}
</div>
) : null}
<div className="rounded-md border border-border">
<button
type="button"
<Collapsible
open={advancedOpen}
onOpenChange={setAdvancedOpen}
className="rounded-md border border-border"
>
<CollapsibleTrigger
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-accent/30"
onClick={() => setAdvancedOpen((open) => !open)}
>
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform", !advancedOpen && "-rotate-90")} />
Advanced JSON
</button>
{advancedOpen ? (
<div className="space-y-2 border-t border-border px-3 py-3">
<Textarea
value={jsonText}
onChange={(event) => {
const nextText = event.target.value;
setJsonText(nextText);
try {
onChange(parseMemoryConfigJson(nextText));
setJsonError(null);
} catch (error) {
setJsonError(error instanceof Error ? error.message : "Config must be valid JSON");
}
}}
className="min-h-40 font-mono text-xs"
/>
{jsonError ? <p className="text-xs text-destructive">{jsonError}</p> : null}
</div>
) : null}
</div>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 border-t border-border px-3 py-3">
<Textarea
value={jsonText}
onChange={(event) => {
setJsonText(event.target.value);
if (jsonError) setJsonError(null);
}}
onBlur={applyJsonText}
className="min-h-40 font-mono text-xs"
/>
{jsonError ? <p role="alert" className="text-xs text-destructive">{jsonError}</p> : null}
</CollapsibleContent>
</Collapsible>
</div>
);
}