Skip to content

Commit 14b17ba

Browse files
committed
feat: add copy button to tree rows; refs #88
1 parent b9891d0 commit 14b17ba

File tree

4 files changed

+155
-2
lines changed

4 files changed

+155
-2
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import CheckIcon from "@mui/icons-material/Check";
2+
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
3+
import { IconButton, Tooltip } from "@mui/material";
4+
import React from "react";
5+
6+
const write = async (text: string) => {
7+
try {
8+
await navigator.clipboard.writeText(text);
9+
return true;
10+
} catch {
11+
// Fallback
12+
const ta = document.createElement("textarea");
13+
ta.value = text;
14+
ta.style.position = "fixed";
15+
ta.style.opacity = "0";
16+
document.body.appendChild(ta);
17+
ta.focus();
18+
ta.select();
19+
const ok = document.execCommand("copy");
20+
document.body.removeChild(ta);
21+
return ok;
22+
}
23+
};
24+
25+
export default function CopyButton({
26+
text,
27+
title = "Copy",
28+
size = "small",
29+
}: {
30+
text: string;
31+
title?: string;
32+
size?: "small" | "medium" | "large";
33+
}) {
34+
const [ok, setOk] = React.useState(false);
35+
36+
const onClick = async (e: React.MouseEvent) => {
37+
e.stopPropagation();
38+
if (await write(text)) {
39+
setOk(true);
40+
setTimeout(() => setOk(false), 1200);
41+
}
42+
};
43+
44+
return (
45+
<Tooltip title={ok ? "Copied!" : title} arrow>
46+
<IconButton size={size} onClick={onClick} sx={{ ml: 0.5 }}>
47+
{ok ? (
48+
<CheckIcon fontSize="inherit" />
49+
) : (
50+
<ContentCopyIcon fontSize="inherit" />
51+
)}
52+
</IconButton>
53+
</Tooltip>
54+
);
55+
}

src/components/DatasetDetailPage/FileTree/FileTree.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type Props = {
1313
// for preview in tree row
1414
onPreview: (src: string | any, index: number, isInternal?: boolean) => void;
1515
getInternalByPath: (path: string) => { data: any; index: number } | undefined;
16+
getJsonByPath?: (path: string) => any;
1617
};
1718

