Skip to content

Commit

Permalink
Merge pull request #138 from kpustakhod/data-change-reset-focus
Browse files Browse the repository at this point in the history
Reset focused node when data is changed
  • Loading branch information
mellis481 authored Jul 21, 2023
2 parents 7071932 + cd6a6f1 commit c28fd8a
Show file tree
Hide file tree
Showing 8 changed files with 1,688 additions and 5 deletions.
33 changes: 32 additions & 1 deletion src/TreeView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,37 @@ const useTree = ({
state,
]);

/**
* When data changes and the last focused item is no longer present in data,
* we need to reset state with existing nodes, e.g. first node in a tree.
*/
useEffect(() => {
if (prevData !== data) {
const treeParentNode = getTreeParent(data);
if (treeParentNode.children.length) {
dispatch({
type: treeTypes.updateTreeStateWhenDataChanged,
tabbableId: !data.find((node) => node.id === state.tabbableId)
? treeParentNode.children[0]
: state.tabbableId,
lastInteractedWith: !data.find(
(node) => node.id === state.lastInteractedWith
)
? null
: state.lastInteractedWith,
lastManuallyToggled: !data.find(
(node) => node.id === state.lastManuallyToggled
)
? null
: state.lastManuallyToggled,
lastUserSelect: !data.find((node) => node.id === state.lastUserSelect)
? treeParentNode.children[0]
: state.lastUserSelect,
});
}
}
}, [data]);

const toggledControlledIds = symmetricDifference(
new Set(controlledSelectedIds),
controlledIds
Expand All @@ -242,7 +273,7 @@ const useTree = ({
ids: propagatedIds(data, [id], disabledIds),
select: true,
multiSelect,
lastInteractedWith: id
lastInteractedWith: id,
});
}
}
Expand Down
17 changes: 17 additions & 0 deletions src/TreeView/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const treeTypes = {
enable: "ENABLE",
clearLastManuallyToggled: "CLEAR_MANUALLY_TOGGLED",
controlledSelectMany: "CONTROLLED_SELECT_MANY",
updateTreeStateWhenDataChanged: "UPDATE_TREE_STATE_WHEN_DATA_CHANGED",
} as const;

export type TreeViewAction =
Expand Down Expand Up @@ -87,6 +88,13 @@ export type TreeViewAction =
type: "CONTROLLED_SELECT_MANY";
ids: NodeId[];
multiSelect?: boolean;
}
| {
type: "UPDATE_TREE_STATE_WHEN_DATA_CHANGED";
tabbableId: NodeId;
lastInteractedWith?: NodeId | null;
lastManuallyToggled?: NodeId | null;
lastUserSelect: NodeId;
};

export interface ITreeViewState {
Expand Down Expand Up @@ -389,6 +397,15 @@ export const treeReducer = (
lastManuallyToggled: null,
};
}
case treeTypes.updateTreeStateWhenDataChanged: {
return {
...state,
tabbableId: action.tabbableId,
lastInteractedWith: action.lastInteractedWith,
lastManuallyToggled: action.lastManuallyToggled,
lastUserSelect: action.lastUserSelect,
};
}
default:
throw new Error("Invalid action passed to the reducer");
}
Expand Down
27 changes: 23 additions & 4 deletions src/TreeView/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,9 @@ interface ITreeNode<M extends IFlatMetadata> {
metadata?: M;
}

