Introduce new TreeView UI component
Key highlights: - Written from scratch to cater specifically to privacy.sexy's needs and requirements. - The visual look mimics the previous component with minimal changes, but its internal code is completely rewritten. - Lays groundwork for future functionalities like the "expand all" button a flat view mode as discussed in #158. - Facilitates the transition to Vue 3 by omitting the Vue 2.0 dependent `liquour-tree` as part of #230. Improvements and features: - Caching for quicker node queries. - Gradual rendering of nodes that introduces a noticable boost in performance, particularly during search/filtering. - `TreeView` solely governs the check states of branch nodes. Changes: - Keyboard interactions now alter the background color to highlight the focused item. Previously, it was changing the color of the text. - Better state management with clear separation of concerns: - `TreeView` exclusively manages indeterminate states. - `TreeView` solely governs the check states of branch nodes. - Introduce transaction pattern to update state in batches to minimize amount of events handled. - Improve keyboard focus, style background instead of foreground. Use hover/touch color on keyboard focus. - `SelectableTree` has been removed. Instead, `TreeView` is now directly integrated with `ScriptsTree`. - `ScriptsTree` has been refactored to incorporate hooks for clearer code and separation of duties. - Adopt Vue-idiomatic bindings instead of keeping a reference of the tree component. - Simplify and change filter event management. - Abandon global styles in favor of class-scoped styles. - Use global mixins with descriptive names to clarify indended functionality.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { expect, describe, it } from 'vitest';
|
||||
import { SingleNodeCollectionFocusManager } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeCollectionFocusManager';
|
||||
import { TreeNodeCollectionStub } from '@tests/unit/shared/Stubs/TreeNodeCollectionStub';
|
||||
import { QueryableNodesStub } from '@tests/unit/shared/Stubs/QueryableNodesStub';
|
||||
import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub';
|
||||
import { TreeNodeStateAccessStub } from '@tests/unit/shared/Stubs/TreeNodeStateAccessStub';
|
||||
import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub';
|
||||
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
|
||||
|
||||
describe('SingleNodeCollectionFocusManager', () => {
|
||||
describe('currentSingleFocusedNode', () => {
|
||||
const testCases: ReadonlyArray<{
|
||||
readonly name: string,
|
||||
readonly nodes: TreeNode[],
|
||||
readonly expectedValue: TreeNode | undefined,
|
||||
}> = [
|
||||
{
|
||||
name: 'should return undefined if no node is focused',
|
||||
nodes: [],
|
||||
expectedValue: undefined,
|
||||
},
|
||||
(() => {
|
||||
const unfocusedNode = getNodeWithFocusState(false);
|
||||
const focusedNode = getNodeWithFocusState(true);
|
||||
return {
|
||||
name: 'should return the single focused node',
|
||||
nodes: [focusedNode, unfocusedNode],
|
||||
expectedValue: focusedNode,
|
||||
};
|
||||
})(),
|
||||
{
|
||||
name: 'should return undefined if multiple nodes are focused',
|
||||
nodes: [getNodeWithFocusState(true), getNodeWithFocusState(true)],
|
||||
expectedValue: undefined,
|
||||
},
|
||||
];
|
||||
testCases.forEach(({ name, nodes, expectedValue }) => {
|
||||
it(name, () => {
|
||||
// arrange
|
||||
const collection = new TreeNodeCollectionStub()
|
||||
.withNodes(new QueryableNodesStub().withFlattenedNodes(nodes));
|
||||
const focusManager = new SingleNodeCollectionFocusManager(collection);
|
||||
// act
|
||||
const singleFocusedNode = focusManager.currentSingleFocusedNode;
|
||||
// assert
|
||||
expect(singleFocusedNode).to.equal(expectedValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSingleFocus', () => {
|
||||
it('should set focus on given node and remove focus from all others', () => {
|
||||
// arrange
|
||||
const node1 = getNodeWithFocusState(true);
|
||||
const node2 = getNodeWithFocusState(true);
|
||||
const node3 = getNodeWithFocusState(false);
|
||||
const collection = new TreeNodeCollectionStub()
|
||||
.withNodes(new QueryableNodesStub().withFlattenedNodes([node1, node2, node3]));
|
||||
// act
|
||||
const focusManager = new SingleNodeCollectionFocusManager(collection);
|
||||
focusManager.setSingleFocus(node3);
|
||||
// assert
|
||||
expect(node1.state.current.isFocused).toBeFalsy();
|
||||
expect(node2.state.current.isFocused).toBeFalsy();
|
||||
expect(node3.state.current.isFocused).toBeTruthy();
|
||||
});
|
||||
it('should set currentSingleFocusedNode as expected', () => {
|
||||
// arrange
|
||||
const nodeToFocus = getNodeWithFocusState(false);
|
||||
const collection = new TreeNodeCollectionStub()
|
||||
.withNodes(new QueryableNodesStub().withFlattenedNodes([
|
||||
getNodeWithFocusState(false),
|
||||
getNodeWithFocusState(true),
|
||||
nodeToFocus,
|
||||
getNodeWithFocusState(false),
|
||||
getNodeWithFocusState(true),
|
||||
]));
|
||||
// act
|
||||
const focusManager = new SingleNodeCollectionFocusManager(collection);
|
||||
focusManager.setSingleFocus(nodeToFocus);
|
||||
// assert
|
||||
expect(focusManager.currentSingleFocusedNode).toEqual(nodeToFocus);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function getNodeWithFocusState(isFocused: boolean): TreeNodeStub {
|
||||
return new TreeNodeStub()
|
||||
.withState(new TreeNodeStateAccessStub().withCurrent(
|
||||
new TreeNodeStateDescriptorStub().withFocusState(isFocused),
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect } from 'vitest';
|
||||
import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub';
|
||||
import { HierarchyAccessStub } from '@tests/unit/shared/Stubs/HierarchyAccessStub';
|
||||
import { TreeNodeNavigator } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/TreeNodeNavigator';
|
||||
|
||||
describe('TreeNodeNavigator', () => {
|
||||
describe('flattenedNodes', () => {
|
||||
it('should flatten root nodes correctly', () => {
|
||||
// arrange
|
||||
const rootNode1 = new TreeNodeStub();
|
||||
const rootNode2 = new TreeNodeStub();
|
||||
const rootNode3 = new TreeNodeStub();
|
||||
// act
|
||||
const navigator = new TreeNodeNavigator([rootNode1, rootNode2, rootNode3]);
|
||||
// assert
|
||||
expect(navigator.flattenedNodes).to.have.length(3);
|
||||
expect(navigator.flattenedNodes).to.include.members([rootNode1, rootNode2, rootNode3]);
|
||||
});
|
||||
|
||||
it('should flatten nested nodes correctly', () => {
|
||||
// arrange
|
||||
const nestedNode = new TreeNodeStub();
|
||||
const nestedNode2 = new TreeNodeStub();
|
||||
const rootNode = new TreeNodeStub()
|
||||
.withHierarchy(new HierarchyAccessStub().withChildren([nestedNode, nestedNode2]));
|
||||
// act
|
||||
const navigator = new TreeNodeNavigator([rootNode]);
|
||||
// assert
|
||||
expect(navigator.flattenedNodes).to.have.length(3);
|
||||
expect(navigator.flattenedNodes).to.include.members([nestedNode, nestedNode2, rootNode]);
|
||||
});
|
||||
|
||||
it('should flatten deeply nested nodes correctly', () => {
|
||||
// arrange
|
||||
const deepNestedNode1 = new TreeNodeStub();
|
||||
const deepNestedNode2 = new TreeNodeStub();
|
||||
const nestedNode = new TreeNodeStub()
|
||||
.withHierarchy(new HierarchyAccessStub().withChildren([deepNestedNode1, deepNestedNode2]));
|
||||
const rootNode = new TreeNodeStub()
|
||||
.withHierarchy(new HierarchyAccessStub().withChildren([nestedNode]));
|
||||
// act
|
||||
const navigator = new TreeNodeNavigator([rootNode]);
|
||||
// assert
|
||||
expect(navigator.flattenedNodes).to.have.length(4);
|
||||
expect(navigator.flattenedNodes).to.include.members([
|
||||
rootNode, nestedNode, deepNestedNode1, deepNestedNode2,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rootNodes', () => {
|
||||
it('should initialize with expected root nodes', () => {
|
||||
// arrange
|
||||
const nestedNode = new TreeNodeStub();
|
||||
const rootNode = new TreeNodeStub()
|
||||
.withHierarchy(new HierarchyAccessStub().withChildren([nestedNode]));
|
||||
// act
|
||||
const navigator = new TreeNodeNavigator([rootNode]);
|
||||
// assert
|
||||
expect(navigator.rootNodes).to.have.length(1);
|
||||
expect(navigator.flattenedNodes).to.include.members([rootNode]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeById', () => {
|
||||
it('should find nested node by id', () => {
|
||||
// arrange
|
||||
const nodeId = 'nested-node-id';
|
||||
const expectedNode = new TreeNodeStub().withId(nodeId);
|
||||
const rootNode = new TreeNodeStub()
|
||||
.withHierarchy(new HierarchyAccessStub().withChildren([expectedNode]));
|
||||
const navigator = new TreeNodeNavigator([rootNode]);
|
||||
// act
|
||||
const actualNode = navigator.getNodeById(nodeId);
|
||||
// assert
|
||||
expect(actualNode).to.equal(expectedNode);
|
||||
});
|
||||
|
||||
it('should find root node by id', () => {
|
||||
// arrange
|
||||
const nodeId = 'root-node-id';
|
||||
const expectedRootNode = new TreeNodeStub().withId(nodeId);
|
||||
const navigator = new TreeNodeNavigator([
|
||||
new TreeNodeStub(),
|
||||
expectedRootNode,
|
||||
new TreeNodeStub(),
|
||||
]);
|
||||
// act
|
||||
const actualNode = navigator.getNodeById(nodeId);
|
||||
// assert
|
||||
expect(actualNode).to.equal(expectedRootNode);
|
||||
});
|
||||
|
||||
it('should throw an error if node cannot be found', () => {
|
||||
// arrange
|
||||
const absentNodeId = 'absent-node-id';
|
||||
const expectedError = `Node could not be found: ${absentNodeId}`;
|
||||
const navigator = new TreeNodeNavigator([
|
||||
new TreeNodeStub(),
|
||||
new TreeNodeStub(),
|
||||
]);
|
||||
// act
|
||||
const act = () => navigator.getNodeById(absentNodeId);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseTreeInput } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeInputParser';
|
||||
import { TreeInputNodeData } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputNodeData';
|
||||
import { TreeNodeManager } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNodeManager';
|
||||
import { TreeInputNodeDataStub } from '@tests/unit/shared/Stubs/TreeInputNodeDataStub';
|
||||
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
|
||||
describe('parseTreeInput', () => {
|
||||
it('throws if input data is not an array', () => {
|
||||
// arrange
|
||||
const expectedError = 'input data must be an array';
|
||||
const invalidInput = 'invalid-input' as unknown as TreeInputNodeData[];
|
||||
// act
|
||||
const act = () => parseTreeInput(invalidInput);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
|
||||
describe('throws if input data is absent', () => {
|
||||
itEachAbsentObjectValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing input';
|
||||
const invalidInput = absentValue;
|
||||
// act
|
||||
const act = () => parseTreeInput(invalidInput);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty array if given an empty array', () => {
|
||||
// arrange
|
||||
const input = [];
|
||||
// act
|
||||
const nodes = parseTreeInput(input);
|
||||
// assert
|
||||
expect(nodes).to.have.lengthOf(0);
|
||||
});
|
||||
|
||||
it(`creates ${TreeNodeManager.name} for each node`, () => {
|
||||
// arrange
|
||||
const input: TreeInputNodeData[] = [
|
||||
new TreeInputNodeDataStub(),
|
||||
new TreeInputNodeDataStub(),
|
||||
];
|
||||
// act
|
||||
const nodes = parseTreeInput(input);
|
||||
// assert
|
||||
expect(nodes).have.lengthOf(2);
|
||||
expect(nodes[0]).to.be.instanceOf(TreeNodeManager);
|
||||
expect(nodes[1]).to.be.instanceOf(TreeNodeManager);
|
||||
});
|
||||
|
||||
it('converts flat input array to flat node array', () => {
|
||||
// arrange
|
||||
const inputNodes: TreeInputNodeData[] = [
|
||||
new TreeInputNodeDataStub().withId('1'),
|
||||
new TreeInputNodeDataStub().withId('2'),
|
||||
];
|
||||
// act
|
||||
const nodes = parseTreeInput(inputNodes);
|
||||
// assert
|
||||
expect(nodes).have.lengthOf(2);
|
||||
expect(nodes[0].id).equal(inputNodes[0].id);
|
||||
expect(nodes[0].hierarchy.children).to.have.lengthOf(0);
|
||||
expect(nodes[0].hierarchy.parent).to.toBeUndefined();
|
||||
expect(nodes[1].id).equal(inputNodes[1].id);
|
||||
expect(nodes[1].hierarchy.children).to.have.lengthOf(0);
|
||||
expect(nodes[1].hierarchy.parent).to.toBeUndefined();
|
||||
});
|
||||
|
||||
it('correctly parses nested data with correct hierarchy', () => {
|
||||
// arrange;
|
||||
const grandChildData = new TreeInputNodeDataStub().withId('1-1-1');
|
||||
const childData = new TreeInputNodeDataStub().withId('1-1').withChildren([grandChildData]);
|
||||
const parentNodeData = new TreeInputNodeDataStub().withId('1').withChildren([childData]);
|
||||
const inputData: TreeInputNodeData[] = [parentNodeData];
|
||||
|
||||
// act
|
||||
const nodes = parseTreeInput(inputData);
|
||||
|
||||
// assert
|
||||
expect(nodes).to.have.lengthOf(1);
|
||||
expect(nodes[0].id).to.equal(parentNodeData.id);
|
||||
expect(nodes[0].hierarchy.children).to.have.lengthOf(1);
|
||||
|
||||
const childNode = nodes[0].hierarchy.children[0];
|
||||
expect(childNode.id).to.equal(childData.id);
|
||||
expect(childNode.hierarchy.children).to.have.lengthOf(1);
|
||||
expect(childNode.hierarchy.parent).to.equal(nodes[0]);
|
||||
|
||||
const grandChildNode = childNode.hierarchy.children[0];
|
||||
expect(grandChildNode.id).to.equal(grandChildData.id);
|
||||
expect(grandChildNode.hierarchy.children).to.have.lengthOf(0);
|
||||
expect(grandChildNode.hierarchy.parent).to.equal(childNode);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TreeNodeInitializerAndUpdater } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeInitializerAndUpdater';
|
||||
import { TreeNodeNavigator } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/TreeNodeNavigator';
|
||||
import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { createTreeNodeParserStub } from '@tests/unit/shared/Stubs/TreeNodeParserStub';
|
||||
import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub';
|
||||
import { TreeInputNodeDataStub } from '@tests/unit/shared/Stubs/TreeInputNodeDataStub';
|
||||
import { QueryableNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/QueryableNodes';
|
||||
|
||||
describe('TreeNodeInitializerAndUpdater', () => {
|
||||
describe('updateRootNodes', () => {
|
||||
it('should throw an error if no data is provided', () => {
|
||||
itEachAbsentCollectionValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing data';
|
||||
const initializer = new TreeNodeInitializerAndUpdaterBuilder()
|
||||
.build();
|
||||
// act
|
||||
const act = () => initializer.updateRootNodes(absentValue);
|
||||
// expect
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
|
||||
it('should update nodes when valid data is provided', () => {
|
||||
// arrange
|
||||
const expectedData = [new TreeNodeStub(), new TreeNodeStub()];
|
||||
const inputData = [new TreeInputNodeDataStub(), new TreeInputNodeDataStub()];
|
||||
const builder = new TreeNodeInitializerAndUpdaterBuilder();
|
||||
builder.parserStub.registerScenario({
|
||||
given: inputData,
|
||||
result: expectedData,
|
||||
});
|
||||
const initializer = builder.build();
|
||||
// act
|
||||
initializer.updateRootNodes(inputData);
|
||||
// assert
|
||||
expect(initializer.nodes).to.be.instanceOf(TreeNodeNavigator);
|
||||
expect(initializer.nodes.rootNodes).to.have.members(expectedData);
|
||||
});
|
||||
|
||||
it('should notify when nodes are updated', () => {
|
||||
// arrange
|
||||
let notifiedNodes: QueryableNodes | undefined;
|
||||
const inputData = [new TreeInputNodeDataStub(), new TreeInputNodeDataStub()];
|
||||
const expectedData = [new TreeNodeStub(), new TreeNodeStub()];
|
||||
const builder = new TreeNodeInitializerAndUpdaterBuilder();
|
||||
builder.parserStub.registerScenario({
|
||||
given: inputData,
|
||||
result: expectedData,
|
||||
});
|
||||
const initializer = builder.build();
|
||||
initializer.nodesUpdated.on((nodes) => {
|
||||
notifiedNodes = nodes;
|
||||
});
|
||||
// act
|
||||
initializer.updateRootNodes(inputData);
|
||||
// assert
|
||||
expect(notifiedNodes).to.toBeTruthy();
|
||||
expect(initializer.nodes.rootNodes).to.have.members(expectedData);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class TreeNodeInitializerAndUpdaterBuilder {
|
||||
public readonly parserStub = createTreeNodeParserStub();
|
||||
|
||||
public build() {
|
||||
return new TreeNodeInitializerAndUpdater(this.parserStub.parseTreeInputStub);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { SingleNodeCollectionFocusManager } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeCollectionFocusManager';
|
||||
import { TreeNodeCollection } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeCollection';
|
||||
import { TreeNodeInitializerAndUpdater } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeInitializerAndUpdater';
|
||||
import { TreeRootManager } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRootManager';
|
||||
import { SingleNodeFocusManagerStub } from '@tests/unit/shared/Stubs/SingleNodeFocusManagerStub';
|
||||
import { TreeNodeCollectionStub } from '@tests/unit/shared/Stubs/TreeNodeCollectionStub';
|
||||
|
||||
describe('TreeRootManager', () => {
|
||||
describe('collection', () => {
|
||||
it(`defaults to ${TreeNodeInitializerAndUpdater.name}`, () => {
|
||||
// arrange
|
||||
const expectedCollectionType = TreeNodeInitializerAndUpdater;
|
||||
const sut = new TreeRootManager();
|
||||
// act
|
||||
const actualCollection = sut.collection;
|
||||
// assert
|
||||
expect(actualCollection).to.be.instanceOf(expectedCollectionType);
|
||||
});
|
||||
it('set by constructor as expected', () => {
|
||||
// arrange
|
||||
const expectedCollection = new TreeNodeCollectionStub();
|
||||
const sut = new TreeRootManager();
|
||||
// act
|
||||
const actualCollection = sut.collection;
|
||||
// assert
|
||||
expect(actualCollection).to.equal(expectedCollection);
|
||||
});
|
||||
});
|
||||
describe('focus', () => {
|
||||
it(`defaults to instance of ${SingleNodeCollectionFocusManager.name}`, () => {
|
||||
// arrange
|
||||
const expectedFocusType = SingleNodeCollectionFocusManager;
|
||||
const sut = new TreeRootManager();
|
||||
// act
|
||||
const actualFocusType = sut.focus;
|
||||
// assert
|
||||
expect(actualFocusType).to.be.instanceOf(expectedFocusType);
|
||||
});
|
||||
it('creates with same collection it uses', () => {
|
||||
// arrange
|
||||
let usedCollection: TreeNodeCollection | undefined;
|
||||
const factoryMock = (collection) => {
|
||||
usedCollection = collection;
|
||||
return new SingleNodeFocusManagerStub();
|
||||
};
|
||||
const sut = new TreeRootManager(new TreeNodeCollectionStub(), factoryMock);
|
||||
// act
|
||||
const expected = sut.collection;
|
||||
// assert
|
||||
expect(usedCollection).to.equal(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user