1819
const formatSize = (n: number) => {
@@ -30,6 +31,7 @@ const FileTree: React.FC<Props> = ({
3031
totalBytes,
3132
onPreview,
3233
getInternalByPath,
34+
getJsonByPath,
3335
}) => (
3436
<Box
3537
sx={{
@@ -68,6 +70,7 @@ const FileTree: React.FC<Props> = ({
6870
level={0}
6971
onPreview={onPreview}
7072
getInternalByPath={getInternalByPath}
73+
getJsonByPath={getJsonByPath}
7174
/> // pass the handlePreview(onPreview = handlePreview) function to FileTreeRow
7275
))}
7376
</Box>

src/components/DatasetDetailPage/FileTree/FileTreeRow.tsx

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
// for rendering the preview and download buttons in folder structure row
22
import type { TreeNode } from "./types";
33
import { formatLeafValue, isPreviewable } from "./utils";
4+
import CheckIcon from "@mui/icons-material/Check";
5+
// for copy button
6+
// add to imports
7+
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
48
import DownloadIcon from "@mui/icons-material/Download";
59
import ExpandLess from "@mui/icons-material/ExpandLess";
610
import ExpandMore from "@mui/icons-material/ExpandMore";
711
import FolderIcon from "@mui/icons-material/Folder";
812
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
913
import VisibilityIcon from "@mui/icons-material/Visibility";
1014
import { Box, Button, Collapse, Typography } from "@mui/material";
15+
import { Tooltip, IconButton } from "@mui/material";
1116
import { Colors } from "design/theme";
1217
import React from "react";
1318

@@ -18,20 +23,53 @@ type Props = {
1823
// src is either an external URL(string) or the internal object
1924
onPreview: (src: string | any, index: number, isInternal?: boolean) => void;
2025
getInternalByPath: (path: string) => { data: any; index: number } | undefined;
26+
getJsonByPath?: (path: string) => any;
27+
};
28+
29+
// copy helper function
30+
const copyText = async (text: string) => {
31+
try {
32+
await navigator.clipboard.writeText(text);
33+
return true;
34+
} catch {
35+
// fallback if the copy api not working
36+
const ta = document.createElement("textarea");
37+
ta.value = text;
38+
ta.style.position = "fixed";
39+
ta.style.opacity = "0";
40+
document.body.appendChild(ta);
41+
ta.select();
42+
const ok = document.execCommand("copy");
43+
document.body.removeChild(ta);
44+
return ok;
45+
}
2146
};
2247

2348
const FileTreeRow: React.FC<Props> = ({
2449
node,
2550
level,
2651
onPreview,
2752
getInternalByPath,
53+
getJsonByPath,
2854
}) => {
2955
const [open, setOpen] = React.useState(false);
56+
const [copied, setCopied] = React.useState(false);
3057
// const internal = getInternalByPath?.(node.path);
3158
// const internal = getInternalByPath ? getInternalByPath(node.path) : undefined;
3259
const internal = getInternalByPath(node.path);
3360
const externalUrl = node.link?.url;
3461

62+
const handleCopy = async (e: React.MouseEvent) => {
63+
e.stopPropagation(); // prevent expand/ collapse from firing when click the copy button
64+
const json = getJsonByPath?.(node.path); // call getJsonByPath(node.path)
65+
const asText = JSON.stringify(json, null, 2); // subtree at this row
66+
if (await copyText(asText ?? "null")) {
67+
// call copyText function
68+
setCopied(true);
69+
setTimeout(() => setCopied(false), 1200);
70+
}
71+
};
72+
3573
// if (node.kind === "folder") {
3674
// return (
3775
// <>
@@ -103,7 +141,7 @@ const FileTreeRow: React.FC<Props> = ({
103141
{node.name}
104142
</Typography>
105143

106-
{/* Actions on folder if it carries a link (from linkHere) */}
144+
{/* Actions on folder if it carries a link (from linkHere) */}
107145
{node.link?.url && (
108146
<Box
109147
sx={{ display: "flex", gap: 1, mr: 0.5, flexShrink: 0 }}
@@ -150,6 +188,22 @@ const FileTreeRow: React.FC<Props> = ({
150188
</Box>
151189
)}
152190

191+
{/* Copy subtree JSON button */}
192+
<Box
193+
sx={{ display: "flex", gap: 1, mr: 0.5, flexShrink: 0 }}
194+
onClick={(e) => e.stopPropagation()}
195+
>
196+
<Tooltip title={copied ? "Copied!" : "Copy subtree JSON"} arrow>
197+
<IconButton size="small" onClick={handleCopy}>
198+
{copied ? (
199+
<CheckIcon fontSize="inherit" />
200+
) : (
201+
<ContentCopyIcon fontSize="inherit" />
202+
)}
203+
</IconButton>
204+
</Tooltip>
205+
</Box>
206+
153207
{open ? <ExpandLess /> : <ExpandMore />}
154208
</Box>
155209

@@ -162,6 +216,7 @@ const FileTreeRow: React.FC<Props> = ({
162216
level={level + 1}
163217
onPreview={onPreview}
164218
getInternalByPath={getInternalByPath}
219+
getJsonByPath={getJsonByPath}
165220
/>
166221
))}
167222
</Collapse>
@@ -218,7 +273,22 @@ const FileTreeRow: React.FC<Props> = ({
218273
</Typography>
219274
)}
220275
</Box>
221-
276+
{/* ALWAYS show copy for files, even when no external/internal */}
277+
<Tooltip title={copied ? "Copied!" : "Copy JSON"} arrow>
278+
<span>
279+
<IconButton
280+
size="small"
281+
onClick={handleCopy}
282+
disabled={!getJsonByPath} // optional safety
283+
>
284+
{copied ? (
285+
<CheckIcon fontSize="inherit" />
286+
) : (
287+
<ContentCopyIcon fontSize="inherit" />
288+
)}
289+
</IconButton>
290+
</span>
291+
</Tooltip>
222292
{(externalUrl || internal) && (
223293
<Box
224294
sx={{ display: "flex", gap: 1, flexShrink: 0 }}
@@ -261,6 +331,7 @@ const FileTreeRow: React.FC<Props> = ({
261331
Preview
262332
</Button>
263333
)}
334+
264335
{/* {node.link?.url && (
265336
<Box sx={{ display: "flex", gap: 1, flexShrink: 0 }}>
266337
<Button

src/pages/UpdatedDatasetDetailPage.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,29 @@ const UpdatedDatasetDetailPage: React.FC = () => {
694694

695695
const getInternalByPath = (path: string) => internalMap.get(path);
696696

697+
// returns the subtree/primitive at that path—returning the whole document if the path is empty, or undefined if any step is invalid.
698+
const getJsonByPath = React.useCallback(
699+
(path: string) => {
700+
if (!datasetDocument) return undefined;
701+
if (!path) return datasetDocument; // root
702+
703+
const parts = path.split("/").filter(Boolean); // "/a/b/[0]/c" → ["a","b","[0]","c"]
704+
let cur: any = datasetDocument;
705+
for (const p of parts) {
706+
if (/^\[\d+\]$/.test(p)) {
707+
const idx = parseInt(p.slice(1, -1), 10);
708+
if (!Array.isArray(cur)) return undefined;
709+
cur = cur[idx];
710+
} else {
711+
if (cur == null || typeof cur !== "object") return undefined;
712+
cur = cur[p];
713+
}
714+
}
715+
return cur;
716+
},
717+
[datasetDocument]
718+
);
719+
697720
const handleClosePreview = () => {
698721
setPreviewOpen(false);
699722
setPreviewDataKey(null);
@@ -1096,6 +1119,7 @@ const UpdatedDatasetDetailPage: React.FC = () => {
10961119
totalBytes={totalBytes}
10971120
onPreview={handlePreview} // pass the function down to FileTree
10981121
getInternalByPath={getInternalByPath}
1122+
getJsonByPath={getJsonByPath}
10991123
/>
11001124
</Box>
11011125

0 commit comments

Comments
 (0)