Fix loss of tree node state when switching views

This commit fixes an issue where the check state of categories was lost
when toggling between card and tree views. This is solved by immediately
emitting node state changes for all nodes. This ensures consistent view
transitions without any loss of node state information.

Furthermore, this commit includes added unit tests for the modified code
sections.
This commit is contained in:
undergroundwires
2023-09-24 20:34:47 +02:00
parent 0303ef2fd9
commit 8f188acd3c
33 changed files with 1606 additions and 175 deletions

View File

@@ -0,0 +1,19 @@
import { DelayScheduler } from '@/presentation/components/Scripts/View/Tree/TreeView/Rendering/DelayScheduler';
export class DelaySchedulerStub implements DelayScheduler {
public nextCallback: () => void | undefined = undefined;
public scheduleNext(callback: () => void): void {
this.nextCallback = callback;
}
public runNextScheduled(): void {
if (!this.nextCallback) {
throw new Error('no callback is scheduled');
}
// Store the callback to prevent changes to this.nextCallback during execution
const callback = this.nextCallback;
this.nextCallback = undefined;
callback();
}
}

View File

@@ -44,7 +44,7 @@ export class EventSubscriptionCollectionStub
methodName: 'unsubscribeAllAndRegister',
args: [subscriptions],
});
this.unsubscribeAll();
this.register(subscriptions);
// Not calling other methods to avoid registering method calls.
this.subscriptions.splice(0, this.subscriptions.length, ...subscriptions);
}
}

View File

@@ -39,4 +39,9 @@ export class HierarchyAccessStub implements HierarchyAccess {
this.isBranchNode = value;
return this;
}
public withIsLeafNode(value: boolean): this {
this.isLeafNode = value;
return this;
}
}

View File

@@ -0,0 +1,48 @@
import { NodeStateChangeEventArgs } from '@/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator';
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor';
import { TreeNodeStub } from './TreeNodeStub';
import { TreeNodeStateDescriptorStub } from './TreeNodeStateDescriptorStub';
import { HierarchyAccessStub } from './HierarchyAccessStub';
export class NodeStateChangeEventArgsStub implements NodeStateChangeEventArgs {
public node: TreeNode = new TreeNodeStub()
.withId(`[${NodeStateChangeEventArgsStub.name}] node-stub`);
public newState: TreeNodeStateDescriptor = new TreeNodeStateDescriptorStub();
public oldState?: TreeNodeStateDescriptor = new TreeNodeStateDescriptorStub();
public withNode(node: TreeNode): this {
this.node = node;
return this;
}
public withNewState(newState: TreeNodeStateDescriptor): this {
this.newState = newState;
return this;
}
public withOldState(oldState: TreeNodeStateDescriptor): this {
this.oldState = oldState;
return this;
}
}
export function createChangeEvent(scenario: {
readonly oldState?: TreeNodeStateDescriptor;
readonly newState: TreeNodeStateDescriptor;
readonly hierarchyBuilder?: (hierarchy: HierarchyAccessStub) => HierarchyAccessStub;
}) {
let nodeHierarchy = new HierarchyAccessStub();
if (scenario.hierarchyBuilder) {
nodeHierarchy = scenario.hierarchyBuilder(nodeHierarchy);
}
const changeEvent = new NodeStateChangeEventArgsStub()
.withOldState(scenario.oldState)
.withNewState(scenario.newState)
.withNode(new TreeNodeStub()
.withId(`[${createChangeEvent.name}] node-stub`)
.withHierarchy(nodeHierarchy));
return changeEvent;
}

View File

