mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
Polish memory provider config form
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user