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

@@ -6,6 +6,7 @@ import { SelectedScript } from '@/application/Context/State/Selection/SelectedSc
import { IScript } from '@/domain/IScript';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { CategoryCollectionStub } from './CategoryCollectionStub';
import { UserSelectionStub } from './UserSelectionStub';
import { UserFilterStub } from './UserFilterStub';
@@ -13,42 +14,53 @@ import { ApplicationCodeStub } from './ApplicationCodeStub';
import { CategoryStub } from './CategoryStub';
export class CategoryCollectionStateStub implements ICategoryCollectionState {
private collectionStub = new CategoryCollectionStub();
public readonly code: IApplicationCode = new ApplicationCodeStub();
public filter: IUserFilter = new UserFilterStub();
public get os(): OperatingSystem {
return this.collectionStub.os;
return this.collection.os;
}
public get collection(): ICategoryCollection {
return this.collectionStub;
}
public collection: ICategoryCollection = new CategoryCollectionStub();
public readonly selection: UserSelectionStub;
public selection: IUserSelection = new UserSelectionStub([]);
constructor(readonly allScripts: IScript[] = [new ScriptStub('script-id')]) {
this.selection = new UserSelectionStub(allScripts);
this.collectionStub = new CategoryCollectionStub()
this.collection = new CategoryCollectionStub()
.withOs(this.os)
.withTotalScripts(this.allScripts.length)
.withAction(new CategoryStub(0).withScripts(...allScripts));
}
public withOs(os: OperatingSystem) {
this.collectionStub = this.collectionStub.withOs(os);
public withCollection(collection: ICategoryCollection): this {
this.collection = collection;
return this;
}
public withFilter(filter: IUserFilter) {
public withOs(os: OperatingSystem): this {
if (this.collection instanceof CategoryCollectionStub) {
this.collection = this.collection.withOs(os);
} else {
this.collection = new CategoryCollectionStub().withOs(os);
}
return this;
}
public withFilter(filter: IUserFilter): this {
this.filter = filter;
return this;
}
public withSelectedScripts(initialScripts: readonly SelectedScript[]) {
this.selection.withSelectedScripts(initialScripts);
public withSelectedScripts(initialScripts: readonly SelectedScript[]): this {
return this.withSelection(
new UserSelectionStub([]).withSelectedScripts(initialScripts),
);
}
public withSelection(selection: IUserSelection) {
this.selection = selection;
return this;
}
}

View File

@@ -0,0 +1,42 @@
import { HierarchyAccess } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/HierarchyAccess';
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
export class HierarchyAccessStub implements HierarchyAccess {
public parent: TreeNode = undefined;
public children: readonly TreeNode[] = [];
public depthInTree = 0;
public isLeafNode = true;
public isBranchNode = false;
public setParent(parent: TreeNode): void {
this.parent = parent;
}
public setChildren(children: readonly TreeNode[]): void {
this.children = children;
}
public withParent(parent: TreeNode): this {
this.parent = parent;
return this;
}
public withDepthInTree(depthInTree: number): this {
this.depthInTree = depthInTree;
return this;
}
public withChildren(children: readonly TreeNode[]): this {
this.setChildren(children);
return this;
}
public withIsBranchNode(value: boolean): this {
this.isBranchNode = value;
return this;
}
}

View File

@@ -0,0 +1,25 @@
import { NodeMetadata, NodeType } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata';
export class NodeMetadataStub implements NodeMetadata {
public id = 'stub-id';
public readonly text: string = 'stub-text';
public readonly isReversible: boolean = false;
public readonly docs: readonly string[] = [];
public children?: readonly NodeMetadata[] = [];
public readonly type: NodeType = NodeType.Category;
public withChildren(children: readonly NodeMetadata[]): this {
this.children = children;
return this;
}
public withId(id: string): this {
this.id = id;
return this;
}
}

View File

@@ -0,0 +1,33 @@
import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState';
import { NodeStateChangedEvent } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess';
import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor';
import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub';
export class NodeStateChangedEventStub implements NodeStateChangedEvent {
public oldState: TreeNodeStateDescriptor = new TreeNodeStateDescriptorStub();
public newState: TreeNodeStateDescriptor = new TreeNodeStateDescriptorStub();
public withNewState(newState: TreeNodeStateDescriptor): this {
this.newState = newState;
return this;
}
public withOldState(oldState: TreeNodeStateDescriptor): this {
this.oldState = oldState;
return this;
}
public withCheckStateChange(change: {
readonly oldState: TreeNodeCheckState,
readonly newState: TreeNodeCheckState,
}) {
return this
.withOldState(
new TreeNodeStateDescriptorStub().withCheckState(change.oldState),
)
.withNewState(
new TreeNodeStateDescriptorStub().withCheckState(change.newState),
);
}
}

View File

@@ -0,0 +1,22 @@
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
import { QueryableNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/QueryableNodes';
export class QueryableNodesStub implements QueryableNodes {
public rootNodes: readonly TreeNode[];
public flattenedNodes: readonly TreeNode[];
public getNodeById(): TreeNode {
throw new Error('Method not implemented.');
}
public withRootNodes(rootNodes: readonly TreeNode[]): this {
this.rootNodes = rootNodes;
return this;
}
public withFlattenedNodes(flattenedNodes: readonly TreeNode[]): this {
this.flattenedNodes = flattenedNodes;
return this;
}
}

View File

@@ -0,0 +1,9 @@
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
import { SingleNodeFocusManager } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeFocusManager';
import { TreeNodeStub } from './TreeNodeStub';
export class SingleNodeFocusManagerStub implements SingleNodeFocusManager {
public currentSingleFocusedNode: TreeNode = new TreeNodeStub();
setSingleFocus(): void { /* NOOP */ }
}

View File

@@ -0,0 +1,26 @@
import { TreeInputNodeData } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputNodeData';
export class TreeInputNodeDataStub implements TreeInputNodeData {
public id = 'stub-id';
public children?: readonly TreeInputNodeData[];
public parent?: TreeInputNodeData;
public data?: object;
public withData(data: object): this {
this.data = data;
return this;
}
public withId(id: string): this {
this.id = id;
return this;
}
public withChildren(children: readonly TreeInputNodeData[]): this {
this.children = children;
return this;
}
}

View File

@@ -0,0 +1,23 @@
import { QueryableNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/QueryableNodes';
import { TreeNodeCollection } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeCollection';
import { EventSourceStub } from './EventSourceStub';
export class TreeNodeCollectionStub implements TreeNodeCollection {
public nodes: QueryableNodes;
public nodesUpdated = new EventSourceStub<QueryableNodes>();
public updateRootNodes(): void {
throw new Error('Method not implemented.');
}
public withNodes(nodes: QueryableNodes): this {
this.nodes = nodes;
return this;
}
public triggerNodesUpdatedEvent(nodes: QueryableNodes): this {
this.nodesUpdated.notify(nodes);
return this;
}
}

View File

@@ -0,0 +1,29 @@
import { TreeInputNodeData } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputNodeData';
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
import { parseTreeInput } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeInputParser';
import { TreeNodeStub } from './TreeNodeStub';
interface StubScenario {
readonly given: readonly TreeInputNodeData[],
readonly result: TreeNode[],
}
export function createTreeNodeParserStub() {
const scenarios = new Array<StubScenario>();
function registerScenario(scenario: StubScenario) {
scenarios.push(scenario);
}
const parseTreeInputStub: typeof parseTreeInput = (
data: readonly TreeInputNodeData[],
): TreeNode[] => {
const result = scenarios.find((scenario) => scenario.given === data);
if (result !== undefined) {
return result.result;
}
return data.map(() => new TreeNodeStub());
};
return {
registerScenario,
parseTreeInputStub,
};
}

View File

@@ -0,0 +1,45 @@
import { NodeStateChangedEvent, TreeNodeStateAccess, TreeNodeStateTransaction } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess';
import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor';
import { TreeNodeStateDescriptorStub } from './TreeNodeStateDescriptorStub';
import { EventSourceStub } from './EventSourceStub';
import { TreeNodeStateTransactionStub } from './TreeNodeStateTransactionStub';
export class TreeNodeStateAccessStub implements TreeNodeStateAccess {
public current: TreeNodeStateDescriptor = new TreeNodeStateDescriptorStub();
public changed: EventSourceStub<NodeStateChangedEvent> = new EventSourceStub();
public toggleCheck(): void {
throw new Error('Method not implemented.');
}
public toggleExpand(): void {
throw new Error('Method not implemented.');
}
public beginTransaction(): TreeNodeStateTransaction {
return new TreeNodeStateTransactionStub();
}
public commitTransaction(transaction: TreeNodeStateTransaction): void {
const oldState = this.current;
const newState = {
...oldState,
...transaction.updatedState,
};
this.current = newState;
this.changed.notify({
oldState,
newState,
});
}
public triggerStateChangedEvent(event: NodeStateChangedEvent) {
this.changed.notify(event);
}
public withCurrent(state: TreeNodeStateDescriptor): this {
this.current = state;
return this;
}
}

View File

@@ -0,0 +1,24 @@
import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState';
import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor';
export class TreeNodeStateDescriptorStub implements TreeNodeStateDescriptor {
public checkState: TreeNodeCheckState = TreeNodeCheckState.Checked;
public isExpanded = false;
public isVisible = false;
public isMatched = false;
public isFocused = false;
public withFocusState(isFocused: boolean): this {
this.isFocused = isFocused;
return this;
}
public withCheckState(checkState: TreeNodeCheckState): this {
this.checkState = checkState;
return this;
}
}

View File

@@ -0,0 +1,32 @@
import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState';
import { TreeNodeStateTransaction } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess';
import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor';
export class TreeNodeStateTransactionStub implements TreeNodeStateTransaction {
public withExpansionState(isExpanded: boolean): TreeNodeStateTransaction {
this.updatedState = { ...this.updatedState, isExpanded };
return this;
}
public withMatchState(isMatched: boolean): TreeNodeStateTransaction {
this.updatedState = { ...this.updatedState, isMatched };
return this;
}
public withFocusState(isFocused: boolean): TreeNodeStateTransaction {
this.updatedState = { ...this.updatedState, isFocused };
return this;
}
public withVisibilityState(isVisible: boolean): TreeNodeStateTransaction {
this.updatedState = { ...this.updatedState, isVisible };
return this;
}
public withCheckState(checkState: TreeNodeCheckState): TreeNodeStateTransaction {
this.updatedState = { ...this.updatedState, checkState };
return this;
}
public updatedState: Partial<TreeNodeStateDescriptor>;
}

View File

@@ -0,0 +1,35 @@
import { HierarchyAccess } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/HierarchyAccess';
import { TreeNodeStateAccess } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess';
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
import { NodeMetadataStub } from './NodeMetadataStub';
import { HierarchyAccessStub } from './HierarchyAccessStub';
export class TreeNodeStub implements TreeNode {
public state: TreeNodeStateAccess;
public hierarchy: HierarchyAccess = new HierarchyAccessStub();
public id: string;
public metadata?: object = new NodeMetadataStub();
public withMetadata(metadata: object): this {
this.metadata = metadata;
return this;
}
public withHierarchy(hierarchy: HierarchyAccess): this {
this.hierarchy = hierarchy;
return this;
}
public withState(state: TreeNodeStateAccess): this {
this.state = state;
return this;
}
public withId(id: string): this {
this.id = id;
return this;
}
}

View File

@@ -0,0 +1,16 @@
import { SingleNodeFocusManager } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeFocusManager';
import { TreeNodeCollection } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeCollection';
import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot';
import { TreeNodeCollectionStub } from './TreeNodeCollectionStub';
import { SingleNodeFocusManagerStub } from './SingleNodeFocusManagerStub';
export class TreeRootStub implements TreeRoot {
public collection: TreeNodeCollection = new TreeNodeCollectionStub();
public focus: SingleNodeFocusManager = new SingleNodeFocusManagerStub();
public withCollection(collection: TreeNodeCollection): this {
this.collection = collection;
return this;
}
}

View File

@@ -11,14 +11,14 @@ import { CategoryCollectionStateStub } from './CategoryCollectionStateStub';
import { EventSubscriptionCollectionStub } from './EventSubscriptionCollectionStub';
import { ApplicationContextStub } from './ApplicationContextStub';
import { UserFilterStub } from './UserFilterStub';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class UseCollectionStateStub {
export class UseCollectionStateStub
extends StubWithObservableMethodCalls<ReturnType<typeof useCollectionState>> {
private currentContext: IApplicationContext = new ApplicationContextStub();
private readonly currentState = ref<ICategoryCollectionState>(new CategoryCollectionStateStub());
private readonly onStateChangeHandlers = new Array<NewStateEventHandler>();
public withFilter(filter: IUserFilter) {
const state = new CategoryCollectionStateStub()
.withFilter(filter);
@@ -49,10 +49,18 @@ export class UseCollectionStateStub {
return this.currentState.value;
}
public triggerOnStateChange(newState: ICategoryCollectionState): void {
this.currentState.value = newState;
this.onStateChangeHandlers.forEach(
(handler) => handler(newState, undefined),
public triggerOnStateChange(scenario: {
readonly newState: ICategoryCollectionState,
readonly immediateOnly: boolean,
}): void {
this.currentState.value = scenario.newState;
let calls = this.callHistory.filter((call) => call.methodName === 'onStateChange');
if (scenario.immediateOnly) {
calls = calls.filter((call) => call.args[1].immediate === true);
}
const handlers = calls.map((call) => call.args[0] as NewStateEventHandler);
handlers.forEach(
(handler) => handler(scenario.newState, undefined),
);
}
@@ -63,15 +71,26 @@ export class UseCollectionStateStub {
if (settings?.immediate) {
handler(this.currentState.value, undefined);
}
this.onStateChangeHandlers.push(handler);
this.registerMethodCall({
methodName: 'onStateChange',
args: [handler, settings],
});
}
private modifyCurrentState(mutator: StateModifier) {
mutator(this.currentState.value);
this.registerMethodCall({
methodName: 'modifyCurrentState',
args: [mutator],
});
}
private modifyCurrentContext(mutator: ContextModifier) {
mutator(this.currentContext);
this.registerMethodCall({
methodName: 'modifyCurrentContext',
args: [mutator],
});
}
public get(): ReturnType<typeof useCollectionState> {

View File

@@ -3,19 +3,23 @@ import { SelectedScript } from '@/application/Context/State/Selection/SelectedSc
import { IScript } from '@/domain/IScript';
import { IEventSource } from '@/infrastructure/Events/IEventSource';
import { EventSource } from '@/infrastructure/Events/EventSource';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class UserSelectionStub implements IUserSelection {
export class UserSelectionStub
extends StubWithObservableMethodCalls<IUserSelection>
implements IUserSelection {
public readonly changed: IEventSource<readonly SelectedScript[]> = new EventSource<
readonly SelectedScript[]>();
public selectedScripts: readonly SelectedScript[] = [];
constructor(private readonly allScripts: readonly IScript[]) {
super();
}
public withSelectedScripts(selectedScripts: readonly SelectedScript[]) {
public withSelectedScripts(selectedScripts: readonly SelectedScript[]): this {
this.selectedScripts = selectedScripts;
return this;
}
public areAllSelected(): boolean {
@@ -34,16 +38,22 @@ export class UserSelectionStub implements IUserSelection {
throw new Error('Method not implemented.');
}
public addSelectedScript(): void {
throw new Error('Method not implemented.');
public addSelectedScript(scriptId: string, revert: boolean): void {
this.registerMethodCall({
methodName: 'addSelectedScript',
args: [scriptId, revert],
});
}
public addOrUpdateSelectedScript(): void {
throw new Error('Method not implemented.');
}
public removeSelectedScript(): void {
throw new Error('Method not implemented.');
public removeSelectedScript(scriptId: string): void {
this.registerMethodCall({
methodName: 'removeSelectedScript',
args: [scriptId],
});
}
public selectOnly(scripts: ReadonlyArray<IScript>): void {