From 52d96de3af92d23ff96aa8d57de5cf3db92035c8 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 4 Mar 2022 16:23:42 +0000 Subject: [PATCH] TreeView using react-virtual --- app/components/JsonTreeView.tsx | 166 ++++++++++++++++++++++++++++++++ app/routes/j/$id/term.tsx | 11 --- app/routes/j/$id/terminal.tsx | 2 +- app/routes/j/$id/tree.tsx | 20 +--- app/utilities/jsonColumnView.ts | 2 +- app/utilities/jsonTreeView.ts | 72 ++++++++++++++ package-lock.json | 33 +++++++ package.json | 3 +- tests/jsonTreeView.test.ts | 84 ++++++++++++++++ 9 files changed, 362 insertions(+), 31 deletions(-) create mode 100644 app/components/JsonTreeView.tsx delete mode 100644 app/routes/j/$id/term.tsx create mode 100644 app/utilities/jsonTreeView.ts create mode 100644 tests/jsonTreeView.test.ts diff --git a/app/components/JsonTreeView.tsx b/app/components/JsonTreeView.tsx new file mode 100644 index 0000000..87c8f49 --- /dev/null +++ b/app/components/JsonTreeView.tsx @@ -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(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 ( +
+
+ {rowVirtualizer.virtualItems.map((virtualRow) => ( +
+ +
+ ))} +
+
+ ); +} + +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 ( +
+
+ {node.collapsable && } + + {node.longTitle ?? node.name} +
+ +
+ + {node.icon && } + + {node.subtitle && ( + + {node.subtitle} + + )} +
+
+ ); +} + +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 +): number { + return nodes.findIndex((node) => node.id === selectedNodeId); +} diff --git a/app/routes/j/$id/term.tsx b/app/routes/j/$id/term.tsx deleted file mode 100644 index f944e1b..0000000 --- a/app/routes/j/$id/term.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Title } from "~/components/Primitives/Title"; - -export default function Terminal() { - return ( -
- - Terminal View coming soon - -
- ); -} diff --git a/app/routes/j/$id/terminal.tsx b/app/routes/j/$id/terminal.tsx index cedb520..b45682c 100644 --- a/app/routes/j/$id/terminal.tsx +++ b/app/routes/j/$id/terminal.tsx @@ -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 (
diff --git a/app/routes/j/$id/tree.tsx b/app/routes/j/$id/tree.tsx index e408d94..07a0373 100644 --- a/app/routes/j/$id/tree.tsx +++ b/app/routes/j/$id/tree.tsx @@ -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 ( -
-
- - - Tree View - - - Coming soon - -
-
- ); +export default function TreeViewPage() { + return ; } diff --git a/app/utilities/jsonColumnView.ts b/app/utilities/jsonColumnView.ts index 3455c24..412f81a 100644 --- a/app/utilities/jsonColumnView.ts +++ b/app/utilities/jsonColumnView.ts @@ -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; diff --git a/app/utilities/jsonTreeView.ts b/app/utilities/jsonTreeView.ts new file mode 100644 index 0000000..ee3aba1 --- /dev/null +++ b/app/utilities/jsonTreeView.ts @@ -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 { + 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 { + 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 []; +} diff --git a/package-lock.json b/package-lock.json index b057681..8561bce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ffe02f9..bedb074 100644 --- a/package.json +++ b/package.json @@ -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" -} \ No newline at end of file +} diff --git a/tests/jsonTreeView.test.ts b/tests/jsonTreeView.test.ts new file mode 100644 index 0000000..fdbfa50 --- /dev/null +++ b/tests/jsonTreeView.test.ts @@ -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", + }, +] +`); + }); +});