Skip to content

Commit fc86dad

Browse files
authored
Merge pull request #761 from ln-12/json_view_copy_button
Add copy functionality to JsonView component
2 parents 321100b + fde9221 commit fc86dad

File tree

1 file changed

+100
-4
lines changed

1 file changed

+100
-4
lines changed

client/src/components/JsonView.tsx

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useState, memo, useMemo, useCallback } from "react";
1+
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";
@@ -101,6 +102,7 @@ const JsonNode = memo(
101102
initialExpandDepth,
102103
isError = false,
103104
}: JsonNodeProps) => {
105+
const { toast } = useToast();
104106
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
105107
const [typeStyleMap] = useState<Record<string, string>>({
106108
number: "text-blue-600",
@@ -113,6 +115,52 @@ const JsonNode = memo(
113115
});
114116
const dataType = getDataType(data);
115117

118+
const [copied, setCopied] = useState(false);
119+
useEffect(() => {
120+
let timeoutId: NodeJS.Timeout;
121+
if (copied) {
122+
timeoutId = setTimeout(() => setCopied(false), 500);
123+
}
124+
return () => {
125+
if (timeoutId) clearTimeout(timeoutId);
126+
};
127+
}, [copied]);
128+
129+
const handleCopyValue = useCallback(
130+
(value: JsonValue) => {
131+
try {
132+
let text: string;
133+
const valueType = getDataType(value);
134+
switch (valueType) {
135+
case "string":
136+
text = value as unknown as string;
137+
break;
138+
case "number":
139+
case "boolean":
140+
text = String(value);
141+
break;
142+
case "null":
143+
text = "null";
144+
break;
145+
case "undefined":
146+
text = "undefined";
147+
break;
148+
default:
149+
text = JSON.stringify(value);
150+
}
151+
navigator.clipboard.writeText(text);
152+
setCopied(true);
153+
} catch (error) {
154+
toast({
155+
title: "Error",
156+
description: `There was an error coping result into the clipboard: ${error instanceof Error ? error.message : String(error)}`,
157+
variant: "destructive",
158+
});
159+
}
160+
},
161+
[toast],
162+
);
163+
116164
const renderCollapsible = (isArray: boolean) => {
117165
const items = isArray
118166
? (data as JsonValue[])
@@ -206,7 +254,7 @@ const JsonNode = memo(
206254

207255
if (!isTooLong) {
208256
return (
209-
<div className="flex mr-1 rounded hover:bg-gray-800/20">
257+
<div className="flex mr-1 rounded hover:bg-gray-800/20 group items-start">
210258
{name && (
211259
<span className="mr-1 text-gray-600 dark:text-gray-400">
212260
{name}:
@@ -220,12 +268,28 @@ const JsonNode = memo(
220268
>
221269
"{value}"
222270
</pre>
271+
<Button
272+
variant="ghost"
273+
className="ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
274+
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
275+
e.stopPropagation();
276+
handleCopyValue(value as unknown as JsonValue);
277+
}}
278+
aria-label={name ? `Copy value of ${name}` : "Copy value"}
279+
title={name ? `Copy value of ${name}` : "Copy value"}
280+
>
281+
{copied ? (
282+
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
283+
) : (
284+
<Copy className="size-4 text-foreground" />
285+
)}
286+
</Button>
223287
</div>
224288
);
225289
}
226290

227291
return (
228-
<div className="flex mr-1 rounded group hover:bg-gray-800/20">
292+
<div className="flex mr-1 rounded group hover:bg-gray-800/20 items-start">
229293
{name && (
230294
<span className="mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
231295
{name}:
@@ -241,6 +305,22 @@ const JsonNode = memo(
241305
>
242306
{isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`}
243307
</pre>
308+
<Button
309+
variant="ghost"
310+
className="ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
311+
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
312+
e.stopPropagation();
313+
handleCopyValue(value as unknown as JsonValue);
314+
}}
315+
aria-label={name ? `Copy value of ${name}` : "Copy value"}
316+
title={name ? `Copy value of ${name}` : "Copy value"}
317+
>
318+
{copied ? (
319+
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
320+
) : (
321+
<Copy className="size-4 text-foreground" />
322+
)}
323+
</Button>
244324
</div>
245325
);
246326
};
@@ -253,7 +333,7 @@ const JsonNode = memo(
253333
return renderString(data as string);
254334
default:
255335
return (
256-
<div className="flex items-center mr-1 rounded hover:bg-gray-800/20">
336+
<div className="flex items-center mr-1 rounded hover:bg-gray-800/20 group">
257337
{name && (
258338
<span className="mr-1 text-gray-600 dark:text-gray-400">
259339
{name}:
@@ -262,6 +342,22 @@ const JsonNode = memo(
262342
<span className={typeStyleMap[dataType] || typeStyleMap.default}>
263343
{data === null ? "null" : String(data)}
264344
</span>
345+
<Button
346+
variant="ghost"
347+
className="ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
348+
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
349+
e.stopPropagation();
350+
handleCopyValue(data as JsonValue);
351+
}}
352+
aria-label={name ? `Copy value of ${name}` : "Copy value"}
353+
title={name ? `Copy value of ${name}` : "Copy value"}
354+
>
355+
{copied ? (
356+
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
357+
) : (
358+
<Copy className="size-4 text-foreground" />
359+
)}
360+
</Button>
265361
</div>
266362
);
267363
}

0 commit comments

Comments
 (0)