import { useState, memo, useMemo, useCallback, useEffect } from "react";
import type { JsonValue } from "@/utils/jsonUtils";
import clsx from "clsx";
import { Copy, CheckCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { getDataType, tryParseJson } from "@/utils/jsonUtils";
interface JsonViewProps {
data: unknown;
name?: string;
initialExpandDepth?: number;
className?: string;
withCopyButton?: boolean;
}
const JsonView = memo(
({
data,
name,
initialExpandDepth = 3,
className,
withCopyButton = true,
}: JsonViewProps) => {
const { toast } = useToast();
const [copied, setCopied] = useState(false);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (copied) {
timeoutId = setTimeout(() => {
setCopied(false);
}, 500);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [copied]);
const normalizedData = useMemo(() => {
return typeof data === "string"
? tryParseJson(data).success
? tryParseJson(data).data
: data
: data;
}, [data]);
const handleCopy = useCallback(() => {
try {
navigator.clipboard.writeText(
typeof normalizedData === "string"
? normalizedData
: JSON.stringify(normalizedData, null, 2),
);
setCopied(true);
} catch (error) {
toast({
title: "Error",
description: `There was an error coping result into the clipboard: ${error instanceof Error ? error.message : String(error)}`,
variant: "destructive",
});
}
}, [toast, normalizedData]);
return (
{withCopyButton && (
)}
);
},
);
JsonView.displayName = "JsonView";
interface JsonNodeProps {
data: JsonValue;
name?: string;
depth: number;
initialExpandDepth: number;
}
const JsonNode = memo(
({ data, name, depth = 0, initialExpandDepth }: JsonNodeProps) => {
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
const [typeStyleMap] = useState>({
number: "text-blue-600",
boolean: "text-amber-600",
null: "text-purple-600",
undefined: "text-gray-600",
string: "text-green-600 break-all whitespace-pre-wrap",
default: "text-gray-700",
});
const dataType = getDataType(data);
const renderCollapsible = (isArray: boolean) => {
const items = isArray
? (data as JsonValue[])
: Object.entries(data as Record);
const itemCount = items.length;
const isEmpty = itemCount === 0;
const symbolMap = {
open: isArray ? "[" : "{",
close: isArray ? "]" : "}",
collapsed: isArray ? "[ ... ]" : "{ ... }",
empty: isArray ? "[]" : "{}",
};
if (isEmpty) {
return (
{name && (
{name}:
)}
{symbolMap.empty}
);
}
return (
setIsExpanded(!isExpanded)}
>
{name && (
{name}:
)}
{isExpanded ? (
{symbolMap.open}
) : (
<>
{symbolMap.collapsed}
{itemCount} {itemCount === 1 ? "item" : "items"}
>
)}
{isExpanded && (
<>
{isArray
? (items as JsonValue[]).map((item, index) => (
))
: (items as [string, JsonValue][]).map(([key, value]) => (
))}
{symbolMap.close}
>
)}
);
};
const renderString = (value: string) => {
const maxLength = 100;
const isTooLong = value.length > maxLength;
if (!isTooLong) {
return (
{name && (
{name}:
)}
"{value}"
);
}
return (
{name && (
{name}:
)}
setIsExpanded(!isExpanded)}
title={isExpanded ? "Click to collapse" : "Click to expand"}
>
{isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`}
);
};
switch (dataType) {
case "object":
case "array":
return renderCollapsible(dataType === "array");
case "string":
return renderString(data as string);
default:
return (
{name && (
{name}:
)}
{data === null ? "null" : String(data)}
);
}
},
);
JsonNode.displayName = "JsonNode";
export default JsonView;