chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,645 @@
|
||||
import type { VirtualItem, Virtualizer } from "@tanstack/react-virtual";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { motion } from "framer-motion";
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import type { NodeState, NodesState } from "./reducer";
|
||||
import { reducer } from "./reducer";
|
||||
import { concreteStateFromInput, selectedIdFromState } from "./utils";
|
||||
|
||||
export type TreeViewProps<TData> = {
|
||||
tree: FlatTree<TData>;
|
||||
parentClassName?: string;
|
||||
renderNode: (params: {
|
||||
node: FlatTreeItem<TData>;
|
||||
state: NodeState;
|
||||
index: number;
|
||||
virtualizer: Virtualizer<HTMLElement, Element>;
|
||||
virtualItem: VirtualItem;
|
||||
}) => React.ReactNode;
|
||||
nodes: UseTreeStateOutput["nodes"];
|
||||
autoFocus?: boolean;
|
||||
virtualizer: Virtualizer<HTMLElement, Element>;
|
||||
parentRef?: MutableRefObject<HTMLElement | null>;
|
||||
scrollRef?: MutableRefObject<HTMLElement | null>;
|
||||
onScroll?: (scrollTop: number) => void;
|
||||
} & Pick<UseTreeStateOutput, "getTreeProps" | "getNodeProps">;
|
||||
|
||||
export type GetTreePropsFn = UseTreeStateOutput["getTreeProps"];
|
||||
export type GetNodePropsFn = UseTreeStateOutput["getNodeProps"];
|
||||
|
||||
export function TreeView<TData>({
|
||||
tree,
|
||||
renderNode,
|
||||
nodes,
|
||||
autoFocus = false,
|
||||
getTreeProps,
|
||||
getNodeProps,
|
||||
parentClassName,
|
||||
virtualizer,
|
||||
parentRef,
|
||||
scrollRef,
|
||||
onScroll,
|
||||
}: TreeViewProps<TData>) {
|
||||
useEffect(() => {
|
||||
if (autoFocus) {
|
||||
parentRef?.current?.focus();
|
||||
}
|
||||
}, [autoFocus, parentRef?.current]);
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
// id -> node lookup so each virtual row resolves in O(1) instead of
|
||||
// scanning the whole tree per row.
|
||||
const nodesById = useMemo(() => {
|
||||
const map = new Map<string, FlatTreeItem<TData>>();
|
||||
for (const node of tree) {
|
||||
map.set(node.id, node);
|
||||
}
|
||||
return map;
|
||||
}, [tree]);
|
||||
|
||||
const scrollCallback = useCallback(
|
||||
(event: Event) => {
|
||||
if (!onScroll) return;
|
||||
const target = event.target as HTMLElement;
|
||||
onScroll?.(target.scrollTop);
|
||||
},
|
||||
[onScroll]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
//subscribe to scrollRef scroll event
|
||||
if (!scrollRef?.current || onScroll === undefined) return;
|
||||
scrollRef.current.addEventListener("scroll", scrollCallback);
|
||||
return () => scrollRef.current?.removeEventListener("scroll", scrollCallback);
|
||||
}, [scrollRef?.current]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={(element) => {
|
||||
if (parentRef) {
|
||||
parentRef.current = element;
|
||||
}
|
||||
if (scrollRef) {
|
||||
scrollRef.current = element;
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control focus-within:outline-hidden",
|
||||
parentClassName
|
||||
)}
|
||||
layoutScroll
|
||||
{...getTreeProps()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
overflowY: "visible",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
overflowY: "visible",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualItems.at(0)?.start ?? 0}px)`,
|
||||
}}
|
||||
>
|
||||
{virtualItems.map((virtualItem) => {
|
||||
const node = nodesById.get(virtualItem.key as string);
|
||||
if (!node) return null;
|
||||
const state = nodes[node.id];
|
||||
if (!state) return null;
|
||||
if (!state.visible) return null;
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="overflow-clip"
|
||||
{...getNodeProps(node.id)}
|
||||
>
|
||||
{renderNode({
|
||||
node,
|
||||
state,
|
||||
index: virtualItem.index,
|
||||
virtualizer: virtualizer,
|
||||
virtualItem,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export type Filter<TData, TFilterValue> = {
|
||||
value?: TFilterValue;
|
||||
fn: (value: TFilterValue, node: FlatTreeItem<TData>) => boolean;
|
||||
};
|
||||
|
||||
type TreeStateHookProps<TData, TFilterValue> = {
|
||||
tree: FlatTree<TData>;
|
||||
selectedId?: string;
|
||||
collapsedIds?: string[];
|
||||
onSelectedIdChanged?: (selectedId: string | undefined) => void;
|
||||
estimatedRowHeight: (params: {
|
||||
node: FlatTreeItem<TData>;
|
||||
state: NodeState;
|
||||
index: number;
|
||||
}) => number;
|
||||
parentRef: RefObject<any>;
|
||||
filter?: Filter<TData, TFilterValue>;
|
||||
};
|
||||
|
||||
//this is so Framer Motion can be used to render the components
|
||||
type HTMLAttributes = Omit<
|
||||
React.HTMLAttributes<HTMLElement>,
|
||||
"onAnimationStart" | "onDragStart" | "onDragEnd" | "onDrag"
|
||||
>;
|
||||
|
||||
export type UseTreeStateOutput = {
|
||||
selected: string | undefined;
|
||||
nodes: NodesState;
|
||||
virtualizer: Virtualizer<HTMLElement, Element>;
|
||||
|
||||
getTreeProps: () => HTMLAttributes;
|
||||
getNodeProps: (id: string) => HTMLAttributes;
|
||||
selectNode: (id: string, scrollToNode?: boolean) => void;
|
||||
deselectNode: (id: string) => void;
|
||||
deselectAllNodes: () => void;
|
||||
toggleNodeSelection: (id: string, scrollToNode?: boolean) => void;
|
||||
expandNode: (id: string, scrollToNode?: boolean) => void;
|
||||
collapseNode: (id: string) => void;
|
||||
toggleExpandNode: (id: string, scrollToNode?: boolean) => void;
|
||||
expandAllBelowDepth: (depth: number) => void;
|
||||
collapseAllBelowDepth: (depth: number) => void;
|
||||
expandLevel: (level: number) => void;
|
||||
collapseLevel: (level: number) => void;
|
||||
toggleExpandLevel: (level: number) => void;
|
||||
selectFirstVisibleNode: (scrollToNode?: boolean) => void;
|
||||
selectLastVisibleNode: (scrollToNode?: boolean) => void;
|
||||
selectNextVisibleNode: (scrollToNode?: boolean) => void;
|
||||
selectPreviousVisibleNode: (scrollToNode?: boolean) => void;
|
||||
selectParentNode: (scrollToNode?: boolean) => void;
|
||||
scrollToNode: (id: string) => void;
|
||||
};
|
||||
|
||||
export function useTree<TData, TFilterValue>({
|
||||
tree,
|
||||
selectedId,
|
||||
collapsedIds,
|
||||
onSelectedIdChanged,
|
||||
parentRef,
|
||||
estimatedRowHeight,
|
||||
filter,
|
||||
}: TreeStateHookProps<TData, TFilterValue>): UseTreeStateOutput {
|
||||
const previousNodeCount = useRef(tree.length);
|
||||
const previousSelectedId = useRef<string | undefined>(selectedId);
|
||||
|
||||
const [state, dispatch] = useReducer(
|
||||
reducer,
|
||||
concreteStateFromInput({ tree, selectedId, collapsedIds, filter })
|
||||
);
|
||||
|
||||
// id -> index lookup so getNodeProps resolves in O(1) instead of scanning
|
||||
// the whole tree per rendered row.
|
||||
const treeIndexById = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
tree.forEach((node, index) => {
|
||||
map.set(node.id, index);
|
||||
});
|
||||
return map;
|
||||
}, [tree]);
|
||||
|
||||
//sync external selectedId prop into internal state
|
||||
useEffect(() => {
|
||||
const internalSelectedId = selectedIdFromState(state.nodes);
|
||||
if (selectedId !== internalSelectedId) {
|
||||
if (selectedId === undefined) {
|
||||
dispatch({ type: "DESELECT_ALL_NODES" });
|
||||
} else {
|
||||
dispatch({
|
||||
type: "SELECT_NODE",
|
||||
payload: { id: selectedId, scrollToNode: false, scrollToNodeFn },
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedId]);
|
||||
|
||||
//fire onSelectedIdChanged()
|
||||
useEffect(() => {
|
||||
const selectedId = selectedIdFromState(state.nodes);
|
||||
if (selectedId !== previousSelectedId.current) {
|
||||
previousSelectedId.current = selectedId;
|
||||
onSelectedIdChanged?.(selectedId);
|
||||
}
|
||||
}, [state.changes.selectedId]);
|
||||
|
||||
//update tree when the number of nodes changes
|
||||
useEffect(() => {
|
||||
if (tree.length !== previousNodeCount.current) {
|
||||
previousNodeCount.current = tree.length;
|
||||
dispatch({ type: "UPDATE_TREE", payload: { tree } });
|
||||
}
|
||||
}, [previousNodeCount.current, tree.length]);
|
||||
|
||||
//update the filter, if it's changed
|
||||
const previousFilter = useRef(filter);
|
||||
useEffect(() => {
|
||||
//check if the value (not reference) of the filter is the same
|
||||
const previousValue = previousFilter.current
|
||||
? JSON.stringify(previousFilter.current.value)
|
||||
: undefined;
|
||||
const newValue = filter ? JSON.stringify(filter.value) : undefined;
|
||||
|
||||
previousFilter.current = filter;
|
||||
|
||||
if (previousValue !== newValue) {
|
||||
dispatch({ type: "UPDATE_FILTER", payload: { filter } });
|
||||
}
|
||||
}, [filter?.value]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: state.visibleNodeIds.length,
|
||||
getItemKey: (index) => state.visibleNodeIds[index],
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: (index: number) => {
|
||||
const treeItem = tree[index];
|
||||
if (!treeItem) return 0;
|
||||
return estimatedRowHeight({
|
||||
node: treeItem,
|
||||
state: state.nodes[treeItem.id],
|
||||
index,
|
||||
});
|
||||
},
|
||||
overscan: 50,
|
||||
});
|
||||
|
||||
const scrollToNodeFn = useCallback(
|
||||
(id: string) => {
|
||||
const itemIndex = state.visibleNodeIds.findIndex((n) => n === id);
|
||||
|
||||
if (itemIndex !== -1) {
|
||||
virtualizer.scrollToIndex(itemIndex, { align: "auto" });
|
||||
}
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const selectNode = useCallback(
|
||||
(id: string, scrollToNode = true) => {
|
||||
dispatch({ type: "SELECT_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const deselectNode = useCallback(
|
||||
(id: string) => {
|
||||
dispatch({ type: "DESELECT_NODE", payload: { id } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const deselectAllNodes = useCallback(() => {
|
||||
dispatch({ type: "DESELECT_ALL_NODES" });
|
||||
}, [state]);
|
||||
|
||||
const toggleNodeSelection = useCallback(
|
||||
(id: string, scrollToNode = true) => {
|
||||
dispatch({ type: "TOGGLE_NODE_SELECTION", payload: { id, scrollToNode, scrollToNodeFn } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const expandNode = useCallback(
|
||||
(id: string, scrollToNode = true) => {
|
||||
dispatch({ type: "EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const collapseNode = useCallback(
|
||||
(id: string) => {
|
||||
dispatch({ type: "COLLAPSE_NODE", payload: { id } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const toggleExpandNode = useCallback(
|
||||
(id: string, scrollToNode = true) => {
|
||||
dispatch({ type: "TOGGLE_EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const selectFirstVisibleNode = useCallback(
|
||||
(scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_FIRST_VISIBLE_NODE",
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
},
|
||||
[tree, state]
|
||||
);
|
||||
|
||||
const selectLastVisibleNode = useCallback(
|
||||
(scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_LAST_VISIBLE_NODE",
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
},
|
||||
[tree, state]
|
||||
);
|
||||
|
||||
const selectNextVisibleNode = useCallback(
|
||||
(scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_NEXT_VISIBLE_NODE",
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const selectPreviousVisibleNode = useCallback(
|
||||
(scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_PREVIOUS_VISIBLE_NODE",
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const selectParentNode = useCallback(
|
||||
(scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_PARENT_NODE",
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const expandAllBelowDepth = useCallback(
|
||||
(depth: number) => {
|
||||
dispatch({ type: "EXPAND_ALL_BELOW_DEPTH", payload: { depth } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const collapseAllBelowDepth = useCallback(
|
||||
(depth: number) => {
|
||||
dispatch({ type: "COLLAPSE_ALL_BELOW_DEPTH", payload: { depth } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const expandLevel = useCallback(
|
||||
(level: number) => {
|
||||
dispatch({ type: "EXPAND_LEVEL", payload: { level } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const collapseLevel = useCallback(
|
||||
(level: number) => {
|
||||
dispatch({ type: "COLLAPSE_LEVEL", payload: { level } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const toggleExpandLevel = useCallback(
|
||||
(level: number) => {
|
||||
dispatch({ type: "TOGGLE_EXPAND_LEVEL", payload: { level } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const getTreeProps = useCallback(() => {
|
||||
return {
|
||||
role: "tree",
|
||||
"aria-multiselectable": true,
|
||||
tabIndex: -1,
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (e.defaultPrevented) {
|
||||
return; // Do nothing if the event was already processed
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "Home": {
|
||||
selectFirstVisibleNode(true);
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
case "End": {
|
||||
selectLastVisibleNode(true);
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
case "Down":
|
||||
case "ArrowDown": {
|
||||
selectNextVisibleNode(true);
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
case "Up":
|
||||
case "ArrowUp": {
|
||||
selectPreviousVisibleNode(true);
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
case "Left":
|
||||
case "ArrowLeft": {
|
||||
if (e.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const selected = selectedIdFromState(state.nodes);
|
||||
if (selected) {
|
||||
const treeNode = tree.find((node) => node.id === selected);
|
||||
|
||||
if (e.altKey) {
|
||||
if (treeNode && treeNode.hasChildren) {
|
||||
collapseLevel(treeNode.level);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const shouldCollapse =
|
||||
treeNode && treeNode.hasChildren && state.nodes[selected].expanded;
|
||||
if (shouldCollapse) {
|
||||
collapseNode(selected);
|
||||
} else {
|
||||
selectParentNode(true);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "Right":
|
||||
case "ArrowRight": {
|
||||
e.preventDefault();
|
||||
|
||||
const selected = selectedIdFromState(state.nodes);
|
||||
|
||||
if (selected) {
|
||||
const treeNode = tree.find((node) => node.id === selected);
|
||||
|
||||
if (e.altKey) {
|
||||
if (treeNode && treeNode.hasChildren) {
|
||||
expandLevel(treeNode.level);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
expandNode(selected, true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Escape": {
|
||||
deselectAllNodes();
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
const getNodeProps = useCallback(
|
||||
(id: string) => {
|
||||
const node = state.nodes[id];
|
||||
if (!node) return {};
|
||||
const treeItemIndex = treeIndexById.get(id) ?? -1;
|
||||
const treeItem = tree[treeItemIndex];
|
||||
return {
|
||||
"aria-expanded": node.expanded,
|
||||
"aria-level": treeItem.level + 1,
|
||||
role: "treeitem",
|
||||
tabIndex: node.selected ? -1 : undefined,
|
||||
};
|
||||
},
|
||||
[state, treeIndexById]
|
||||
);
|
||||
|
||||
return {
|
||||
selected: selectedIdFromState(state.nodes),
|
||||
nodes: state.nodes,
|
||||
getTreeProps,
|
||||
getNodeProps,
|
||||
selectNode,
|
||||
deselectNode,
|
||||
deselectAllNodes,
|
||||
toggleNodeSelection,
|
||||
expandNode,
|
||||
collapseNode,
|
||||
toggleExpandNode,
|
||||
expandAllBelowDepth,
|
||||
collapseAllBelowDepth,
|
||||
expandLevel,
|
||||
collapseLevel,
|
||||
toggleExpandLevel,
|
||||
selectFirstVisibleNode,
|
||||
selectLastVisibleNode,
|
||||
selectNextVisibleNode,
|
||||
selectPreviousVisibleNode,
|
||||
selectParentNode,
|
||||
scrollToNode: scrollToNodeFn,
|
||||
virtualizer,
|
||||
};
|
||||
}
|
||||
|
||||
/** An actual tree structure with custom data */
|
||||
export type Tree<TData> = {
|
||||
id: string;
|
||||
runId?: string;
|
||||
children?: Tree<TData>[];
|
||||
data: TData;
|
||||
};
|
||||
|
||||
/** A tree but flattened so it can easily be used for DOM elements */
|
||||
export type FlatTreeItem<TData> = {
|
||||
id: string;
|
||||
parentId?: string | undefined;
|
||||
runId?: string;
|
||||
children: string[];
|
||||
hasChildren: boolean;
|
||||
/** The indentation level, the root is 0 */
|
||||
level: number;
|
||||
data: TData;
|
||||
};
|
||||
|
||||
export type FlatTree<TData> = FlatTreeItem<TData>[];
|
||||
|
||||
export function flattenTree<TData>(tree: Tree<TData>): FlatTree<TData> {
|
||||
const flatTree: FlatTree<TData> = [];
|
||||
|
||||
function flattenNode(node: Tree<TData>, parentId: string | undefined, level: number) {
|
||||
const children = node.children?.map((child) => child.id) ?? [];
|
||||
flatTree.push({
|
||||
id: node.id,
|
||||
parentId,
|
||||
runId: node.runId,
|
||||
children,
|
||||
hasChildren: children.length > 0,
|
||||
level,
|
||||
data: node.data,
|
||||
});
|
||||
|
||||
node.children?.forEach((child) => {
|
||||
flattenNode(child, node.id, level + 1);
|
||||
});
|
||||
}
|
||||
|
||||
flattenNode(tree, undefined, 0);
|
||||
|
||||
return flatTree;
|
||||
}
|
||||
|
||||
type FlatTreeWithoutChildren<TData> = {
|
||||
id: string;
|
||||
parentId: string | undefined;
|
||||
runId?: string;
|
||||
data: TData;
|
||||
};
|
||||
|
||||
export function createTreeFromFlatItems<TData>(
|
||||
withoutChildren: FlatTreeWithoutChildren<TData>[],
|
||||
rootId: string
|
||||
): Tree<TData> | undefined {
|
||||
// Index items by id
|
||||
const indexedItems: { [id: string]: Tree<TData> } = withoutChildren.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.id] = { id: item.id, runId: item.runId, data: item.data, children: [] };
|
||||
return acc;
|
||||
},
|
||||
{} as { [id: string]: Tree<TData> }
|
||||
);
|
||||
|
||||
// Add items to parent's children array
|
||||
withoutChildren.forEach((item) => {
|
||||
const indexedItem = indexedItems[item.id];
|
||||
if (item.parentId !== undefined) {
|
||||
const parentItem = indexedItems[item.parentId];
|
||||
if (parentItem) {
|
||||
// If parent ID doesn't exist, this is also a root item
|
||||
parentItem.children?.push(indexedItem);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return indexedItems[rootId];
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
import assertNever from "assert-never";
|
||||
import type { Filter, FlatTree } from "./TreeView";
|
||||
import {
|
||||
applyFilterToState,
|
||||
applyVisibility,
|
||||
collapsedIdsFromState,
|
||||
concreteStateFromInput,
|
||||
firstVisibleNode,
|
||||
generateChanges,
|
||||
lastVisibleNode,
|
||||
selectedIdFromState,
|
||||
visibleNodes,
|
||||
} from "./utils";
|
||||
|
||||
export type NodeState = {
|
||||
selected: boolean;
|
||||
expanded: boolean;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export type Changes = {
|
||||
selectedId: string | undefined;
|
||||
};
|
||||
|
||||
export type TreeState = {
|
||||
tree: FlatTree<any>;
|
||||
nodes: NodesState;
|
||||
filteredNodes: NodesState;
|
||||
changes: Changes;
|
||||
filter: Filter<any, any> | undefined;
|
||||
visibleNodeIds: string[];
|
||||
};
|
||||
|
||||
export type NodesState = Record<string, NodeState>;
|
||||
|
||||
type ScrollToNodeFn = (id: string) => void;
|
||||
|
||||
type WithScrollToNode = {
|
||||
scrollToNode: boolean;
|
||||
scrollToNodeFn: ScrollToNodeFn;
|
||||
};
|
||||
|
||||
type UpdateTreeAction = {
|
||||
type: "UPDATE_TREE";
|
||||
payload: {
|
||||
tree: FlatTree<any>;
|
||||
};
|
||||
};
|
||||
|
||||
type SelectNodeAction = {
|
||||
type: "SELECT_NODE";
|
||||
payload: {
|
||||
id: string;
|
||||
} & WithScrollToNode;
|
||||
};
|
||||
|
||||
type DeselectNodeAction = {
|
||||
type: "DESELECT_NODE";
|
||||
payload: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
type DeselectAllNodesAction = {
|
||||
type: "DESELECT_ALL_NODES";
|
||||
};
|
||||
|
||||
type ToggleNodeSelection = {
|
||||
type: "TOGGLE_NODE_SELECTION";
|
||||
payload: {
|
||||
id: string;
|
||||
} & WithScrollToNode;
|
||||
};
|
||||
|
||||
type ExpandNodeAction = {
|
||||
type: "EXPAND_NODE";
|
||||
payload: {
|
||||
id: string;
|
||||
} & WithScrollToNode;
|
||||
};
|
||||
|
||||
type CollapseNodeAction = {
|
||||
type: "COLLAPSE_NODE";
|
||||
payload: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ToggleExpandNodeAction = {
|
||||
type: "TOGGLE_EXPAND_NODE";
|
||||
payload: {
|
||||
id: string;
|
||||
} & WithScrollToNode;
|
||||
};
|
||||
|
||||
type ExpandAllBelowDepthAction = {
|
||||
type: "EXPAND_ALL_BELOW_DEPTH";
|
||||
payload: {
|
||||
depth: number;
|
||||
};
|
||||
};
|
||||
|
||||
type CollapseAllBelowDepthAction = {
|
||||
type: "COLLAPSE_ALL_BELOW_DEPTH";
|
||||
payload: {
|
||||
depth: number;
|
||||
};
|
||||
};
|
||||
|
||||
type ExpandLevelAction = {
|
||||
type: "EXPAND_LEVEL";
|
||||
payload: {
|
||||
level: number;
|
||||
};
|
||||
};
|
||||
|
||||
type CollapseLevelAction = {
|
||||
type: "COLLAPSE_LEVEL";
|
||||
payload: {
|
||||
level: number;
|
||||
};
|
||||
};
|
||||
|
||||
type ToggleExpandLevelAction = {
|
||||
type: "TOGGLE_EXPAND_LEVEL";
|
||||
payload: {
|
||||
level: number;
|
||||
};
|
||||
};
|
||||
|
||||
type SelectFirstVisibleNodeAction = {
|
||||
type: "SELECT_FIRST_VISIBLE_NODE";
|
||||
payload: {} & WithScrollToNode;
|
||||
};
|
||||
|
||||
type SelectLastVisibleNodeAction = {
|
||||
type: "SELECT_LAST_VISIBLE_NODE";
|
||||
payload: {} & WithScrollToNode;
|
||||
};
|
||||
|
||||
type SelectNextVisibleNodeAction = {
|
||||
type: "SELECT_NEXT_VISIBLE_NODE";
|
||||
payload: {} & WithScrollToNode;
|
||||
};
|
||||
|
||||
type SelectPreviousVisibleNodeAction = {
|
||||
type: "SELECT_PREVIOUS_VISIBLE_NODE";
|
||||
payload: {} & WithScrollToNode;
|
||||
};
|
||||
|
||||
type SelectParentNodeAction = {
|
||||
type: "SELECT_PARENT_NODE";
|
||||
payload: {} & WithScrollToNode;
|
||||
};
|
||||
|
||||
type UpdateFilterAction = {
|
||||
type: "UPDATE_FILTER";
|
||||
payload: {
|
||||
filter: Filter<any, any> | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export type Action =
|
||||
| UpdateTreeAction
|
||||
| SelectNodeAction
|
||||
| DeselectNodeAction
|
||||
| DeselectAllNodesAction
|
||||
| ToggleNodeSelection
|
||||
| ExpandNodeAction
|
||||
| CollapseNodeAction
|
||||
| ToggleExpandNodeAction
|
||||
| ExpandAllBelowDepthAction
|
||||
| CollapseAllBelowDepthAction
|
||||
| ExpandLevelAction
|
||||
| CollapseLevelAction
|
||||
| ToggleExpandLevelAction
|
||||
| SelectFirstVisibleNodeAction
|
||||
| SelectLastVisibleNodeAction
|
||||
| SelectNextVisibleNodeAction
|
||||
| SelectPreviousVisibleNodeAction
|
||||
| SelectParentNodeAction
|
||||
| UpdateFilterAction;
|
||||
|
||||
export function reducer(state: TreeState, action: Action): TreeState {
|
||||
switch (action.type) {
|
||||
case "SELECT_NODE": {
|
||||
//if the node was already selected, do nothing. The user needs to use deselectNode to deselect
|
||||
const alreadySelected = state.nodes[action.payload.id]?.selected ?? false;
|
||||
if (alreadySelected) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const newNodes = Object.fromEntries(
|
||||
Object.entries(state.nodes).map(([key, value]) => [key, { ...value, selected: false }])
|
||||
);
|
||||
newNodes[action.payload.id] = { ...newNodes[action.payload.id], selected: true };
|
||||
|
||||
if (action.payload.scrollToNode) {
|
||||
action.payload.scrollToNodeFn(action.payload.id);
|
||||
}
|
||||
|
||||
return applyFilterToState({
|
||||
...state,
|
||||
tree: state.tree,
|
||||
nodes: newNodes,
|
||||
changes: generateChanges(state.nodes, newNodes),
|
||||
});
|
||||
}
|
||||
case "DESELECT_NODE": {
|
||||
const nodes = {
|
||||
...state.nodes,
|
||||
[action.payload.id]: { ...state.nodes[action.payload.id], selected: false },
|
||||
};
|
||||
|
||||
return applyFilterToState({
|
||||
...state,
|
||||
nodes,
|
||||
changes: generateChanges(state.nodes, nodes),
|
||||
});
|
||||
}
|
||||
case "DESELECT_ALL_NODES": {
|
||||
const nodes = Object.fromEntries(
|
||||
Object.entries(state.nodes).map(([key, value]) => [key, { ...value, selected: false }])
|
||||
);
|
||||
return applyFilterToState({
|
||||
...state,
|
||||
nodes,
|
||||
changes: generateChanges(state.nodes, nodes),
|
||||
});
|
||||
}
|
||||
case "TOGGLE_NODE_SELECTION": {
|
||||
const currentlySelected = state.nodes[action.payload.id]?.selected ?? false;
|
||||
if (currentlySelected) {
|
||||
return reducer(state, { type: "DESELECT_NODE", payload: { id: action.payload.id } });
|
||||
}
|
||||
|
||||
return reducer(state, {
|
||||
type: "SELECT_NODE",
|
||||
payload: {
|
||||
id: action.payload.id,
|
||||
scrollToNode: action.payload.scrollToNode,
|
||||
scrollToNodeFn: action.payload.scrollToNodeFn,
|
||||
},
|
||||
});
|
||||
}
|
||||
case "EXPAND_NODE": {
|
||||
const newNodes = {
|
||||
...state.nodes,
|
||||
[action.payload.id]: { ...state.nodes[action.payload.id], expanded: true },
|
||||
};
|
||||
|
||||
if (action.payload.scrollToNode) {
|
||||
action.payload.scrollToNodeFn(action.payload.id);
|
||||
}
|
||||
|
||||
const visibleNodes = applyVisibility(state.tree, newNodes);
|
||||
return applyFilterToState({
|
||||
...state,
|
||||
nodes: visibleNodes,
|
||||
changes: generateChanges(state.nodes, visibleNodes),
|
||||
});
|
||||
}
|
||||
case "COLLAPSE_NODE": {
|
||||
const visibleNodes = applyVisibility(state.tree, {
|
||||
...state.nodes,
|
||||
[action.payload.id]: { ...state.nodes[action.payload.id], expanded: false },
|
||||
});
|
||||
return applyFilterToState({
|
||||
...state,
|
||||
nodes: visibleNodes,
|
||||
changes: generateChanges(state.nodes, visibleNodes),
|
||||
});
|
||||
}
|
||||
case "TOGGLE_EXPAND_NODE": {
|
||||
const currentlyExpanded = state.nodes[action.payload.id]?.expanded ?? true;
|
||||
if (currentlyExpanded) {
|
||||
return reducer(state, {
|
||||
type: "COLLAPSE_NODE",
|
||||
payload: { id: action.payload.id },
|
||||
});
|
||||
}
|
||||
|
||||
return reducer(state, {
|
||||
type: "EXPAND_NODE",
|
||||
payload: {
|
||||
id: action.payload.id,
|
||||
scrollToNode: action.payload.scrollToNode,
|
||||
scrollToNodeFn: action.payload.scrollToNodeFn,
|
||||
},
|
||||
});
|
||||
}
|
||||
case "EXPAND_ALL_BELOW_DEPTH": {
|
||||
const nodesToExpand = state.tree.filter(
|
||||
(n) => n.level >= action.payload.depth && n.hasChildren
|
||||
);
|
||||
|
||||
const newNodes = Object.fromEntries(
|
||||
Object.entries(state.nodes).map(([key, value]) => [
|
||||
key,
|
||||
{
|
||||
...value,
|
||||
expanded: nodesToExpand.find((n) => n.id === key) ? true : value.expanded,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const visibleNodes = applyVisibility(state.tree, newNodes);
|
||||
return applyFilterToState({
|
||||
...state,
|
||||
nodes: visibleNodes,
|
||||
changes: generateChanges(state.nodes, visibleNodes),
|
||||
});
|
||||
}
|
||||
case "COLLAPSE_ALL_BELOW_DEPTH": {
|
||||
const nodesToCollapse = state.tree.filter(
|
||||
(n) => n.level >= action.payload.depth && n.hasChildren
|
||||
);
|
||||
|
||||
const newNodes = Object.fromEntries(
|
||||
Object.entries(state.nodes).map(([key, value]) => [
|
||||
key,
|
||||
{
|
||||
...value,
|
||||
expanded: nodesToCollapse.find((n) => n.id === key) ? false : value.expanded,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const visibleNodes = applyVisibility(state.tree, newNodes);
|
||||
return applyFilterToState({
|
||||
...state,
|
||||
nodes: visibleNodes,
|
||||
changes: generateChanges(state.nodes, visibleNodes),
|
||||
});
|
||||
}
|
||||
case "EXPAND_LEVEL": {
|
||||
const nodesToExpand = state.tree.filter(
|
||||
(n) => n.level <= action.payload.level && n.hasChildren
|
||||
);
|
||||
|
||||
const newNodes = Object.fromEntries(
|
||||
Object.entries(state.nodes).map(([key, value]) => [
|
||||
key,
|
||||
{
|
||||
...value,
|
||||
expanded: nodesToExpand.find((n) => n.id === key) ? true : value.expanded,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const visibleNodes = applyVisibility(state.tree, newNodes);
|
||||
return applyFilterToState({
|
||||
...state,
|
||||
nodes: visibleNodes,
|
||||
changes: generateChanges(state.nodes, visibleNodes),
|
||||
});
|
||||
}
|
||||
case "COLLAPSE_LEVEL": {
|
||||
const nodesToCollapse = state.tree.filter(
|
||||
(n) => n.level === action.payload.level && n.hasChildren
|
||||
);
|
||||
|
||||
const newNodes = Object.fromEntries(
|
||||
Object.entries(state.nodes).map(([key, value]) => [
|
||||
key,
|
||||
{
|
||||
...value,
|
||||
expanded: nodesToCollapse.find((n) => n.id === key) ? false : value.expanded,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const visibleNodes = applyVisibility(state.tree, newNodes);
|
||||
return applyFilterToState({
|
||||
...state,
|
||||
nodes: visibleNodes,
|
||||
changes: generateChanges(state.nodes, visibleNodes),
|
||||
});
|
||||
}
|
||||
case "TOGGLE_EXPAND_LEVEL": {
|
||||
//first get the first item at that level in the tree. If it is expanded, collapse all nodes at that level
|
||||
//if it is collapsed, expand all nodes at that level
|
||||
const nodesAtLevel = state.tree.filter(
|
||||
(n) => n.level === action.payload.level && n.hasChildren
|
||||
);
|
||||
const firstNode = nodesAtLevel[0];
|
||||
if (!firstNode) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const currentlyExpanded = state.nodes[firstNode.id]?.expanded ?? true;
|
||||
const currentVisible = state.nodes[firstNode.id]?.visible ?? true;
|
||||
if (currentlyExpanded && currentVisible) {
|
||||
return reducer(state, {
|
||||
type: "COLLAPSE_LEVEL",
|
||||
payload: {
|
||||
level: action.payload.level,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return reducer(state, {
|
||||
type: "EXPAND_LEVEL",
|
||||
payload: {
|
||||
level: action.payload.level,
|
||||
},
|
||||
});
|
||||
}
|
||||
case "SELECT_FIRST_VISIBLE_NODE": {
|
||||
const node = firstVisibleNode(state.tree, state.filteredNodes);
|
||||
if (node) {
|
||||
return reducer(state, {
|
||||
type: "SELECT_NODE",
|
||||
payload: {
|
||||
id: node.id,
|
||||
scrollToNode: action.payload.scrollToNode,
|
||||
scrollToNodeFn: action.payload.scrollToNodeFn,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
case "SELECT_LAST_VISIBLE_NODE": {
|
||||
const node = lastVisibleNode(state.tree, state.filteredNodes);
|
||||
if (node) {
|
||||
return reducer(state, {
|
||||
type: "SELECT_NODE",
|
||||
payload: {
|
||||
id: node.id,
|
||||
scrollToNode: action.payload.scrollToNode,
|
||||
scrollToNodeFn: action.payload.scrollToNodeFn,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
case "SELECT_NEXT_VISIBLE_NODE": {
|
||||
const selected = selectedIdFromState(state.nodes);
|
||||
if (!selected) {
|
||||
return reducer(state, {
|
||||
type: "SELECT_FIRST_VISIBLE_NODE",
|
||||
payload: {
|
||||
scrollToNode: action.payload.scrollToNode,
|
||||
scrollToNodeFn: action.payload.scrollToNodeFn,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const visible = visibleNodes(state.tree, state.filteredNodes);
|
||||
const selectedIndex = visible.findIndex((node) => node.id === selected);
|
||||
const nextNode = visible[selectedIndex + 1];
|
||||
if (nextNode) {
|
||||
return reducer(state, {
|
||||
type: "SELECT_NODE",
|
||||
payload: {
|
||||
id: nextNode.id,
|
||||
scrollToNode: action.payload.scrollToNode,
|
||||
scrollToNodeFn: action.payload.scrollToNodeFn,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
case "SELECT_PREVIOUS_VISIBLE_NODE": {
|
||||
const selected = selectedIdFromState(state.nodes);
|
||||
|
||||
if (!selected) {
|
||||
return reducer(state, {
|
||||
type: "SELECT_FIRST_VISIBLE_NODE",
|
||||
payload: {
|
||||
scrollToNode: action.payload.scrollToNode,
|
||||
scrollToNodeFn: action.payload.scrollToNodeFn,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const visible = visibleNodes(state.tree, state.filteredNodes);
|
||||
const selectedIndex = visible.findIndex((node) => node.id === selected);
|
||||
const previousNode = visible[Math.max(0, selectedIndex - 1)];
|
||||
if (previousNode) {
|
||||
return reducer(state, {
|
||||
type: "SELECT_NODE",
|
||||
payload: {
|
||||
id: previousNode.id,
|
||||
scrollToNode: action.payload.scrollToNode,
|
||||
scrollToNodeFn: action.payload.scrollToNodeFn,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
case "SELECT_PARENT_NODE": {
|
||||
const selected = selectedIdFromState(state.nodes);
|
||||
|
||||
if (!selected) {
|
||||
return reducer(state, {
|
||||
type: "SELECT_FIRST_VISIBLE_NODE",
|
||||
payload: {
|
||||
scrollToNode: action.payload.scrollToNode,
|
||||
scrollToNodeFn: action.payload.scrollToNodeFn,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const selectedNode = state.tree.find((node) => node.id === selected);
|
||||
if (!selectedNode) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const parentNode = state.tree.find((node) => node.id === selectedNode.parentId);
|
||||
if (parentNode) {
|
||||
return reducer(state, {
|
||||
type: "SELECT_NODE",
|
||||
payload: {
|
||||
id: parentNode.id,
|
||||
scrollToNode: action.payload.scrollToNode,
|
||||
scrollToNodeFn: action.payload.scrollToNodeFn,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
case "UPDATE_TREE": {
|
||||
//update the tree but try and keep the selected and expanded states
|
||||
const selectedId = selectedIdFromState(state.nodes);
|
||||
const collapsedIds = collapsedIdsFromState(state.nodes);
|
||||
const newState = concreteStateFromInput({
|
||||
...state,
|
||||
tree: action.payload.tree,
|
||||
selectedId,
|
||||
collapsedIds,
|
||||
});
|
||||
return newState;
|
||||
}
|
||||
case "UPDATE_FILTER": {
|
||||
const newState = applyFilterToState({
|
||||
...state,
|
||||
filter: action.payload.filter,
|
||||
});
|
||||
return newState;
|
||||
}
|
||||
default: {
|
||||
assertNever(action);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled action type: ${(action as any).type}`);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { Filter, FlatTree } from "./TreeView";
|
||||
import type { Changes, NodeState, NodesState, TreeState } from "./reducer";
|
||||
|
||||
type PartialNodeState = Record<string, Partial<NodeState>>;
|
||||
|
||||
const defaultSelected = false;
|
||||
const defaultExpanded = true;
|
||||
|
||||
export function concreteStateFromInput({
|
||||
tree,
|
||||
filter,
|
||||
selectedId,
|
||||
collapsedIds,
|
||||
}: {
|
||||
tree: FlatTree<any>;
|
||||
filter: Filter<any, any> | undefined;
|
||||
selectedId: string | undefined;
|
||||
collapsedIds: string[] | undefined;
|
||||
}): TreeState {
|
||||
const state: PartialNodeState = {};
|
||||
collapsedIds?.forEach((id) => {
|
||||
const hasTreeItem = tree.some((item) => item.id === id);
|
||||
if (hasTreeItem) {
|
||||
state[id] = { expanded: false };
|
||||
}
|
||||
});
|
||||
if (selectedId) {
|
||||
const selectedNode = tree.find((node) => node.id === selectedId);
|
||||
if (selectedNode) {
|
||||
state[selectedId] = { selected: true };
|
||||
//make sure all parents are expanded
|
||||
let parentId = selectedNode.parentId;
|
||||
while (parentId) {
|
||||
state[parentId] = { expanded: true };
|
||||
const parent = tree.find((node) => node.id === parentId);
|
||||
parentId = parent?.parentId;
|
||||
}
|
||||
}
|
||||
}
|
||||
const nodes = concreteStateFromPartialState(tree, state);
|
||||
|
||||
return applyFilterToState({
|
||||
tree,
|
||||
nodes,
|
||||
changes: { selectedId },
|
||||
filter,
|
||||
filteredNodes: nodes,
|
||||
visibleNodeIds: [],
|
||||
});
|
||||
}
|
||||
|
||||
export function concreteStateFromPartialState<TData>(
|
||||
tree: FlatTree<TData>,
|
||||
state: PartialNodeState
|
||||
): NodesState {
|
||||
const concreteState = tree.reduce(
|
||||
(acc, node) => {
|
||||
acc[node.id] = {
|
||||
selected: acc[node.id]?.selected ?? defaultSelected,
|
||||
expanded: acc[node.id]?.expanded ?? defaultExpanded,
|
||||
visible: acc[node.id]?.visible ?? true,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
state as Record<string, NodeState>
|
||||
);
|
||||
|
||||
return applyVisibility(tree, concreteState);
|
||||
}
|
||||
|
||||
export function applyVisibility<TData>(tree: FlatTree<TData>, state: NodesState): NodesState {
|
||||
const newState = tree.reduce((acc, node) => {
|
||||
//groups are open by default
|
||||
const nodeState = state[node.id] ?? {
|
||||
selected: defaultSelected,
|
||||
expanded: node.hasChildren ? defaultExpanded : !defaultExpanded,
|
||||
};
|
||||
const parent = node.parentId
|
||||
? acc[node.parentId]
|
||||
: { selected: defaultSelected, expanded: defaultExpanded, visible: true };
|
||||
const visible = parent.expanded && parent.visible === true ? true : false;
|
||||
acc[node.id] = { ...nodeState, visible };
|
||||
|
||||
return acc;
|
||||
}, {} as NodesState);
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
export function selectedIdFromState(state: NodesState): string | undefined {
|
||||
const selected = Object.entries(state).find(([id, node]) => node.selected);
|
||||
return selected?.[0];
|
||||
}
|
||||
|
||||
export function applyFilterToState<_TData>({
|
||||
tree,
|
||||
nodes,
|
||||
filter,
|
||||
visibleNodeIds,
|
||||
changes,
|
||||
}: TreeState): TreeState {
|
||||
if (!filter || !filter.value) {
|
||||
return {
|
||||
tree,
|
||||
nodes,
|
||||
filteredNodes: nodes,
|
||||
changes,
|
||||
filter,
|
||||
visibleNodeIds: visibleNodes(tree, nodes).map((node) => node.id),
|
||||
};
|
||||
}
|
||||
|
||||
//we need to do two passes, first collect all the nodes that are results
|
||||
const newFilteredOut = new Set<string>();
|
||||
for (const node of tree) {
|
||||
if (!filter.fn(filter.value, node)) {
|
||||
newFilteredOut.add(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
//nothing is filtered out
|
||||
if (newFilteredOut.size === 0) {
|
||||
return {
|
||||
tree,
|
||||
nodes,
|
||||
filteredNodes: nodes,
|
||||
changes,
|
||||
filter,
|
||||
visibleNodeIds: visibleNodes(tree, nodes).map((node) => node.id),
|
||||
};
|
||||
}
|
||||
|
||||
//copy of nodes
|
||||
const filteredNodes = { ...nodes };
|
||||
|
||||
const selected = selectedIdFromState(filteredNodes);
|
||||
|
||||
const visible = new Set<string>();
|
||||
const expanded = new Set<string>();
|
||||
|
||||
//figure out the state of each node
|
||||
for (const node of tree) {
|
||||
const shouldDisplay = !newFilteredOut.has(node.id);
|
||||
|
||||
//if the node is visible, make all the parents visible and expanded
|
||||
if (shouldDisplay) {
|
||||
//should be visible
|
||||
visible.add(node.id);
|
||||
//if it has children it should be expanded
|
||||
if (node.hasChildren) {
|
||||
expanded.add(node.id);
|
||||
}
|
||||
|
||||
//parents need to be both visible and expanded
|
||||
let parentId = node.parentId;
|
||||
while (parentId) {
|
||||
visible.add(parentId);
|
||||
expanded.add(parentId);
|
||||
parentId = tree.find((node) => node.id === parentId)?.parentId;
|
||||
}
|
||||
|
||||
//children should be visible and if they have children expanded
|
||||
if (node.hasChildren) {
|
||||
const children = tree.filter((child) => child.parentId === node.id);
|
||||
for (const child of children) {
|
||||
visible.add(child.id);
|
||||
if (child.hasChildren) {
|
||||
expanded.add(child.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allItems = new Set(tree.map((node) => node.id));
|
||||
const hidden = difference(allItems, visible);
|
||||
const collapsed = difference(visible, expanded);
|
||||
|
||||
//now set the visibility and expanded state
|
||||
for (const id of hidden) {
|
||||
filteredNodes[id] = { ...filteredNodes[id], visible: false };
|
||||
}
|
||||
for (const id of visible) {
|
||||
filteredNodes[id] = { ...filteredNodes[id], visible: true };
|
||||
}
|
||||
|
||||
for (const id of collapsed) {
|
||||
filteredNodes[id] = { ...filteredNodes[id], expanded: false };
|
||||
}
|
||||
for (const id of expanded) {
|
||||
filteredNodes[id] = { ...filteredNodes[id], expanded: true };
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
if (visible.has(selected)) {
|
||||
filteredNodes[selected] = { ...filteredNodes[selected], selected: true };
|
||||
} else {
|
||||
filteredNodes[selected] = { ...filteredNodes[selected], selected: false };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tree,
|
||||
nodes,
|
||||
filteredNodes,
|
||||
changes,
|
||||
filter,
|
||||
visibleNodeIds: visibleNodes(tree, filteredNodes).map((node) => node.id),
|
||||
};
|
||||
}
|
||||
|
||||
export function visibleNodes(tree: FlatTree<any>, nodes: NodesState) {
|
||||
return tree.filter((node) => nodes[node.id].visible === true);
|
||||
}
|
||||
|
||||
export function firstVisibleNode(tree: FlatTree<any>, nodes: NodesState) {
|
||||
return tree.find((node) => nodes[node.id].visible === true);
|
||||
}
|
||||
|
||||
export function lastVisibleNode(tree: FlatTree<any>, nodes: NodesState) {
|
||||
return tree
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((node) => nodes[node.id].visible === true);
|
||||
}
|
||||
function difference<T>(a: Set<T>, b: Set<T>): Set<T> {
|
||||
return new Set([...a].filter((x) => !b.has(x)));
|
||||
}
|
||||
|
||||
export function collapsedIdsFromState(state: NodesState): string[] {
|
||||
return Object.entries(state)
|
||||
.filter(([_, s]) => s.expanded === false)
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
export function generateChanges(a: NodesState, b: NodesState): Changes {
|
||||
//if selected === defaultSelected, remove it
|
||||
//if expanded === defaultExpanded, remove it
|
||||
//if both are default, remove the node
|
||||
const selectedIdA = selectedIdFromState(a);
|
||||
const selectedIdB = selectedIdFromState(b);
|
||||
|
||||
const collapsedIdsA = new Set(collapsedIdsFromState(a));
|
||||
const collapsedIdsB = new Set(collapsedIdsFromState(b));
|
||||
|
||||
const _collapsedChanges = [...difference(collapsedIdsA, collapsedIdsB)];
|
||||
|
||||
return {
|
||||
selectedId: selectedIdA !== selectedIdB ? selectedIdB : undefined,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user