TreeView using react-virtual

This commit is contained in:
Eric Allam
2022-03-04 16:24:12 +00:00
parent a3bd1c03fe
commit 52d96de3af
9 changed files with 362 additions and 31 deletions
+166
View File
@@ -0,0 +1,166 @@
import { ChevronDownIcon } from "@heroicons/react/outline";
import { JSONHeroPath } from "@jsonhero/path";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useVirtual } from "react-virtual";
import { useJson } from "~/hooks/useJson";
import {
useJsonColumnViewAPI,
useJsonColumnViewState,
} from "~/hooks/useJsonColumnView";
import { generateTreeViewNodes, TreeViewNode } from "~/utilities/jsonTreeView";
import { Body } from "./Primitives/Body";
import { Mono } from "./Primitives/Mono";
const initialRect = { width: 800, height: 600 };
export function JsonTreeView() {
const [json] = useJson();
const { selectedNodeId } = useJsonColumnViewState();
const treeNodes = useMemo(() => {
return generateTreeViewNodes(json);
}, [json]);
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtual({
size: treeNodes.length,
overscan: 10,
parentRef,
estimateSize: useCallback(() => 32, []),
initialRect,
});
useEffect(() => {
if (selectedNodeId) {
rowVirtualizer.scrollToIndex(findNodeIndex(selectedNodeId, treeNodes));
}
}, [selectedNodeId, treeNodes]);
return (
<div
className="text-white w-full"
ref={parentRef}
style={{
height: `calc(100vh - 106px)`,
overflowY: "auto",
overflowX: "hidden",
}}
>
<div
className="w-full relative"
style={{ height: `${rowVirtualizer.totalSize}px` }}
>
{rowVirtualizer.virtualItems.map((virtualRow) => (
<div
key={virtualRow.index}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<TreeViewItem
node={treeNodes[virtualRow.index]}
index={virtualRow.index}
selectedNodeId={selectedNodeId}
/>
</div>
))}
</div>
</div>
);
}
function TreeViewItem({
node,
index,
selectedNodeId,
}: {
node: TreeViewNode;
index: number;
selectedNodeId?: string;
}) {
const indentClassName = computeTreeNodePaddingClass(node);
const { goToNodeId } = useJsonColumnViewAPI();
const isSelected = node.id === selectedNodeId;
const handleClick = useCallback(
() => goToNodeId(node.id),
[goToNodeId, node.id]
);
return (
<div
className={`h-full flex select-none ${
isSelected
? "dark:bg-indigo-700"
: index % 2
? "dark:bg-slate-800"
: "dark:bg-slate-700"
}`}
onClick={handleClick}
>
<div className={`${indentClassName} w-2/5 items-center flex`}>
{node.collapsable && <ChevronDownIcon className="w-3 h-3 mr-2" />}
<Body>{node.longTitle ?? node.name}</Body>
</div>
<div className="flex w-3/5 items-center">
<span className="mr-2">
{node.icon && <node.icon className={`h-5 w-5`} />}
</span>
{node.subtitle && (
<Mono className="truncate text-gray-400 pr-1 transition dark:text-gray-500">
{node.subtitle}
</Mono>
)}
</div>
</div>
);
}
function computeTreeNodePaddingClass(node: TreeViewNode) {
const path = new JSONHeroPath(node.id);
const depth = path.components.length - 1;
switch (depth) {
case 0:
return "pl-[4px]";
case 1:
return "pl-[24px]";
case 2:
return "pl-[48px]";
case 3:
return "pl-[72px]";
case 4:
return "pl-[96px]";
case 5:
return "pl-[120px]";
case 6:
return "pl-[144px]";
case 7:
return "pl-[168px]";
case 8:
return "pl-[192px]";
case 9:
return "pl-[216px]";
case 10:
return "pl-[240px]";
default:
return "pl-[264px]";
}
}
function findNodeIndex(
selectedNodeId: string,
nodes: Array<TreeViewNode>
): number {
return nodes.findIndex((node) => node.id === selectedNodeId);
}
-11
View File
@@ -1,11 +0,0 @@
import { Title } from "~/components/Primitives/Title";
export default function Terminal() {
return (
<div className="relative block w-6 ml-6">
<Title className="mt-2 block text-sm font-medium text-gray-900 dark:text-white">
Terminal View coming soon
</Title>
</div>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { LargeTitle } from "~/components/Primitives/LargeTitle";
import { Body } from "~/components/Primitives/Body";
import { TerminalIcon } from "@heroicons/react/outline";
export default function Terminal() {
export default function TerminalViewPage() {
return (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center justify-center max-w-[300px] rounded text-center bg-slate-200 shadow border-slate-100 border-[10px] border-solid py-16 px-16 transition dark:bg-slate-700 dark:border-slate-500">
+3 -17
View File
@@ -1,19 +1,5 @@
import { TreeIcon } from "~/components/Icons/TreeIcon";
import { LargeTitle } from "~/components/Primitives/LargeTitle";
import { Body } from "~/components/Primitives/Body";
import { JsonTreeView } from "~/components/JsonTreeView";
export default function Terminal() {
return (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center justify-center max-w-[300px] rounded text-center bg-slate-200 shadow border-slate-100 border-[10px] border-solid py-16 px-16 transition dark:bg-slate-700 dark:border-slate-500">
<TreeIcon className="text-indigo-500 transition dark:text-white w-8 mb-2" />
<LargeTitle className="text-gray-900 transition dark:text-white">
Tree View
</LargeTitle>
<Body className="text-gray-700 transition dark:text-white">
Coming soon
</Body>
</div>
</div>
);
export default function TreeViewPage() {
return <JsonTreeView />;
}
+1 -1
View File
@@ -117,7 +117,7 @@ export function generateNodesToPath(
return nodes;
}
function iconForType(type: JSONValueType): IconComponent {
export function iconForType(type: JSONValueType): IconComponent {
switch (type.name) {
case "object": {
return CubeIcon;
+72
View File
@@ -0,0 +1,72 @@
import { inferType, JSONValueType } from "@jsonhero/json-infer-types";
import { JSONHeroPath } from "@jsonhero/path";
import { IconComponent } from "~/useColumnView";
import { formatValue } from "./formatter";
import { iconForType } from "./jsonColumnView";
export type TreeViewNode = {
id: string;
name: string;
title: string;
subtitle?: string;
longTitle?: string;
icon?: IconComponent;
collapsable: boolean;
};
export function generateTreeViewNodes(json: unknown): Array<TreeViewNode> {
const info = inferType(json);
const path = new JSONHeroPath("$");
const rootNode = {
name: "root",
title: "root",
id: "$",
icon: iconForType(info),
collapsable: info.name === "object" || info.name === "array",
};
return [rootNode, ...generateChildren(info, path)];
}
function generateChildren(
info: JSONValueType,
path: JSONHeroPath
): Array<TreeViewNode> {
if (info.name === "array") {
return info.value.flatMap((item, index) => {
const itemInfo = inferType(item);
const itemPath = path.child(index.toString());
const itemNode = {
id: itemPath.toString(),
name: index.toString(),
title: index.toString(),
longTitle: `Index ${index.toString()}`,
subtitle: formatValue(itemInfo),
icon: iconForType(itemInfo),
collapsable: itemInfo.name === "object" || itemInfo.name === "array",
};
return [itemNode, ...generateChildren(itemInfo, itemPath)];
});
}
if (info.name === "object") {
return Object.entries(info.value).flatMap(([key, value]) => {
const itemInfo = inferType(value);
const itemPath = path.child(key);
const itemNode = {
id: itemPath.toString(),
name: key,
title: key,
subtitle: formatValue(itemInfo),
icon: iconForType(itemInfo),
collapsable: itemInfo.name === "object" || itemInfo.name === "array",
};
return [itemNode, ...generateChildren(itemInfo, itemPath)];
});
}
return [];
}
+33
View File
@@ -34,6 +34,7 @@
"react-dom": "^17.0.2",
"react-dropzone": "^11.4.2",
"react-hotkeys-hook": "^3.4.4",
"react-virtual": "^2.10.4",
"remix": "^1.2.3",
"tailwindcss-radix": "^1.6.0",
"tiny-invariant": "^1.2.0",
@@ -2436,6 +2437,11 @@
"@babel/runtime": "^7.13.10"
}
},
"node_modules/@reach/observe-rect": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@reach/observe-rect/-/observe-rect-1.2.0.tgz",
"integrity": "sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ=="
},
"node_modules/@remix-run/cloudflare-workers": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@remix-run/cloudflare-workers/-/cloudflare-workers-1.2.3.tgz",
@@ -11408,6 +11414,20 @@
}
}
},
"node_modules/react-virtual": {
"version": "2.10.4",
"resolved": "https://registry.npmjs.org/react-virtual/-/react-virtual-2.10.4.tgz",
"integrity": "sha512-Ir6+oPQZTVHfa6+JL9M7cvMILstFZH/H3jqeYeKI4MSUX+rIruVwFC6nGVXw9wqAw8L0Kg2KvfXxI85OvYQdpQ==",
"funding": [
"https://github.com/sponsors/tannerlinsley"
],
"dependencies": {
"@reach/observe-rect": "^1.1.0"
},
"peerDependencies": {
"react": "^16.6.3 || ^17.0.0"
}
},
"node_modules/read-package-json-fast": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-2.0.3.tgz",
@@ -15333,6 +15353,11 @@
"@babel/runtime": "^7.13.10"
}
},
"@reach/observe-rect": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@reach/observe-rect/-/observe-rect-1.2.0.tgz",
"integrity": "sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ=="
},
"@remix-run/cloudflare-workers": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@remix-run/cloudflare-workers/-/cloudflare-workers-1.2.3.tgz",
@@ -21869,6 +21894,14 @@
"tslib": "^1.0.0"
}
},
"react-virtual": {
"version": "2.10.4",
"resolved": "https://registry.npmjs.org/react-virtual/-/react-virtual-2.10.4.tgz",
"integrity": "sha512-Ir6+oPQZTVHfa6+JL9M7cvMILstFZH/H3jqeYeKI4MSUX+rIruVwFC6nGVXw9wqAw8L0Kg2KvfXxI85OvYQdpQ==",
"requires": {
"@reach/observe-rect": "^1.1.0"
}
},
"read-package-json-fast": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-2.0.3.tgz",
+2 -1
View File
@@ -51,6 +51,7 @@
"react-dom": "^17.0.2",
"react-dropzone": "^11.4.2",
"react-hotkeys-hook": "^3.4.4",
"react-virtual": "^2.10.4",
"remix": "^1.2.3",
"tailwindcss-radix": "^1.6.0",
"tiny-invariant": "^1.2.0",
@@ -100,4 +101,4 @@
},
"sideEffects": false,
"main": "dist/worker.js"
}
}
+84
View File
@@ -0,0 +1,84 @@
import { generateTreeViewNodes } from "../app/utilities/jsonTreeView";
describe("generateTreeViewNodes", () => {
test("it creates the correct tree structure for the passed in JSON", () => {
const json = {
name: "Eric Allam",
address: { city: "London", country: "UK" },
emailAddresses: [
{
primary: "eric@stackhero.run",
},
],
};
expect(generateTreeViewNodes(json)).toMatchInlineSnapshot(`
Array [
Object {
"collapsable": true,
"icon": [Function],
"id": "$",
"name": "root",
"title": "root",
},
Object {
"collapsable": false,
"icon": [Function],
"id": "$.name",
"name": "name",
"subtitle": "Eric Allam",
"title": "name",
},
Object {
"collapsable": true,
"icon": [Function],
"id": "$.address",
"name": "address",
"subtitle": undefined,
"title": "address",
},
Object {
"collapsable": false,
"icon": [Function],
"id": "$.address.city",
"name": "city",
"subtitle": "London",
"title": "city",
},
Object {
"collapsable": false,
"icon": [Function],
"id": "$.address.country",
"name": "country",
"subtitle": "UK",
"title": "country",
},
Object {
"collapsable": true,
"icon": [Function],
"id": "$.emailAddresses",
"name": "emailAddresses",
"subtitle": undefined,
"title": "emailAddresses",
},
Object {
"collapsable": true,
"icon": [Function],
"id": "$.emailAddresses.0",
"longTitle": "Index 0",
"name": "0",
"subtitle": undefined,
"title": "0",
},
Object {
"collapsable": false,
"icon": [Function],
"id": "$.emailAddresses.0.primary",
"name": "primary",
"subtitle": "eric@stackhero.run",
"title": "primary",
},
]
`);
});
});