diff --git a/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/LiquorTree.d.ts b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/LiquorTree.d.ts index ed7f9a6b..4af1ff73 100644 --- a/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/LiquorTree.d.ts +++ b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/LiquorTree.d.ts @@ -10,9 +10,9 @@ declare module 'liquor-tree' { clearFilter(): void; setModel(nodes: ReadonlyArray): void; // getNodeById(id: string): ILiquorTreeExistingNode; - // recurseDown(fn: (node: ILiquorTreeExistingNode) => void): void; + recurseDown(fn: (node: ILiquorTreeExistingNode) => void): void; } - interface ICustomLiquorTreeData { + export interface ICustomLiquorTreeData { type: number; documentationUrls: ReadonlyArray; isReversible: boolean; @@ -35,7 +35,7 @@ declare module 'liquor-tree' { data: ILiquorTreeNodeData; states: ILiquorTreeNodeState | undefined; children: ReadonlyArray | undefined; - expand(): void; + // expand(): void; } /** @@ -60,7 +60,7 @@ declare module 'liquor-tree' { deletion(node: ILiquorTreeNode): boolean; } - interface ILiquorTreeNodeData extends ICustomLiquorTreeData { + export interface ILiquorTreeNodeData extends ICustomLiquorTreeData { text: string; } diff --git a/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/LiquorTreeOptions.ts b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/LiquorTreeOptions.ts index 7dfc636f..e7cff70f 100644 --- a/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/LiquorTreeOptions.ts +++ b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/LiquorTreeOptions.ts @@ -1,15 +1,21 @@ -import { ILiquorTreeOptions, ILiquorTreeFilter, ILiquorTreeNode } from 'liquor-tree'; +import { ILiquorTreeOptions, ILiquorTreeFilter, ILiquorTreeNode, ILiquorTreeExistingNode } from 'liquor-tree'; export class LiquorTreeOptions implements ILiquorTreeOptions { - public multiple = true; - public checkbox = true; - public checkOnSelect = true; + public readonly multiple = true; + public readonly checkbox = true; + public readonly checkOnSelect = true; /* For checkbox mode only. Children will have the same checked state as their parent. This is false as it's handled manually to be able to batch select for performance + highlighting */ - public autoCheckChildren = false; - public parentSelect = false; - public keyboardNavigation = true; - constructor(public filter: ILiquorTreeFilter) { } + public readonly autoCheckChildren = false; + public readonly parentSelect = false; + public readonly keyboardNavigation = true; + public readonly filter = { // Wrap this in an arrow function as setting filter directly does not work JS APIs + emptyText: this.liquorTreeFilter.emptyText, + matcher: (query: string, node: ILiquorTreeExistingNode) => { + return this.liquorTreeFilter.matcher(query, node); + }, + }; + constructor(private readonly liquorTreeFilter: ILiquorTreeFilter) { } public deletion(node: ILiquorTreeNode): boolean { return false; // no op } diff --git a/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeStateUpdater.ts b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeStateUpdater.ts deleted file mode 100644 index 6967549f..00000000 --- a/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeStateUpdater.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { ILiquorTreeExistingNode, ILiquorTreeNewNode, ILiquorTreeNode } from 'liquor-tree'; -import { NodeType } from './../Node/INode'; - -export function updateNodesCheckedState( - oldNodes: ReadonlyArray, - selectedNodeIds: ReadonlyArray): ReadonlyArray { - const result = new Array(); - for (const oldNode of oldNodes) { - const newState = oldNode.states; - newState.checked = getNewCheckedState(oldNode, selectedNodeIds); - const newNode: ILiquorTreeNewNode = { - id: oldNode.id, - text: oldNode.data.text, - data: { - type: oldNode.data.type, - documentationUrls: oldNode.data.documentationUrls, - isReversible: oldNode.data.isReversible, - }, - children: !oldNode.children ? [] : updateNodesCheckedState(oldNode.children, selectedNodeIds), - state: newState, - }; - result.push(newNode); - } - return result; -} - -export function getNewCheckedState( - oldNode: ILiquorTreeNode, - selectedNodeIds: ReadonlyArray): boolean { - switch (oldNode.data.type) { - case NodeType.Script: - return selectedNodeIds.some((id) => id === oldNode.id); - case NodeType.Category: - return parseAllSubScriptIds(oldNode).every((id) => selectedNodeIds.includes(id)); - default: - throw new Error('Unknown node type'); - } -} - -function parseAllSubScriptIds(categoryNode: ILiquorTreeNode): ReadonlyArray { - if (categoryNode.data.type !== NodeType.Category) { - throw new Error('Not a category node'); - } - if (!categoryNode.children) { - return []; - } - const ids = new Array(); - for (const child of categoryNode.children) { - addNodeIds(child, ids); - } - return ids; -} - -function addNodeIds(node: ILiquorTreeNode, ids: string[]) { - switch (node.data.type) { - case NodeType.Script: - ids.push(node.id); - break; - case NodeType.Category: - const subCategoryIds = parseAllSubScriptIds(node); - ids.push(...subCategoryIds); - break; - default: - throw new Error('Unknown node type'); - } -} diff --git a/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodePredicateFilter.ts b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodePredicateFilter.ts similarity index 83% rename from src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodePredicateFilter.ts rename to src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodePredicateFilter.ts index d012449f..ae6710fe 100644 --- a/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodePredicateFilter.ts +++ b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodePredicateFilter.ts @@ -1,11 +1,11 @@ import { ILiquorTreeFilter, ILiquorTreeExistingNode } from 'liquor-tree'; import { convertExistingToNode } from './NodeTranslator'; -import { INode } from '../Node/INode'; +import { INode } from './../../Node/INode'; export type FilterPredicate = (node: INode) => boolean; export class NodePredicateFilter implements ILiquorTreeFilter { - public emptyText: string = '🕵️Hmm.. Can not see one 🧐'; + public emptyText = ''; // Does not matter as a custom mesage is shown constructor(private readonly filterPredicate: FilterPredicate) { if (!filterPredicate) { throw new Error('filterPredicate is undefined'); diff --git a/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeStateUpdater.ts b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeStateUpdater.ts new file mode 100644 index 00000000..1e2a1c9f --- /dev/null +++ b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeStateUpdater.ts @@ -0,0 +1,38 @@ +import { ILiquorTreeNode } from 'liquor-tree'; +import { NodeType } from './../../Node/INode'; + +export function getNewCheckedState( + oldNode: ILiquorTreeNode, + selectedNodeIds: ReadonlyArray): boolean { + switch (oldNode.data.type) { + case NodeType.Script: + return selectedNodeIds.some((id) => id === oldNode.id); + case NodeType.Category: + return parseAllSubScriptIds(oldNode).every((id) => selectedNodeIds.includes(id)); + default: + throw new Error('Unknown node type'); + } +} + +function parseAllSubScriptIds(categoryNode: ILiquorTreeNode): ReadonlyArray { + if (categoryNode.data.type !== NodeType.Category) { + throw new Error('Not a category node'); + } + if (!categoryNode.children) { + return []; + } + return categoryNode + .children + .flatMap((child) => getNodeIds(child)); +} + +function getNodeIds(node: ILiquorTreeNode): ReadonlyArray { + switch (node.data.type) { + case NodeType.Script: + return [ node.id ]; + case NodeType.Category: + return parseAllSubScriptIds(node); + default: + throw new Error('Unknown node type'); + } +} diff --git a/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeTranslator.ts b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeTranslator.ts similarity index 97% rename from src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeTranslator.ts rename to src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeTranslator.ts index a583a545..7e947c6d 100644 --- a/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeTranslator.ts +++ b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeTranslator.ts @@ -1,5 +1,5 @@ import { ILiquorTreeNewNode, ILiquorTreeExistingNode } from 'liquor-tree'; -import { INode } from './../Node/INode'; +import { INode } from './../../Node/INode'; // Functions to translate INode to LiqourTree models and vice versa for anti-corruption diff --git a/src/presentation/Scripts/ScriptsTree/SelectableTree/SelectableTree.vue b/src/presentation/Scripts/ScriptsTree/SelectableTree/SelectableTree.vue index cf1ac042..078eb3b8 100644 --- a/src/presentation/Scripts/ScriptsTree/SelectableTree/SelectableTree.vue +++ b/src/presentation/Scripts/ScriptsTree/SelectableTree/SelectableTree.vue @@ -21,11 +21,11 @@ import LiquorTree, { ILiquorTreeNewNode, ILiquorTreeExistingNode, ILiquorTree } from 'liquor-tree'; import Node from './Node/Node.vue'; import { INode } from './Node/INode'; - import { convertExistingToNode, toNewLiquorTreeNode } from './LiquorTree/NodeTranslator'; + import { convertExistingToNode, toNewLiquorTreeNode } from './LiquorTree/NodeWrapper/NodeTranslator'; import { INodeSelectedEvent } from './/INodeSelectedEvent'; - import { updateNodesCheckedState, getNewCheckedState } from './LiquorTree/NodeStateUpdater'; + import { getNewCheckedState } from './LiquorTree/NodeWrapper/NodeStateUpdater'; import { LiquorTreeOptions } from './LiquorTree/LiquorTreeOptions'; - import { FilterPredicate, NodePredicateFilter } from './LiquorTree/NodePredicateFilter'; + import { FilterPredicate, NodePredicateFilter } from './LiquorTree/NodeWrapper/NodePredicateFilter'; /** Wrapper for Liquor Tree, reveals only abstracted INode for communication */ @Component({ @@ -84,19 +84,9 @@ if (!selectedNodeIds) { throw new Error('Selected nodes are undefined'); } - const newNodes = updateNodesCheckedState(this.getLiquorTreeApi().model, selectedNodeIds); - this.getLiquorTreeApi().setModel(newNodes); - /* Alternative: - this.getLiquorTreeApi().recurseDown((node) => { - node.states.checked = selectedNodeIds.includes(node.id); - }); - Problem: Does not check their parent if all children are checked, because it does not - trigger update on parent as we work with scripts not categories. */ - /* Alternative: - this.getLiquorTreeApi().recurseDown((node) => { - if(selectedNodeIds.includes(node.id)) { node.select(); } else { node.unselect(); } - }); - Problem: Emits nodeSelected() event again which will cause an infinite loop. */ + this.getLiquorTreeApi().recurseDown((node) => { + node.states.checked = getNewCheckedState(node, selectedNodeIds); + }); } private getLiquorTreeApi(): ILiquorTree { diff --git a/tests/unit/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodePredicateFilter.spec.ts b/tests/unit/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodePredicateFilter.spec.ts new file mode 100644 index 00000000..7c0535cd --- /dev/null +++ b/tests/unit/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodePredicateFilter.spec.ts @@ -0,0 +1,63 @@ +import 'mocha'; +import { expect } from 'chai'; +import { NodeType, INode } from '@/presentation/Scripts/ScriptsTree/SelectableTree/Node/INode'; +import { NodePredicateFilter } from '@/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodePredicateFilter'; +import { ILiquorTreeExistingNode } from 'liquor-tree'; + +describe('NodePredicateFilter', () => { + it('calls predicate with expected node', () => { + // arrange + const object: ILiquorTreeExistingNode = { + id: 'script', + data: { + text: 'script-text', + type: NodeType.Script, + documentationUrls: [], + isReversible: false, + }, + states: undefined, + children: [], + }; + const expected: INode = { + id: 'script', + text: 'script-text', + isReversible: false, + documentationUrls: [], + children: [], + type: NodeType.Script, + }; + let actual: INode; + const predicate = (node: INode) => { actual = node; return true; }; + const sut = new NodePredicateFilter(predicate); + // act + sut.matcher('nop query', object); + // assert + expect(actual).to.deep.equal(expected); + }); + describe('returns result from the predicate', () => { + for (const expected of [false, true]) { + it(expected.toString(), () => { + // arrange + const sut = new NodePredicateFilter(() => expected); + // act + const actual = sut.matcher('nop query', getExistingNode()); + // assert + expect(actual).to.equal(expected); + }); + } + }); +}); + +function getExistingNode(): ILiquorTreeExistingNode { + return { + id: 'script', + data: { + text: 'script-text', + type: NodeType.Script, + documentationUrls: [], + isReversible: false, + }, + states: undefined, + children: [], + }; +} diff --git a/tests/unit/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeStateUpdater.spec.ts b/tests/unit/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeStateUpdater.spec.ts new file mode 100644 index 00000000..297fde47 --- /dev/null +++ b/tests/unit/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeStateUpdater.spec.ts @@ -0,0 +1,110 @@ +import 'mocha'; +import { expect } from 'chai'; +import { ILiquorTreeNode } from 'liquor-tree'; +import { NodeType } from '@/presentation/Scripts/ScriptsTree/SelectableTree/Node/INode'; +import { getNewCheckedState } from '@/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeStateUpdater'; + +describe('getNewCheckedState', () => { + describe('script node', () => { + it('state is true when selected', () => { + // arrange + const node = getScriptNode(); + const selectedScriptNodeIds = [ 'a', 'b', node.id, 'c' ]; + // act + const actual = getNewCheckedState(node, selectedScriptNodeIds); + // assert + expect(actual).to.equal(true); + }); + it('state is false when unselected', () => { + // arrange + const node = getScriptNode(); + const selectedScriptNodeIds = [ 'a', 'b', 'c' ]; + // act + const actual = getNewCheckedState(node, selectedScriptNodeIds); + // assert + expect(actual).to.equal(false); + }); + }); + describe('category node', () => { + it('state is true when every child selected', () => { + // arrange + const node = { + id: '1', + data: { type: NodeType.Category, documentationUrls: [], isReversible: false }, + children: [ + { id: '2', + data: { type: NodeType.Category, documentationUrls: [], isReversible: false }, + children: [ getScriptNode('a'), getScriptNode('b') ], + }, + { id: '3', + data: { type: NodeType.Category, documentationUrls: [], isReversible: false }, + children: [ getScriptNode('c') ], + }, + ], + }; + const selectedScriptNodeIds = [ 'a', 'b', 'c' ]; + // act + const actual = getNewCheckedState(node, selectedScriptNodeIds); + // assert + expect(actual).to.equal(true); + }); + it('state is false when none of the children is selected', () => { + // arrange + const node = { + id: '1', + data: { type: NodeType.Category, documentationUrls: [], isReversible: false }, + children: [ + { id: '2', + data: { type: NodeType.Category, documentationUrls: [], isReversible: false }, + children: [ getScriptNode('a'), getScriptNode('b') ], + }, + { id: '3', + data: { type: NodeType.Category, documentationUrls: [], isReversible: false }, + children: [ getScriptNode('c') ], + }, + ], + }; + const selectedScriptNodeIds = [ 'none', 'of', 'them', 'are', 'selected' ]; + // act + const actual = getNewCheckedState(node, selectedScriptNodeIds); + // assert + expect(actual).to.equal(false); + }); + it('state is false when some of the children is selected', () => { + // arrange + const node = { + id: '1', + data: { type: NodeType.Category, documentationUrls: [], isReversible: false }, + children: [ + { + id: '2', + data: { type: NodeType.Category, documentationUrls: [], isReversible: false }, + children: [ getScriptNode('a'), getScriptNode('b') ], + }, + { + id: '3', + data: { type: NodeType.Category, documentationUrls: [], isReversible: false }, + children: [ getScriptNode('c') ], + }, + ], + }; + const selectedScriptNodeIds = [ 'a', 'c', 'unrelated' ]; + // act + const actual = getNewCheckedState(node, selectedScriptNodeIds); + // assert + expect(actual).to.equal(false); + }); + }); +}); + +function getScriptNode(scriptNodeId: string = 'script'): ILiquorTreeNode { + return { + id: scriptNodeId, + data: { + type: NodeType.Script, + documentationUrls: [], + isReversible: false, + }, + children: [], + }; +} diff --git a/tests/unit/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeTranslator.spec.ts b/tests/unit/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeTranslator.spec.ts new file mode 100644 index 00000000..06e8ac77 --- /dev/null +++ b/tests/unit/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeTranslator.spec.ts @@ -0,0 +1,141 @@ +import 'mocha'; +import { expect } from 'chai'; +import { NodeType, INode } from '@/presentation/Scripts/ScriptsTree/SelectableTree/Node/INode'; +import { ILiquorTreeExistingNode, ILiquorTreeNewNode, ILiquorTreeNodeData, ICustomLiquorTreeData } from 'liquor-tree'; +import { convertExistingToNode, toNewLiquorTreeNode } from '@/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeTranslator'; + +describe('NodeTranslator', () => { + it('convertExistingToNode', () => { + // arrange + const existingNode = getExistingNode(); + const expected = getNode(); + // act + const actual = convertExistingToNode(existingNode); + // assert + expect(actual).to.deep.equal(expected); + }); + it('toNewLiquorTreeNode', () => { + // arrange + const node = getNode(); + const expected = getNewNode(); + // act + const actual = toNewLiquorTreeNode(node); + // assert + expect(actual).to.deep.equal(expected); + }); +}); + +function getNode(): INode { + return { + id: '1', + text: 'parentcategory', + isReversible: true, + type: NodeType.Category, + documentationUrls: [ 'parentcategory-url1', 'parentcategory-url2 '], + children: [ + { + id: '2', + text: 'subcategory', + isReversible: true, + documentationUrls: [ 'subcategory-url1', 'subcategory-url2 '], + type: NodeType.Category, + children: [ + { + id: 'script1', + text: 'cool script 1', + isReversible: true, + documentationUrls: [ 'script1url1', 'script1url2'], + children: [], + type: NodeType.Script, + }, + { + id: 'script2', + text: 'cool script 2', + isReversible: true, + documentationUrls: [ 'script2url1', 'script2url2'], + children: [], + type: NodeType.Script, + }], + }], + }; +} + +function getExpectedExistingNodeData(node: INode): ILiquorTreeNodeData { + return { + text: node.text, + type: node.type, + documentationUrls: node.documentationUrls, + isReversible: node.isReversible, + }; +} +function getExpectedNewNodeData(node: INode): ICustomLiquorTreeData { + return { + type: node.type, + documentationUrls: node.documentationUrls, + isReversible: node.isReversible, + }; +} + +function getExistingNode(): ILiquorTreeExistingNode { + const base = getNode(); + return { + id: base.id, + data: getExpectedExistingNodeData(base), + states: undefined, + children: [ + { + id: base.children[0].id, + data: getExpectedExistingNodeData(base.children[0]), + states: undefined, + children: [ + { + id: base.children[0].children[0].id, + data: getExpectedExistingNodeData(base.children[0].children[0]), + states: undefined, + children: [], + }, + { + id: base.children[0].children[1].id, + data: getExpectedExistingNodeData(base.children[0].children[1]), + states: undefined, + children: [], + }], + }], + }; +} + +function getNewNode(): ILiquorTreeNewNode { + const base = getNode(); + const commonState = { + checked: false, + }; + return { + id: base.id, + text: base.text, + data: getExpectedNewNodeData(base), + state: commonState, + children: [ + { + id: base.children[0].id, + text: base.children[0].text, + data: getExpectedNewNodeData(base.children[0]), + state: commonState, + children: [ + { + id: base.children[0].children[0].id, + text: base.children[0].children[0].text, + data: getExpectedNewNodeData(base.children[0].children[0]), + state: commonState, + children: [], + }, + { + id: base.children[0].children[1].id, + text: base.children[0].children[1].text, + data: getExpectedNewNodeData(base.children[0].children[1]), + state: commonState, + children: [], + }], + }], + }; +} +