feat(web): unified json viewer/editor + edit existing automation

This commit is contained in:
CREDO23 2026-05-28 16:07:54 +02:00
parent 2d8d42bd9c
commit fa0cdb9760
15 changed files with 504 additions and 119 deletions

View file

@ -1,6 +1,6 @@
import { FileJson } from "lucide-react";
import React from "react";
import { defaultStyles, JsonView } from "react-json-view-lite";
import { JsonView } from "@/components/json-view";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -10,7 +10,6 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import "react-json-view-lite/dist/index.css";
interface JsonMetadataViewerProps {
title: string;
@ -56,13 +55,13 @@ export function JsonMetadataViewer({
{title} - Metadata
</DialogTitle>
</DialogHeader>
<div className="mt-2 sm:mt-4 p-2 sm:p-4 bg-muted/30 rounded-md text-xs sm:text-sm">
<div className="mt-2 sm:mt-4 p-2 sm:p-4 bg-muted/30 rounded-md text-xs sm:text-sm overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-12">
<Spinner size="lg" className="text-muted-foreground" />
</div>
) : (
<JsonView data={jsonData} style={defaultStyles} />
<JsonView src={jsonData} collapsed={2} />
)}
</div>
</DialogContent>
@ -87,8 +86,8 @@ export function JsonMetadataViewer({
{title} - Metadata
</DialogTitle>
</DialogHeader>
<div className="mt-2 sm:mt-4 p-2 sm:p-4 bg-muted/30 rounded-md text-xs sm:text-sm">
<JsonView data={jsonData} style={defaultStyles} />
<div className="mt-2 sm:mt-4 p-2 sm:p-4 bg-muted/30 rounded-md text-xs sm:text-sm overflow-auto">
<JsonView src={jsonData} collapsed={2} />
</div>
</DialogContent>
</Dialog>

View file

@ -0,0 +1,93 @@
"use client";
import ReactJson, { type InteractionProps } from "@microlink/react-json-view";
import { useTheme } from "next-themes";
import { useCallback, useMemo } from "react";
/**
* Shared JSON viewer/editor wrapper around @microlink/react-json-view.
*
* One component, dual mode: passing ``editable`` + ``onChange`` enables
* inline value editing, key renaming, add and delete. Omitting them
* yields a read-only viewer. The underlying library is uncontrolled it
* mutates its own internal copy of ``src`` and surfaces the final tree on
* each interaction via ``updated_src``, which we forward to ``onChange``.
*
* Theme follows ``next-themes``: a dark base-16 palette in dark mode, the
* library's neutral default in light mode. Defaults are tuned for our
* compact UI surfaces (no data-type labels, no key quotes, triangle icons,
* tight indent).
*/
export interface JsonViewProps {
/** The JSON value to display. Primitives are wrapped under ``{ value }``
* because the underlying library requires an object root. */
src: unknown;
/** Enables value/key editing + add + delete. Requires ``onChange`` to
* observe the result; without it the toggle is silently a no-op. */
editable?: boolean;
/** Called with the full updated tree on every accepted interaction. */
onChange?: (next: unknown) => void;
/** Collapse depth. ``true`` collapses everything past the root; a number
* collapses from that depth onward. */
collapsed?: boolean | number;
/** Root label. Default ``false`` (no label — saves vertical space). */
name?: string | false;
className?: string;
}
const DARK_THEME = "monokai" as const;
const LIGHT_THEME = "rjv-default" as const;
const SHARED_DEFAULTS = {
iconStyle: "triangle" as const,
indentWidth: 2,
enableClipboard: true,
displayDataTypes: false,
displayObjectSize: true,
quotesOnKeys: false,
collapseStringsAfterLength: 80,
};
export function JsonView({
src,
editable = false,
onChange,
collapsed = 2,
name = false,
className,
}: JsonViewProps) {
const { resolvedTheme } = useTheme();
const theme = resolvedTheme === "dark" ? DARK_THEME : LIGHT_THEME;
// The library throws on non-object roots. Wrap primitives and null/undefined.
const safeSrc = useMemo(() => {
if (src && typeof src === "object") return src as object;
return { value: src };
}, [src]);
const handleChange = useCallback(
(interaction: InteractionProps) => {
onChange?.(interaction.updated_src);
return true;
},
[onChange]
);
const interactive = editable && onChange ? handleChange : (false as const);
return (
<div className={className}>
<ReactJson
src={safeSrc}
name={name}
theme={theme}
collapsed={collapsed}
onEdit={interactive}
onAdd={interactive}
onDelete={interactive}
style={{ backgroundColor: "transparent", fontSize: 12, fontFamily: "var(--font-mono)" }}
{...SHARED_DEFAULTS}
/>
</div>
);
}

View file

@ -2,17 +2,11 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useAtomValue } from "jotai";
import {
AlertCircle,
Code,
CornerDownLeftIcon,
ExternalLink,
Pencil,
Workflow,
} from "lucide-react";
import { AlertCircle, CornerDownLeftIcon, ExternalLink, Pencil, Workflow } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { JsonView } from "@/components/json-view";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { automationCreateRequest } from "@/contracts/types/automation.types";
@ -231,28 +225,12 @@ interface JsonEditorProps {
}
function JsonEditor({ initialValue, onSave, onCancel }: JsonEditorProps) {
const [text, setText] = useState(() => JSON.stringify(initialValue, null, 2));
const [value, setValue] = useState<Record<string, unknown>>(initialValue);
const [issues, setIssues] = useState<string[]>([]);
function handleFormat() {
try {
setText(JSON.stringify(JSON.parse(text), null, 2));
setIssues([]);
} catch (err) {
setIssues([`Cannot format — not valid JSON: ${(err as Error).message}`]);
}
}
function handleSave() {
setIssues([]);
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (err) {
setIssues([`Invalid JSON: ${(err as Error).message}`]);
return;
}
const result = editArgsSchema.safeParse(parsed);
const result = editArgsSchema.safeParse(value);
if (!result.success) {
setIssues(
result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
@ -264,14 +242,14 @@ function JsonEditor({ initialValue, onSave, onCancel }: JsonEditorProps) {
return (
<div className="space-y-3">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
spellCheck={false}
rows={16}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-mono text-foreground shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y min-h-[12rem]"
aria-label="Automation JSON"
/>
<div className="rounded-md border border-input bg-background px-3 py-2 max-h-[24rem] overflow-auto">
<JsonView
src={value}
editable
onChange={(next) => setValue(next as Record<string, unknown>)}
collapsed={false}
/>
</div>
{issues.length > 0 && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-destructive">
@ -291,10 +269,6 @@ function JsonEditor({ initialValue, onSave, onCancel }: JsonEditorProps) {
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
Cancel
</Button>
<Button type="button" variant="outline" size="sm" onClick={handleFormat}>
<Code className="mr-1.5 h-3.5 w-3.5" />
Format
</Button>
<Button type="button" size="sm" onClick={handleSave}>
Save edits
</Button>