Skip to content

Commit cf3c0ec

Browse files
committed
added copy functionality to JsonView component
1 parent f94f2d8 commit cf3c0ec

File tree

1 file changed

+99
-3
lines changed

1 file changed

+99
-3
lines changed

client/src/components/JsonView.tsx

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useState, memo, useMemo, useCallback, useEffect } from "react";
2+
import type React from "react";
23
import type { JsonValue } from "@/utils/jsonUtils";
34
import clsx from "clsx";
45
import { Copy, CheckCheck } from "lucide-react";
@@ -114,6 +115,7 @@ const JsonNode = memo(
114115
initialExpandDepth,
115116
isError = false,
116117
}: JsonNodeProps) => {
118+
const { toast } = useToast();
117119
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
118120
const [typeStyleMap] = useState<Record<string, string>>({
119121
number: "text-blue-600",
@@ -126,6 +128,52 @@ const JsonNode = memo(
126128
});
127129
const dataType = getDataType(data);
128130

131+
const [copied, setCopied] = useState(false);
132+
useEffect(() => {
133+
let timeoutId: NodeJS.Timeout;
134+
if (copied) {
135+
timeoutId = setTimeout(() => setCopied(false), 500);
136+
}
137+
return () => {
138+
if (timeoutId) clearTimeout(timeoutId);
139+
};
140+
}, [copied]);
141+
142+
const handleCopyValue = useCallback(
143+
(value: JsonValue) => {
144+
try {
145+
let text: string;
146+
const valueType = getDataType(value);
147+
switch (valueType) {
148+
case "string":
149+
text = value as unknown as string;
150+
break;
151+
case "number":
152+
case "boolean":
153+
text = String(value);
154+
break;
155+
case "null":
156+
text = "null";
157+
break;
158+
case "undefined":
159+
text = "undefined";
160+
break;
161+
default:
162+
text = JSON.stringify(value);
163+
}
164+
navigator.clipboard.writeText(text);
165+
setCopied(true);
166+
} catch (error) {
167+
toast({
168+
title: "Error",
169+
description: `There was an error coping result into the clipboard: ${error instanceof Error ? error.message : String(error)}`,
170+
variant: "destructive",
171+
});
172+
}
173+
},
174+
[toast],
175+
);
176+
129177
const renderCollapsible = (isArray: boolean) => {
130178
const items = isArray
131179
? (data as JsonValue[])
@@ -219,7 +267,7 @@ const JsonNode = memo(
219267

220268
if (!isTooLong) {
221269
return (
222-
<div className="flex mr-1 rounded hover:bg-gray-800/20">
270+
<div className="flex mr-1 rounded hover:bg-gray-800/20 group items-start">
223271
{name && (
224272
<span className="mr-1 text-gray-600 dark:text-gray-400">
225273
{name}:
@@ -233,12 +281,28 @@ const JsonNode = memo(
233281
>
234282
"{value}"
235283
</pre>
284+
<Button
285+
variant="ghost"
286+
className="ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
287+
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
288+
e.stopPropagation();
289+
handleCopyValue(value as unknown as JsonValue);
290+
}}
291+
aria-label={name ? `Copy value of ${name}` : "Copy value"}
292+
title={name ? `Copy value of ${name}` : "Copy value"}
293+
>
294+
{copied ? (
295+
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
296+
) : (
297+
<Copy className="size-4 text-foreground" />
298+
)}
299+
</Button>
236300
</div>
237301
);
238302
}
239303

240304
return (
241-
<div className="flex mr-1 rounded group hover:bg-gray-800/20">
305+
<div className="flex mr-1 rounded group hover:bg-gray-800/20 items-start">
242306
{name && (
243307
<span className="mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
244308
{name}:
@@ -254,6 +318,22 @@ const JsonNode = memo(
254318
>
255319
{isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`}
256320
</pre>
321+
<Button
322+
variant="ghost"
323+
className="ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
324+
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
325+
e.stopPropagation();
326+
handleCopyValue(value as unknown as JsonValue);
327+
}}
328+
aria-label={name ? `Copy value of ${name}` : "Copy value"}
329+
title={name ? `Copy value of ${name}` : "Copy value"}
330+
>
331+
{copied ? (
332+
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
333+
) : (
334+
<Copy className="size-4 text-foreground" />
335+
)}
336+
</Button>
257337
</div>
258338
);
259339
};
@@ -266,7 +346,7 @@ const JsonNode = memo(
266346
return renderString(data as string);
267347
default:
268348
return (
269-
<div className="flex items-center mr-1 rounded hover:bg-gray-800/20">
349+
<div className="flex items-center mr-1 rounded hover:bg-gray-800/20 group">
270350
{name && (
271351
<span className="mr-1 text-gray-600 dark:text-gray-400">
272352
{name}:
@@ -275,6 +355,22 @@ const JsonNode = memo(
275355
<span className={typeStyleMap[dataType] || typeStyleMap.default}>
276356
{data === null ? "null" : String(data)}
277357
</span>
358+
<Button
359+
variant="ghost"
360+
className="ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
361+
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
362+
e.stopPropagation();
363+
handleCopyValue(data as JsonValue);
364+
}}
365+
aria-label={name ? `Copy value of ${name}` : "Copy value"}
366+
title={name ? `Copy value of ${name}` : "Copy value"}
367+
>
368+
{copied ? (
369+
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
370+
) : (
371+
<Copy className="size-4 text-foreground" />
372+
)}
373+
</Button>
278374
</div>
279375
);
280376
}

0 commit comments

Comments
 (0)