export const flattenTree = <M extends IFlatMetadata>(tree: ITreeNode<M>): INode<M>[] => {
export const flattenTree = <M extends IFlatMetadata>(
tree: ITreeNode<M>
): INode<M>[] => {
let internalCount = 0;
const flattenedTree: INode<M>[] = [];

Expand All @@ -306,7 +308,7 @@ export const flattenTree = <M extends IFlatMetadata>(tree: ITreeNode<M>): INode<
name: tree.name,
children: [],
parent,
metadata: tree.metadata ? { ...tree.metadata} : undefined
metadata: tree.metadata ? { ...tree.metadata } : undefined,
};

if (flattenedTree.find((x) => x.id === node.id)) {
Expand Down Expand Up @@ -478,6 +480,15 @@ const hasDuplicates = (ids: NodeId[]): boolean => {
return ids.length !== uniqueIds.length;
};

/**
* We need to validate a tree data for
* - duplicates
* - node references to itself
* - node has duplicated children
* - no root node in a tree
* - more then one root node in a tree
* - to have nodes to display
* */
export const validateTreeViewData = (data: INode[]): void => {
if (hasDuplicates(data.map((node) => node.id))) {
throw Error(
Expand All @@ -496,8 +507,16 @@ export const validateTreeViewData = (data: INode[]): void => {
}
});

if (data.filter((node) => node.parent === null).length !== 1) {
throw Error(`TreeView can have only one root node.`);
if (data.filter((node) => node.parent === null).length === 0) {
throw Error("TreeView must have one root node.");
}

if (data.filter((node) => node.parent === null).length > 1) {
throw Error("TreeView can have only one root node.");
}

if (!getTreeParent(data).children.length) {
console.warn("TreeView have no nodes to display.");
}

return;
Expand Down
89 changes: 89 additions & 0 deletions src/__tests__/CheckboxTree.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -389,3 +389,92 @@ test("should not set focus without interaction with the tree", () => {
expect(nodes[0].childNodes[0]).not.toHaveClass("tree-node--focused");
expect(nodes[0].childNodes[1]).not.toHaveClass("tree-node-group--focused");
});

test("should set focus on first node if data has changed", () => {
const firstNodeName = "Beets";
const vegetables = flattenTree({
name: "",
children: [
{ name: firstNodeName },
{ name: "Carrots" },
{ name: "Celery" },
{ name: "Lettuce" },
{ name: "Onions" },
],
});
const { queryAllByRole, rerender } = render(<CheckboxTree />);

const nodes = queryAllByRole("treeitem");
nodes[0].focus();

if (document.activeElement == null)
throw new Error(
`Expected to find an active element on the document (after focusing the second element with role["treeitem"]), but did not.`
);
fireEvent.keyDown(document.activeElement, { key: "ArrowDown" }); //focused Drinks

expect(document.querySelector(".tree-node--focused")?.innerHTML).toContain(
"Drinks"
);

rerender(<CheckboxTree data={vegetables} />);

expect(document.querySelector(".tree-node--focused")?.innerHTML).toContain(
firstNodeName
);
});

test.only("should preserve focus on node if changed data contains previouslt focused node", () => {
const filteredData = flattenTree({
name: "",
children: [
{
name: "Fruits",
children: [
{ name: "Avocados" },
{ name: "Bananas" },
{ name: "Berries" },
{ name: "Oranges" },
{ name: "Pears" },
],
},
{
name: "Drinks",
children: [
{ name: "Apple Juice" },
{ name: "Chocolate" },
{ name: "Coffee" },
{
name: "Tea",
children: [
{ name: "Black Tea" },
{ name: "Green Tea" },
{ name: "Red Tea" },
{ name: "Matcha" },
],
},
],
},
],
});
const { queryAllByRole, rerender } = render(<CheckboxTree />);

const nodes = queryAllByRole("treeitem");
nodes[0].focus();

if (document.activeElement == null)
throw new Error(
`Expected to find an active element on the document (after focusing the second element with role["treeitem"]), but did not.`
);
fireEvent.keyDown(document.activeElement, { key: "ArrowDown" }); //focused Drinks

expect(document.querySelector(".tree-node--focused")?.innerHTML).toContain(
"Drinks"
);

rerender(<CheckboxTree data={filteredData} />);

expect(document.querySelector(".tree-node--focused")?.innerHTML).toContain(
"Drinks"
);
});
21 changes: 21 additions & 0 deletions src/__tests__/ValidateTreeViewData.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { INode } from "../TreeView/types";
import { validateTreeViewData } from "../TreeView/utils";

test("Should error when no parent node", () => {
const treeViewData: INode[] = [
{ name: "Fruits", id: 0, parent: 14, children: [] },
{ name: "Vegetables", id: 3, parent: 0, children: [] },
{ name: "Drinks", id: 14, parent: 3, children: [] },
];
const expected = () => validateTreeViewData(treeViewData);
expect(expected).toThrow("TreeView must have one root node.");
});

test("Should error when more then one parent node", () => {
const treeViewData: INode[] = [
{ name: "Fruits", id: 0, parent: null, children: [] },
Expand All @@ -11,6 +21,17 @@ test("Should error when more then one parent node", () => {
expect(expected).toThrow("TreeView can have only one root node.");
});

test("Should warn of no nodes to display", () => {
const treeViewData: INode[] = [
{ name: "", id: 0, parent: null, children: [] },
];
jest.spyOn(console, "warn");
validateTreeViewData(treeViewData);
expect(console.warn).toHaveBeenCalledWith(
"TreeView have no nodes to display."
);
});

test("Should error when node's parent reference to node", () => {
const treeViewData: INode[] = [
{ name: "Fruits", id: 0, parent: null, children: [] },
Expand Down
12 changes: 12 additions & 0 deletions website/docs/examples-Filtering.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
title: Filtering
---

The example how to implement filtering within a tree.

import TreeView from "./examples/Filtering";
import js from "!!raw-loader!!./examples/Filtering";
import css from "!!raw-loader!!./examples/Filtering/styles.css";
import CodeTabs from "../src/components/CodeTabs";

<CodeTabs component={TreeView} js={js} css={css} />
Loading

0 comments on commit c28fd8a

Please sign in to comment.