Merge branch 'features/tree-view'

# Conflicts:
#	app/components/ColumnItem.tsx
#	app/components/PathBar.tsx
This commit is contained in:
Eric Allam
2022-03-22 09:40:58 +00:00
21 changed files with 1264 additions and 96 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "typescript",
"tsconfig": "tsconfig.json",
"option": "watch",
"problemMatcher": ["$tsc-watch"],
"group": "build",
"label": "tsc: watch - tsconfig.json"
}
]
}
+19 -6
View File
@@ -10,7 +10,7 @@ export type ColumnItemProps = {
json: unknown;
isSelected: boolean;
isHighlighted: boolean;
selectedItem: (id: string) => void;
onClick?: (id: string) => void;
};
function ColumnItemElement({
@@ -18,7 +18,7 @@ function ColumnItemElement({
json,
isSelected,
isHighlighted,
selectedItem,
onClick,
}: ColumnItemProps) {
const htmlElement = useRef<HTMLDivElement>(null);
@@ -50,18 +50,31 @@ function ColumnItemElement({
return (
<div
className={`flex h-9 items-center justify-items-stretch mx-1 px-1 py-1 my-1 rounded-sm ${stateStyle}`}
onClick={() => selectedItem(item.id)}
onClick={() => onClick && onClick(item.id)}
ref={htmlElement}
>
<div className="w-4 flex-none flex-col justify-items-center">
{item.icon && <item.icon className={`h-5 w-5 ${isSelected && isHighlighted ? "text-slate-900 dark:text-slate-300" : "text-slate-500"}`} />}
{item.icon && (
<item.icon
className={`h-5 w-5 ${
isSelected && isHighlighted
? "text-slate-900 dark:text-slate-300"
: "text-slate-500"
}`}
/>
)}
</div>
<div className="flex flex-grow flex-shrink items-baseline justify-between truncate">
<Body className="flex-grow flex-shrink-0 pl-3 pr-2 ">{item.title}</Body>
{item.subtitle && (
<Mono className={`truncate pr-1 transition duration-75 ${isHighlighted ? "text-slate-100" : "text-gray-400 dark:text-gray-500"}`}>
<Mono
className={`truncate pr-1 transition duration-75 ${
isHighlighted
? "text-slate-100"
: "text-gray-400 dark:text-gray-500"
}`}
>
{item.subtitle}
</Mono>
)}
+1 -1
View File
@@ -35,7 +35,7 @@ function ColumnsElement({ columns }: { columns: ColumnDefinition[] }) {
isHighlighted={
highlightedPath[highlightedPath.length - 1] === item.id
}
selectedItem={goToNodeId}
onClick={(id) => goToNodeId(id, "columnView")}
/>
))}
</Column>
+1 -1
View File
@@ -63,7 +63,7 @@ function HomeInfoBoxSectionContent() {
useEffect(() => {
const selectedPath = infoBoxData[index].highlight;
api.goToNodeId(selectedPath);
api.goToNodeId(selectedPath, "home");
}, [index]);
const resetInterval = () => {
+1 -1
View File
@@ -72,7 +72,7 @@ export function JsonEditor() {
const path = JSONHeroPath.fromPointer(pointer);
goToNodeId(path.toString());
goToNodeId(path.toString(), "editor");
},
[goToNodeId]
);
+238
View File
@@ -0,0 +1,238 @@
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline";
import { useEffect, useRef } from "react";
import {
useJsonColumnViewAPI,
useJsonColumnViewState,
} from "~/hooks/useJsonColumnView";
import { JsonTreeViewNode, useJsonTreeViewContext } from "~/hooks/useJsonTree";
import { VirtualNode } from "~/hooks/useVirtualTree";
import { Body } from "./Primitives/Body";
import { Mono } from "./Primitives/Mono";
export function JsonTreeView() {
const { selectedNodeId, selectedNodeSource } = useJsonColumnViewState();
const { goToNodeId } = useJsonColumnViewAPI();
const { tree, parentRef } = useJsonTreeViewContext();
// Scroll to the selected node when this component is first rendered.
const scrolledToNodeRef = useRef(false);
useEffect(() => {
if (!scrolledToNodeRef.current && selectedNodeId) {
tree.scrollToNode(selectedNodeId);
scrolledToNodeRef.current = true;
}
}, [selectedNodeId, scrolledToNodeRef]);
// This focuses and scrolls to the selected node when the selectedNodeId
// is set from a source other than this tree (e.g. the search bar, path bar, related values).
useEffect(() => {
if (
tree.focusedNodeId &&
selectedNodeId &&
tree.focusedNodeId !== selectedNodeId
) {
if (selectedNodeId === "$") {
return;
}
if (selectedNodeSource !== "tree") {
tree.focusNode(selectedNodeId);
tree.scrollToNode(selectedNodeId);
}
}
}, [tree.focusedNodeId, goToNodeId, selectedNodeId, selectedNodeSource]);
// This is what syncs the tree view's focused node to the column view selected node
const previousFocusedNodeId = useRef<string | null>(null);
useEffect(() => {
let updated = false;
if (!previousFocusedNodeId.current) {
previousFocusedNodeId.current = tree.focusedNodeId;
updated = true;
}
if (
tree.focusedNodeId &&
(updated || previousFocusedNodeId.current !== tree.focusedNodeId)
) {
previousFocusedNodeId.current = tree.focusedNodeId;
goToNodeId(tree.focusedNodeId, "tree");
}
}, [previousFocusedNodeId, tree.focusedNodeId, tree.focusNode, goToNodeId]);
const treeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (treeRef.current) {
treeRef.current.focus({ preventScroll: true });
}
}, [treeRef.current]);
return (
<div
className="text-white w-full"
ref={parentRef}
style={{
height: `calc(100vh - 106px)`,
overflowY: "auto",
overflowX: "hidden",
}}
>
<div
className="relative w-full outline-none"
style={{ height: `${tree.totalSize}px` }}
{...tree.getTreeProps()}
ref={treeRef}
>
{tree.nodes.map((virtualNode) => (
<TreeViewNode
virtualNode={virtualNode}
key={virtualNode.node.id}
onToggle={(node, e) => tree.toggleNode(node.id, e)}
selectedNodeId={selectedNodeId}
/>
))}
</div>
</div>
);
}
function TreeViewNode({
virtualNode,
onToggle,
selectedNodeId,
}: {
virtualNode: VirtualNode<JsonTreeViewNode>;
selectedNodeId?: string;
onToggle?: (node: JsonTreeViewNode, e: MouseEvent) => void;
}) {
const { node, virtualItem, depth } = virtualNode;
const indentClassName = computeTreeNodePaddingClass(depth);
const isSelected = selectedNodeId === node.id;
return (
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualNode.size}px`,
transform: `translateY(${virtualNode.start}px)`,
}}
key={virtualNode.node.id}
{...virtualNode.getItemProps()}
>
<div
className={`h-full flex m-2 pl-5 rounded-sm select-none ${
isSelected
? "bg-indigo-700"
: virtualItem.index % 2
? "dark:bg-slate-900"
: "bg-slate-200 bg-opacity-90 dark:bg-slate-800 dark:bg-opacity-30"
}`}
>
<div className={`${indentClassName} w-2/6 items-center flex`}>
{node.children && node.children.length > 0 && (
<span
onClick={(e) => {
if (onToggle) {
e.preventDefault();
onToggle(node, e.nativeEvent);
}
}}
>
{virtualNode.isCollapsed ? (
<ChevronRightIcon
className={`w-4 h-4 mr-1 -ml-5 ${
isSelected
? "text-slate-100"
: "text-slate-600 dark:text-slate-100"
}`}
/>
) : (
<ChevronDownIcon
className={`w-4 h-4 mr-1 -ml-5 ${
isSelected
? "text-slate-100"
: "text-slate-600 dark:text-slate-100"
}`}
/>
)}
</span>
)}
<Body
className={`truncate whitespace-nowrap pr-2 ${
isSelected
? "text-slate-100"
: "text-slate-700 dark:text-slate-200"
}`}
>
{node.longTitle ?? node.name}
</Body>
</div>
<div className="flex w-4/6 items-center">
<span className="mr-2">
{node.icon && (
<node.icon
className={`h-5 w-5 ${
isSelected
? "text-slate-100"
: "text-slate-400 dark:text-slate-500"
}`}
/>
)}
</span>
{node.subtitle && (
<Mono
className={`truncate pr-1 transition ${
isSelected
? "text-slate-100"
: "text-slate-500 dark:text-slate-200"
}`}
>
{node.subtitle}
</Mono>
)}
</div>
</div>
</div>
);
}
function computeTreeNodePaddingClass(depth: number) {
switch (depth) {
case 0:
return "pl-[4px]";
case 1:
return "pl-[calc(12px_+_4px)]";
case 2:
return "pl-[calc(12px_*_2_+_4px)]";
case 3:
return "pl-[calc(12px_*_3_+_4px)]";
case 4:
return "pl-[calc(12px_*_4_+_4px)]";
case 5:
return "pl-[calc(12px_*_5_+_4px)]";
case 6:
return "pl-[calc(12px_*_6_+_4px)]";
case 7:
return "pl-[calc(12px_*_7_+_4px)]";
case 8:
return "pl-[calc(12px_*_8_+_4px)]";
case 9:
return "pl-[calc(12px_*_9_+_4px)]";
case 10:
return "pl-[calc(12px_*_10_+_4px)]";
default:
return "pl-[calc(12px_*_11_+_4px)]";
}
}
+4 -4
View File
@@ -42,7 +42,7 @@ export function PathBarLink({
key={index}
node={node}
highlightedNodeId={highlightedNodeId}
goToNodeId={goToNodeId}
onClick={(id) => goToNodeId(id, "pathBar")}
isLast={index == selectedNodes.length - 1}
/>
);
@@ -94,12 +94,12 @@ export function PathHistoryControls() {
function PathBarElement({
node,
highlightedNodeId,
goToNodeId,
onClick,
isLast,
}: {
node: ColumnViewNode;
highlightedNodeId: string | undefined;
goToNodeId: (id: string) => void;
onClick?: (id: string) => void;
isLast: boolean;
}) {
return (
@@ -118,7 +118,7 @@ function PathBarElement({
style={{
flexShrink: 1,
}}
onClick={() => goToNodeId(node.id)}
onClick={() => onClick && onClick(node.id)}
>
<div className="w-4 flex-shrink-[0.5] flex-grow-0 flex-col justify-items-center whitespace-nowrap overflow-x-hidden transition dark:text-slate-400">
{node.icon && <node.icon className="h-3 w-3" />}
+2 -1
View File
@@ -81,7 +81,8 @@ export function PathPreview({
: "disabled"
}`}
onClick={() =>
isEnabled && goToNodeId(components[components.length - 1].id)
isEnabled &&
goToNodeId(components[components.length - 1].id, "relatedValues")
}
>
<div
+3 -1
View File
@@ -121,6 +121,7 @@ export function JsonColumnViewProvider({ children }: { children: ReactNode }) {
const isStateRestored = useRef<boolean>(!!initialNodeId);
// This is restoring the state
useEffect(() => {
if (isStateRestored.current) {
return;
@@ -134,9 +135,10 @@ export function JsonColumnViewProvider({ children }: { children: ReactNode }) {
const restoredState = JSON.parse(storage) as ColumnViewInstanceState;
if (!restoredState.selectedNodeId) return;
api.goToNodeId(restoredState.selectedNodeId);
api.goToNodeId(restoredState.selectedNodeId, "localStorage");
}, [doc.id, isStateRestored.current, state, api]);
// This is setting the state
useEffect(() => {
if (doc == null) {
return;
+134
View File
@@ -0,0 +1,134 @@
import { useJson } from "./useJson";
import { inferType, JSONValueType } from "@jsonhero/json-infer-types";
import { JSONHeroPath } from "@jsonhero/path";
import { IconComponent } from "~/useColumnView";
import { formatValue } from "~/utilities/formatter";
import { iconForType } from "~/utilities/jsonColumnView";
import {
createContext,
ReactNode,
useCallback,
useContext,
useMemo,
useRef,
} from "react";
import { useVirtualTree, UseVirtualTreeInstance } from "./useVirtualTree";
import invariant from "tiny-invariant";
import { useJsonDoc } from "./useJsonDoc";
const initialRect = { width: 800, height: 600 };
export type JsonTreeOptions = {
overscan?: number;
};
export type UseJsonTreeInstance = {
tree: UseVirtualTreeInstance<JsonTreeViewNode>;
parentRef: React.RefObject<HTMLDivElement>;
};
export type JsonTreeViewType = UseJsonTreeInstance;
const JsonTreeViewContext = createContext<JsonTreeViewType>(
{} as JsonTreeViewType
);
export function JsonTreeViewProvider({
children,
...options
}: { children: ReactNode } & JsonTreeOptions) {
const instance = useJsonTree(options);
return (
<JsonTreeViewContext.Provider value={instance}>
{children}
</JsonTreeViewContext.Provider>
);
}
export function useJsonTree(options: JsonTreeOptions): UseJsonTreeInstance {
const parentRef = useRef<HTMLDivElement>(null);
const { doc } = useJsonDoc();
const [json] = useJson();
const jsonNodes = useMemo(() => {
return generateTreeViewNodes(json);
}, [json]);
const tree = useVirtualTree({
id: doc.id,
nodes: jsonNodes,
parentRef,
estimateSize: useCallback((index) => 32, []),
initialRect,
overscan: options.overscan,
persistState: true,
});
return { tree, parentRef };
}
export function useJsonTreeViewContext(): JsonTreeViewType {
const context = useContext(JsonTreeViewContext);
invariant(
context,
"useJsonTreeViewContext must be used within a JsonTreeViewContext.Provider"
);
return context;
}
export type JsonTreeViewNode = {
id: string;
name: string;
title: string;
subtitle?: string;
longTitle?: string;
icon?: IconComponent;
children?: Array<JsonTreeViewNode>;
};
export function generateTreeViewNodes(json: unknown): Array<JsonTreeViewNode> {
const info = inferType(json);
const path = new JSONHeroPath("$");
return generateChildren(info, path) ?? [];
}
function generateChildren(
info: JSONValueType,
path: JSONHeroPath
): Array<JsonTreeViewNode> | undefined {
if (info.name === "array") {
return info.value.map((item, index) => {
const itemInfo = inferType(item);
const itemPath = path.child(index.toString());
return {
id: itemPath.toString(),
name: index.toString(),
title: index.toString(),
longTitle: `Index ${index.toString()}`,
subtitle: formatValue(itemInfo),
icon: iconForType(itemInfo),
children: generateChildren(itemInfo, itemPath),
};
});
}
if (info.name === "object") {
return Object.entries(info.value).map(([key, value]) => {
const itemInfo = inferType(value);
const itemPath = path.child(key);
return {
id: itemPath.toString(),
name: key,
title: key,
subtitle: formatValue(itemInfo),
icon: iconForType(itemInfo),
children: generateChildren(itemInfo, itemPath),
};
});
}
}
+755
View File
@@ -0,0 +1,755 @@
import pick from "lodash-es/pick";
import React, {
useReducer,
Reducer,
useCallback,
Dispatch,
useEffect,
useRef,
} from "react";
import { useVirtual, VirtualItem } from "react-virtual";
type UseVirtualOptions<R> = Parameters<typeof useVirtual>[0];
export type UseVirtualTreeOptions<
T extends { id: string; children?: T[] },
R
> = {
id: string;
persistState?: boolean;
nodes: T[];
} & Omit<UseVirtualOptions<R>, "size">;
export type VirtualNode<T> = {
node: T;
size: number; // This is the same as virtualItem.size
start: number; // This is the same as virtualItem.start
virtualItem: VirtualItem;
depth: number;
getItemProps: () => React.HTMLAttributes<HTMLElement>;
isCollapsed?: boolean;
};
export type UseVirtualTreeInstance<T> = {
nodes: VirtualNode<T>[];
focusedNodeId: string | null;
totalSize: number;
toggleNode: (id: string, source?: KeyboardEvent | MouseEvent) => void;
focusNode: (id: string) => void;
focusFirst: () => void;
blur: () => void;
scrollToNode: (id: string) => void;
getTreeProps: () => React.HTMLAttributes<HTMLElement>;
};
type TreeNodeItem<T extends { id: string; children?: T[] }> = {
id: string;
depth: number;
node: T;
pos: number;
size: number;
isCollapsed: boolean;
};
type TreeState<T extends { id: string; children?: T[] }> = {
nodes: T[];
items: TreeNodeItem<T>[];
collapsedState: Record<string, boolean>;
focusedNodeId: string | null;
};
type ToggleNodeAction = {
type: "TOGGLE_NODE";
id: string;
source?: KeyboardEvent | MouseEvent;
};
type FocusNodeAction = {
type: "FOCUS_NODE";
id: string;
};
type MoveNodeAction = {
type: "MOVE_DOWN" | "MOVE_UP" | "MOVE_TO_TOP" | "MOVE_TO_BOTTOM";
source: KeyboardEvent | MouseEvent;
};
type MoveRightAction = {
type: "MOVE_RIGHT";
source: KeyboardEvent | MouseEvent;
};
type MoveLeftAction = {
type: "MOVE_LEFT";
source: KeyboardEvent | MouseEvent;
};
type FocusFirstAction = {
type: "FOCUS_FIRST";
};
type RestoreStateAction = {
type: "RESTORE_STATE";
restoredState: { collapsedState: Record<string, boolean> };
};
type ExpandAllOnPathAction = {
type: "EXPAND_ALL_ON_PATH";
path: string[];
};
type BlurAction = {
type: "BLUR";
};
type TreeAction =
| ToggleNodeAction
| MoveNodeAction
| FocusNodeAction
| FocusFirstAction
| MoveRightAction
| MoveLeftAction
| RestoreStateAction
| ExpandAllOnPathAction
| BlurAction;
function expandNode<T extends { id: string; children?: T[] }>(
state: TreeState<T>,
id: string
): TreeState<T> {
const collapsedState = {
...state.collapsedState,
[id]: false,
};
return {
...state,
collapsedState,
items: createNodeItems(state.nodes, 0, collapsedState),
focusedNodeId: id,
};
}
function collapseNode<T extends { id: string; children?: T[] }>(
state: TreeState<T>,
id: string
): TreeState<T> {
const collapsedState = {
...state.collapsedState,
[id]: true,
};
return {
...state,
collapsedState,
items: createNodeItems(state.nodes, 0, collapsedState),
focusedNodeId: id,
};
}
function toggleAllChildren<T extends { id: string; children?: T[] }>(
state: TreeState<T>,
id: string
): TreeState<T> {
const item = state.items.find(({ id: nodeId }) => nodeId === id);
if (!item) {
return state;
}
if (!item.node.children || item.node.children.length === 0) {
return state;
}
const allCollapsed = item.node.children.every(
(child) => state.collapsedState[child.id]
);
if (allCollapsed) {
const collapsedState = item.node.children.reduce(
(acc, child) => ({
...acc,
[child.id]: false,
}),
state.collapsedState
);
return {
...state,
collapsedState,
items: createNodeItems(state.nodes, 0, collapsedState),
focusedNodeId: id,
};
}
const collapsedState = item.node.children.reduce(
(acc, child) => ({
...acc,
[child.id]: true,
}),
state.collapsedState
);
return {
...state,
collapsedState,
items: createNodeItems(state.nodes, 0, collapsedState),
focusedNodeId: id,
};
}
export function useVirtualTree<T extends { id: string; children?: T[] }, R>(
options: UseVirtualTreeOptions<T, R>
): UseVirtualTreeInstance<T> {
const reducer = useCallback<Reducer<TreeState<T>, TreeAction>>(
(state, action) => {
switch (action.type) {
case "BLUR": {
return {
...state,
focusedNodeId: null,
};
}
case "TOGGLE_NODE": {
const isCollapsed = state.collapsedState[action.id];
if (isCollapsed) {
return expandNode<T>(state, action.id);
} else {
if (
action.source &&
(action.source.shiftKey || action.source.altKey)
) {
return toggleAllChildren<T>(state, action.id);
} else {
return collapseNode<T>(state, action.id);
}
}
}
case "FOCUS_NODE": {
const itemIndex = state.items.findIndex(({ id }) => id === action.id);
if (itemIndex === -1) {
const node = findNodeInTreeById(state.nodes, action.id);
if (!node) {
return state;
}
const path = calculatePathToNode(state.nodes, node) ?? [];
const collapsedState = path.reduce(
(acc, id) => ({
...acc,
[id]: false,
}),
state.collapsedState
);
return {
...state,
collapsedState,
items: createNodeItems(state.nodes, 0, collapsedState),
focusedNodeId: action.id,
};
}
return {
...state,
focusedNodeId: action.id,
};
}
case "FOCUS_FIRST":
case "MOVE_TO_TOP": {
const nextItem = state.items[0];
if (!nextItem) {
return state;
}
return {
...state,
focusedNodeId: nextItem.id,
};
}
case "MOVE_TO_BOTTOM": {
const nextItem = state.items[state.items.length - 1];
if (!nextItem) {
return state;
}
return {
...state,
focusedNodeId: nextItem.id,
};
}
case "MOVE_DOWN": {
if (!state.focusedNodeId) {
const nextItem = state.items[0];
if (!nextItem) {
return state;
}
return {
...state,
focusedNodeId: nextItem.id,
};
}
const focusedNodeIdIndex = state.items.findIndex(
(item) => item.id === state.focusedNodeId
);
if (focusedNodeIdIndex === -1) {
return state;
}
if (state.items.length <= focusedNodeIdIndex + 1) {
return state;
}
const nextItem = state.items[focusedNodeIdIndex + 1];
return {
...state,
focusedNodeId: nextItem.id,
};
}
case "MOVE_UP": {
const focusedNodeIdIndex = state.items.findIndex(
(item) => item.id === state.focusedNodeId
);
if (focusedNodeIdIndex === -1) {
return state;
}
if (focusedNodeIdIndex === 0) {
return state;
}
const nextItem = state.items[focusedNodeIdIndex - 1];
return {
...state,
focusedNodeId: nextItem.id,
};
}
case "MOVE_RIGHT": {
if (!state.focusedNodeId) {
return state;
}
const isCollapsed = state.collapsedState[state.focusedNodeId];
if (isCollapsed) {
return expandNode<T>(state, state.focusedNodeId);
}
if (
action.source &&
(action.source.shiftKey || action.source.altKey)
) {
return toggleAllChildren<T>(state, state.focusedNodeId);
}
const nodeIndex = state.items.findIndex(
(item) => item.id === state.focusedNodeId
);
if (nodeIndex === -1) {
return state;
}
if (state.items.length <= nodeIndex + 1) {
return state;
}
const nextItem = state.items[nodeIndex + 1];
return {
...state,
focusedNodeId: nextItem.id,
};
}
case "MOVE_LEFT": {
if (!state.focusedNodeId) {
return state;
}
const item = state.items.find(
(item) => item.id === state.focusedNodeId
);
if (!item) {
return state;
}
const hasChildren =
item.node.children && item.node.children.length > 0;
const isCollapsed = state.collapsedState[state.focusedNodeId];
if (hasChildren && !isCollapsed) {
if (
action.source &&
(action.source.shiftKey || action.source.altKey)
) {
return toggleAllChildren<T>(state, state.focusedNodeId);
} else {
return collapseNode<T>(state, state.focusedNodeId);
}
}
if (!hasChildren || isCollapsed) {
// Try to go to the parent node
const parentNodeIndex = state.items.findIndex(
(item) =>
item.node.children &&
item.node.children
.map((child) => child.id)
.includes(state.focusedNodeId!)
);
if (parentNodeIndex === -1) {
return state;
}
const nextItem = state.items[parentNodeIndex];
return {
...state,
focusedNodeId: nextItem.id,
};
}
return state;
}
case "RESTORE_STATE": {
const nextState = {
...state,
...action.restoredState,
};
return {
...nextState,
items: createNodeItems(
nextState.nodes,
0,
nextState.collapsedState
),
};
}
default:
return state;
}
},
[]
);
const initializer = useCallback(
({ nodes }: { nodes: T[] }) => {
return {
nodes,
items: createNodeItems(nodes),
collapsedState: {},
focusedNodeId: null,
};
},
[options.persistState, options.id]
);
const [state, dispatch] = useReducer<
Reducer<TreeState<T>, TreeAction>,
{ nodes: T[] }
>(
reducer,
{
nodes: options.nodes,
},
initializer
);
const isStateRestored = useRef<boolean>(false);
// This is setting the state
useEffect(() => {
if (!isStateRestored.current) {
return;
}
if (options.persistState) {
localStorage.setItem(
`${options.id}-virtual-tree-state`,
JSON.stringify(pick(state, "collapsedState"))
);
}
}, [
state.collapsedState,
options.id,
options.persistState,
isStateRestored.current,
]);
// This is restoring the state
useEffect(() => {
if (!options.persistState) {
return;
}
if (isStateRestored.current) {
return;
}
isStateRestored.current = true;
const savedState = localStorage.getItem(`${options.id}-virtual-tree-state`);
if (savedState) {
const restoredState = JSON.parse(savedState) as {
collapsedState: Record<string, boolean>;
};
dispatch({
type: "RESTORE_STATE",
restoredState,
});
}
}, [options.persistState, options.id, dispatch, isStateRestored.current]);
const rowVirtualizer = useVirtual({
size: state.items.length,
parentRef: options.parentRef,
estimateSize: options.estimateSize,
overscan: options.overscan,
initialRect: options.initialRect,
useObserver: options.useObserver,
});
const allVirtualNodes = rowVirtualizer.virtualItems.map((virtualItem) => {
const treeItem = state.items[virtualItem.index];
return {
node: treeItem.node,
depth: treeItem.depth,
size: virtualItem.size,
start: virtualItem.start,
virtualItem,
getItemProps: createItemProps(treeItem, virtualItem, state, dispatch),
isCollapsed: treeItem.isCollapsed,
};
});
const toggleNode = useCallback(
(id: string, source?: KeyboardEvent | MouseEvent) => {
dispatch({ type: "TOGGLE_NODE", id, source });
},
[dispatch]
);
const focusNode = useCallback(
(id: string) => {
dispatch({ type: "FOCUS_NODE", id });
},
[dispatch]
);
const focusFirst = useCallback(
() => dispatch({ type: "FOCUS_FIRST" }),
[dispatch]
);
const blur = useCallback(() => dispatch({ type: "BLUR" }), [dispatch]);
// TODO: have this work with collapsed nodes
const scrollToNode = useCallback(
(id: string) => {
const itemIndex = state.items.findIndex((item) => item.id === id);
if (itemIndex !== -1) {
rowVirtualizer.scrollToIndex(itemIndex, { align: "auto" });
}
},
[state.items, rowVirtualizer.scrollToIndex, dispatch]
);
useEffect(() => {
if (state.focusedNodeId) {
scrollToNode(state.focusedNodeId);
}
}, [state.focusedNodeId, scrollToNode]);
return {
nodes: allVirtualNodes,
totalSize: rowVirtualizer.totalSize,
toggleNode,
focusNode,
focusFirst,
blur,
focusedNodeId: state.focusedNodeId,
getTreeProps: useCallback(createTreeProps(dispatch), [dispatch]),
scrollToNode,
};
}
function createNodeItems<T extends { id: string; children?: T[] }>(
nodes: T[],
depth = 0,
collapsedState: Record<string, boolean> = {}
): TreeNodeItem<T>[] {
return nodes.flatMap((node, index) => {
const children = node.children
? collapsedState[node.id]
? []
: createNodeItems(node.children, depth + 1, collapsedState)
: [];
return [
{
id: node.id,
depth,
node,
pos: index + 1,
size: nodes.length,
isCollapsed: !!collapsedState[node.id],
},
...children,
];
});
}
function createTreeProps<T extends { id: string; children?: T[] }>(
dispatch: Dispatch<TreeAction>
): () => React.HTMLAttributes<HTMLElement> {
return () => ({
role: "tree",
tabIndex: -1,
onKeyDown: (e) => {
if (e.defaultPrevented) {
return; // Do nothing if the event was already processed
}
switch (e.key) {
case "Home": {
dispatch({ type: "MOVE_TO_TOP", source: e.nativeEvent });
e.preventDefault();
break;
}
case "End": {
dispatch({ type: "MOVE_TO_BOTTOM", source: e.nativeEvent });
e.preventDefault();
break;
}
case "Down":
case "ArrowDown": {
dispatch({ type: "MOVE_DOWN", source: e.nativeEvent });
e.preventDefault();
break;
}
case "Up":
case "ArrowUp": {
dispatch({ type: "MOVE_UP", source: e.nativeEvent });
e.preventDefault();
break;
}
case "Left":
case "ArrowLeft": {
dispatch({
type: "MOVE_LEFT",
source: e.nativeEvent,
});
e.preventDefault();
break;
}
case "Right":
case "ArrowRight": {
dispatch({
type: "MOVE_RIGHT",
source: e.nativeEvent,
});
e.preventDefault();
break;
}
}
},
});
}
function createItemProps<T extends { id: string; children?: T[] }>(
item: TreeNodeItem<T>,
virtualItem: VirtualItem,
state: TreeState<T>,
dispatch: Dispatch<TreeAction>
): () => React.HTMLAttributes<HTMLElement> {
const { depth, pos, size, node, isCollapsed } = item;
return () => ({
"aria-expanded": node.children && node.children.length > 0 && !isCollapsed,
"aria-level": depth + 1,
"aria-posinset": pos,
"aria-setsize": size,
role: "treeitem",
tabIndex: node.id === state.focusedNodeId ? -1 : undefined,
onClick: (e) => {
if (e.defaultPrevented) {
return; // Do nothing if the event was already processed
}
if (node.id !== state.focusedNodeId) {
dispatch({ type: "FOCUS_NODE", id: node.id });
}
},
});
}
// Finds the node in the list of nodes or recursively in the children
function findNodeInTreeById<T extends { id: string; children?: T[] }>(
nodes: T[],
id: string
): T | undefined {
const node = nodes.find((node) => node.id === id);
if (node) {
return node;
}
for (const node of nodes) {
const foundNode = findNodeInTreeById(node.children || [], id);
if (foundNode) {
return foundNode;
}
}
return;
}
function calculatePathToNode<T extends { id: string; children?: T[] }>(
nodes: T[],
searchNode: T,
path: string[] = []
): string[] | undefined {
const nodeIndex = nodes.findIndex((node) => node.id === searchNode.id);
if (nodeIndex !== -1) {
return [...path, searchNode.id];
}
for (const node of nodes) {
if (!node.children) {
continue;
}
const foundPath = calculatePathToNode(node.children || [], searchNode, [
...path,
node.id,
]);
if (foundPath && foundPath.length > path.length) {
return foundPath;
}
}
return;
}
+3
View File
@@ -20,6 +20,7 @@ import { JsonColumnViewProvider } from "~/hooks/useJsonColumnView";
import { JsonSchemaProvider } from "~/hooks/useJsonSchema";
import { JsonView } from "~/components/JsonView";
import safeFetch from "~/utilities/safeFetch";
import { JsonTreeViewProvider } from "~/hooks/useJsonTree";
export const loader: LoaderFunction = async ({ params, request }) => {
invariant(params.id, "expected params.id");
@@ -113,6 +114,7 @@ export default function JsonDocumentRoute() {
<JsonProvider initialJson={loaderData.json}>
<JsonSchemaProvider>
<JsonColumnViewProvider>
<JsonTreeViewProvider overscan={25}>
<div>
<div className="h-screen flex flex-col">
<Header />
@@ -139,6 +141,7 @@ export default function JsonDocumentRoute() {
<Footer></Footer>
</div>
</div>
</JsonTreeViewProvider>
</JsonColumnViewProvider>
</JsonSchemaProvider>
</JsonProvider>
-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 />;
}
+21 -7
View File
@@ -40,6 +40,7 @@ export type ColumnViewInstanceState = {
columns: Array<ColumnDefinition>;
getColumnViewProps: () => ColumnViewProps;
selectedNodeId?: string;
selectedNodeSource?: string;
selectedPath: string[];
highlightedNodeId?: string;
highlightedPath: string[];
@@ -55,7 +56,7 @@ export type ColumnViewAPIOptions = {
export type ColumnViewAPI = {
goBack: () => void;
goForward: () => void;
goToNodeId: (nodeId: string) => void;
goToNodeId: (nodeId: string, source: string) => void;
goToParent: (options?: ColumnViewAPIOptions) => void;
goToChildren: () => void;
goToNextSibling: () => void;
@@ -158,8 +159,8 @@ export function useColumnView({
goForward: () => {
dispatch(goForwardAction());
},
goToNodeId: (nodeId: string) => {
dispatch(goToNodeIdAction(nodeId));
goToNodeId: (nodeId: string, source: string) => {
dispatch(goToNodeIdAction(nodeId, source));
},
goToParent: (options?: ColumnViewAPIOptions) => {
dispatch(goToParentAction(options));
@@ -177,10 +178,15 @@ export function useColumnView({
dispatch(resetSelectionAction());
},
};
}, []);
}, [dispatch]);
const { selectedNodeId, highlightedNodeId, history, historyCurrentIndex } =
state;
const {
selectedNodeId,
highlightedNodeId,
selectedNodeSource,
history,
historyCurrentIndex,
} = state;
const selectedPath = getPathToNode(nodeTable, selectedNodeId);
const highlightedPath = getPathToNode(nodeTable, highlightedNodeId);
@@ -209,6 +215,7 @@ export function useColumnView({
return {
state: {
selectedNodeId,
selectedNodeSource,
selectedPath,
selectedNodes,
highlightedNodeId,
@@ -225,6 +232,7 @@ export function useColumnView({
export type ColumnViewState = {
selectedNodeId?: string;
highlightedNodeId?: string;
selectedNodeSource?: string;
history: Array<Omit<ColumnViewState, "history" | "historyCurrentIndex">>;
historyCurrentIndex: number;
nodeTable: NodeTable;
@@ -234,6 +242,7 @@ export type ColumnViewState = {
export type SetSelectedNodeIdAction = {
type: "SET_SELECTED_NODE_ID";
id: string;
source: string;
};
export type MoveSelectedNodeAction = {
@@ -276,10 +285,14 @@ function resetSelectionAction(): ResetSelectionNodeAction {
};
}
function goToNodeIdAction(nodeId: string): SetSelectedNodeIdAction {
function goToNodeIdAction(
nodeId: string,
source: string
): SetSelectedNodeIdAction {
return {
type: "SET_SELECTED_NODE_ID",
id: nodeId,
source,
};
}
@@ -339,6 +352,7 @@ function columnViewReducer(
...state,
selectedNodeId: action.id,
highlightedNodeId: action.id,
selectedNodeSource: action.source,
};
case "MOVE_DOWN": {
if (state.highlightedNodeId === state.rootNodeId) {
+6 -2
View File
@@ -31,15 +31,19 @@ export function formatValue(type: JSONValueType): string | undefined {
case "array": {
if (type.value.length == 0) {
return formatRawValue(type);
} else if (type.value.length === 1) {
return `1 item`;
} else {
return undefined;
return `${type.value.length} items`;
}
}
case "object": {
if (Object.keys(type.value).length == 0) {
return formatRawValue(type);
} else if (Object.keys(type.value).length === 1) {
return `1 field`;
} else {
return undefined;
return `${Object.keys(type.value).length} fields`;
}
}
case "bool": {
+1 -1
View File
@@ -119,7 +119,7 @@ export function generateNodesToPath(
return nodes;
}
function iconForType(type: JSONValueType): IconComponent {
export function iconForType(type: JSONValueType): IconComponent {
switch (type.name) {
case "object": {
return CubeIcon;
+33 -18
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",
@@ -41,8 +42,6 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^2.2.2",
"@esbuild-plugins/node-globals-polyfill": "^0.1.1",
"@esbuild-plugins/node-modules-polyfill": "^0.1.4",
"@remix-run/dev": "^1.2.3",
"@tailwindcss/forms": "^0.4.0",
"@types/color": "^3.0.3",
@@ -924,15 +923,6 @@
"integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
"optional": true
},
"node_modules/@esbuild-plugins/node-globals-polyfill": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.1.1.tgz",
"integrity": "sha512-MR0oAA+mlnJWrt1RQVQ+4VYuRJW/P2YmRTv1AsplObyvuBMnPHiizUF95HHYiSsMGLhyGtWufaq2XQg6+iurBg==",
"dev": true,
"peerDependencies": {
"esbuild": "*"
}
},
"node_modules/@esbuild-plugins/node-modules-polyfill": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.1.4.tgz",
@@ -2436,6 +2426,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 +11403,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",
@@ -14173,13 +14182,6 @@
"integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
"optional": true
},
"@esbuild-plugins/node-globals-polyfill": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.1.1.tgz",
"integrity": "sha512-MR0oAA+mlnJWrt1RQVQ+4VYuRJW/P2YmRTv1AsplObyvuBMnPHiizUF95HHYiSsMGLhyGtWufaq2XQg6+iurBg==",
"dev": true,
"requires": {}
},
"@esbuild-plugins/node-modules-polyfill": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.1.4.tgz",
@@ -15333,6 +15335,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 +21876,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",
+1 -2
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",
@@ -58,8 +59,6 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^2.2.2",
"@esbuild-plugins/node-globals-polyfill": "^0.1.1",
"@esbuild-plugins/node-modules-polyfill": "^0.1.4",
"@remix-run/dev": "^1.2.3",
"@tailwindcss/forms": "^0.4.0",
"@types/color": "^3.0.3",
+2
View File
@@ -13,6 +13,8 @@
"paths": {
"~/*": ["./app/*"]
},
"skipDefaultLibCheck": true,
"skipLibCheck": true,
// Remix takes care of building everything in `remix build`.
"noEmit": true