TreeView using react-virtual
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
Generated
+33
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user