@@ -1,4 +1,3 @@
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';
@@ -17,17 +16,4 @@ export class NodeStateChangedEventStub implements NodeStateChangedEvent {
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

@@ -1,10 +1,16 @@
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
import { QueryableNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/QueryableNodes';
import { TreeNodeStub } from './TreeNodeStub';
export class QueryableNodesStub implements QueryableNodes {
public rootNodes: readonly TreeNode[];
public rootNodes: readonly TreeNode[] = [
new TreeNodeStub().withId(`[${QueryableNodesStub.name}] root-node-stub`),
];
public flattenedNodes: readonly TreeNode[];
public flattenedNodes: readonly TreeNode[] = [
new TreeNodeStub().withId(`[${QueryableNodesStub.name}] flattened-node-stub-1`),
new TreeNodeStub().withId(`[${QueryableNodesStub.name}] flattened-node-stub-2`),
];
public getNodeById(): TreeNode {
throw new Error('Method not implemented.');

View File

@@ -3,7 +3,8 @@ import { SingleNodeFocusManager } from '@/presentation/components/Scripts/View/T
import { TreeNodeStub } from './TreeNodeStub';
export class SingleNodeFocusManagerStub implements SingleNodeFocusManager {
public currentSingleFocusedNode: TreeNode = new TreeNodeStub();
public currentSingleFocusedNode: TreeNode = new TreeNodeStub()
.withId(`[${SingleNodeFocusManagerStub.name}] focused-node-stub`);
setSingleFocus(): void { /* NOOP */ }
}

View File

@@ -1,9 +1,10 @@
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';
import { QueryableNodesStub } from './QueryableNodesStub';
export class TreeNodeCollectionStub implements TreeNodeCollection {
public nodes: QueryableNodes;
public nodes: QueryableNodes = new QueryableNodesStub();
public nodesUpdated = new EventSourceStub<QueryableNodes>();

View File

@@ -20,7 +20,8 @@ export function createTreeNodeParserStub() {
if (result !== undefined) {
return result.result;
}
return data.map(() => new TreeNodeStub());
return data.map(() => new TreeNodeStub()
.withId(`[${createTreeNodeParserStub.name}] parsed-node-stub`));
};
return {
registerScenario,

View File

@@ -1,10 +1,13 @@
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 { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState';
import { TreeNodeStateDescriptorStub } from './TreeNodeStateDescriptorStub';
import { EventSourceStub } from './EventSourceStub';
import { TreeNodeStateTransactionStub } from './TreeNodeStateTransactionStub';
export class TreeNodeStateAccessStub implements TreeNodeStateAccess {
public isStateModificationRequested = false;
public current: TreeNodeStateDescriptor = new TreeNodeStateDescriptorStub();
public changed: EventSourceStub<NodeStateChangedEvent> = new EventSourceStub();
@@ -32,6 +35,7 @@ export class TreeNodeStateAccessStub implements TreeNodeStateAccess {
oldState,
newState,
});
this.isStateModificationRequested = true;
}
public triggerStateChangedEvent(event: NodeStateChangedEvent) {
@@ -42,4 +46,26 @@ export class TreeNodeStateAccessStub implements TreeNodeStateAccess {
this.current = state;
return this;
}
public withCurrentCheckState(checkState: TreeNodeCheckState): this {
return this.withCurrent(
new TreeNodeStateDescriptorStub()
.withCheckState(checkState),
);
}
public withCurrentVisibility(isVisible: boolean): this {
return this.withCurrent(
new TreeNodeStateDescriptorStub()
.withVisibility(isVisible),
);
}
}
export function createAccessStubsFromCheckStates(
states: readonly TreeNodeCheckState[],
): TreeNodeStateAccessStub[] {
return states.map(
(checkState) => new TreeNodeStateAccessStub().withCurrentCheckState(checkState),
);
}

View File

@@ -0,0 +1,41 @@
import { TreeNodeStateChangedEmittedEvent } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeNodeStateChangedEmittedEvent';
import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState';
import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor';
import { ReadOnlyTreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
import { TreeNodeStateDescriptorStub } from './TreeNodeStateDescriptorStub';
export class TreeNodeStateChangedEmittedEventStub implements TreeNodeStateChangedEmittedEvent {
public node: ReadOnlyTreeNode;
public oldState?: TreeNodeStateDescriptor;
public newState: TreeNodeStateDescriptor;
public withNode(node: ReadOnlyTreeNode): this {
this.node = node;
return this;
}
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

@@ -12,7 +12,7 @@ export class TreeNodeStateDescriptorStub implements TreeNodeStateDescriptor {
public isFocused = false;
public withFocusState(isFocused: boolean): this {
public withFocus(isFocused: boolean): this {
this.isFocused = isFocused;
return this;
}
@@ -21,4 +21,9 @@ export class TreeNodeStateDescriptorStub implements TreeNodeStateDescriptor {
this.checkState = checkState;
return this;
}
public withVisibility(isVisible: boolean): this {
this.isVisible = isVisible;
return this;
}
}

View File

@@ -3,13 +3,24 @@ import { TreeNodeStateAccess } from '@/presentation/components/Scripts/View/Tree
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
import { NodeMetadataStub } from './NodeMetadataStub';
import { HierarchyAccessStub } from './HierarchyAccessStub';
import { TreeNodeStateAccessStub } from './TreeNodeStateAccessStub';
export class TreeNodeStub implements TreeNode {
public state: TreeNodeStateAccess;
public static fromStates(
states: readonly TreeNodeStateAccess[],
): TreeNodeStub[] {
return states.map(
(state) => new TreeNodeStub()
.withId(`[${TreeNodeStub.fromStates.name}] node-stub`)
.withState(state),
);
}
public state: TreeNodeStateAccess = new TreeNodeStateAccessStub();
public hierarchy: HierarchyAccess = new HierarchyAccessStub();
public id: string;
public id = 'tree-node-stub-id';
public metadata?: object = new NodeMetadataStub();

View File

@@ -2,9 +2,11 @@ import { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hook
import { EventSubscriptionCollectionStub } from './EventSubscriptionCollectionStub';
export class UseAutoUnsubscribedEventsStub {
public readonly events = new EventSubscriptionCollectionStub();
public get(): ReturnType<typeof useAutoUnsubscribedEvents> {
return {
events: new EventSubscriptionCollectionStub(),
events: this.events,
};
}
}

View File

@@ -0,0 +1,32 @@
import {
WatchSource, readonly, shallowRef, triggerRef,
} from 'vue';
import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot';
import { useCurrentTreeNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/UseCurrentTreeNodes';
import { QueryableNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/QueryableNodes';
import { QueryableNodesStub } from './QueryableNodesStub';
export class UseCurrentTreeNodesStub {
public treeWatcher: WatchSource<TreeRoot> | undefined;
private nodes = shallowRef<QueryableNodes>(new QueryableNodesStub());
public withQueryableNodes(nodes: QueryableNodes): this {
this.nodes.value = nodes;
return this;
}
public triggerNewNodes(nodes: QueryableNodes) {
this.nodes.value = nodes;
triggerRef(this.nodes);
}
public get(): typeof useCurrentTreeNodes {
return (treeWatcher: WatchSource<TreeRoot>) => {
this.treeWatcher = treeWatcher;
return {
nodes: readonly(this.nodes),
};
};
}
}

View File

@@ -0,0 +1,33 @@
import { WatchSource } from 'vue';
import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot';
import {
NodeStateChangeEventArgs,
NodeStateChangeEventCallback,
useNodeStateChangeAggregator,
} from '@/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator';
export class UseNodeStateChangeAggregatorStub {
public callback: NodeStateChangeEventCallback | undefined;
public treeWatcher: WatchSource<TreeRoot> | undefined;
public onNodeStateChange(callback: NodeStateChangeEventCallback) {
this.callback = callback;
}
public notifyChange(change: NodeStateChangeEventArgs) {
if (!this.callback) {
throw new Error('callback is not set');
}
this.callback(change);
}
public get(): typeof useNodeStateChangeAggregator {
return (treeWatcher: WatchSource<TreeRoot>) => {
this.treeWatcher = treeWatcher;
return {
onNodeStateChange: this.onNodeStateChange.bind(this),
};
};
}
}