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:
undergroundwires
2023-09-09 22:26:21 +02:00
parent 821cc62c4c
commit 65f121c451
120 changed files with 4537 additions and 1203 deletions

View File

@@ -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),
));
}

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});

View File

@@ -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);
}
}

View File

@@ -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);
});
});
});