fix search (got broken in b789250) with tests and refactorings

This commit is contained in:
undergroundwires
2020-09-02 22:44:20 +01:00
parent a23d28f2cf
commit 8bbe6ebf75
10 changed files with 379 additions and 97 deletions

View File

@@ -10,9 +10,9 @@ declare module 'liquor-tree' {
clearFilter(): void;
setModel(nodes: ReadonlyArray<ILiquorTreeNewNode>): 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<string>;
isReversible: boolean;
@@ -35,7 +35,7 @@ declare module 'liquor-tree' {
data: ILiquorTreeNodeData;
states: ILiquorTreeNodeState | undefined;
children: ReadonlyArray<ILiquorTreeExistingNode> | 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;
}

View File

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

View File

@@ -1,66 +0,0 @@
import { ILiquorTreeExistingNode, ILiquorTreeNewNode, ILiquorTreeNode } from 'liquor-tree';
import { NodeType } from './../Node/INode';
export function updateNodesCheckedState(
oldNodes: ReadonlyArray<ILiquorTreeExistingNode>,
selectedNodeIds: ReadonlyArray<string>): ReadonlyArray<ILiquorTreeNewNode> {
const result = new Array<ILiquorTreeNewNode>();
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<string>): 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<string> {
if (categoryNode.data.type !== NodeType.Category) {
throw new Error('Not a category node');
}
if (!categoryNode.children) {
return [];
}
const ids = new Array<string>();
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');
}
}

View File

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

View File

@@ -0,0 +1,38 @@
import { ILiquorTreeNode } from 'liquor-tree';
import { NodeType } from './../../Node/INode';
export function getNewCheckedState(
oldNode: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>): 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<string> {
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<string> {
switch (node.data.type) {
case NodeType.Script:
return [ node.id ];
case NodeType.Category:
return parseAllSubScriptIds(node);
default:
throw new Error('Unknown node type');
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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: [],
};
}

View File

@@ -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: [],
};
}

View File

@@ -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: [],
}],
}],
};
}