diff --git a/package.json b/package.json index 86413eee..98d72844 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "jest-axe": "^9.0.0", "jest-extended": "^4.0.2", "jest-fixed-jsdom": "^0.0.8", + "jsdom-testing-mocks": "^1.13.1", "jsonfile": "^6.1.0", "lodash": "^4.17.21", "msw": "^2.6.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e77cb575..0b489935 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: jest-fixed-jsdom: specifier: ^0.0.8 version: 0.0.8(jest-environment-jsdom@29.7.0) + jsdom-testing-mocks: + specifier: ^1.13.1 + version: 1.13.1 jsonfile: specifier: ^6.1.0 version: 6.1.0 @@ -1424,6 +1427,9 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bezier-easing@2.1.0: + resolution: {integrity: sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1546,6 +1552,9 @@ packages: resolution: {integrity: sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==} engines: {node: '>= 8'} + css-mediaquery@0.1.2: + resolution: {integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -2408,6 +2417,10 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + jsdom-testing-mocks@1.13.1: + resolution: {integrity: sha512-8BAsnuoO4DLGTf7LDbSm8fcx5CUHSv4h+bdUbwyt6rMYAXWjeHLRx9f8sYiSxoOTXy3S1e06pe87KER39o1ckA==} + engines: {node: '>=14'} + jsdom@20.0.3: resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} engines: {node: '>=14'} @@ -4973,6 +4986,8 @@ snapshots: balanced-match@1.0.2: {} + bezier-easing@2.1.0: {} + binary-extensions@2.3.0: {} brace-expansion@1.1.11: @@ -5102,6 +5117,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-mediaquery@0.1.2: {} + css.escape@1.5.1: {} cssesc@3.0.0: {} @@ -6194,6 +6211,11 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom-testing-mocks@1.13.1: + dependencies: + bezier-easing: 2.1.0 + css-mediaquery: 0.1.2 + jsdom@20.0.3: dependencies: abab: 2.0.6 diff --git a/src/dragAndDropHandler/binarySearch.ts b/src/dragAndDropHandler/binarySearch.ts new file mode 100644 index 00000000..18f2937f --- /dev/null +++ b/src/dragAndDropHandler/binarySearch.ts @@ -0,0 +1,27 @@ +function binarySearch(items: T[], compareFn: (a: T) => number): null | T { + let low = 0; + let high = items.length; + + while (low < high) { + const mid = (low + high) >> 1; + const item = items[mid]; + + if (item === undefined) { + return null; + } + + const compareResult = compareFn(item); + + if (compareResult > 0) { + high = mid; + } else if (compareResult < 0) { + low = mid + 1; + } else { + return item; + } + } + + return null; +} + +export default binarySearch; diff --git a/src/dragAndDropHandler/index.ts b/src/dragAndDropHandler/index.ts index d0a35305..747bd48d 100644 --- a/src/dragAndDropHandler/index.ts +++ b/src/dragAndDropHandler/index.ts @@ -16,6 +16,7 @@ import { Node } from "../node"; import NodeElement from "../nodeElement"; import { getPositionName, Position } from "../position"; import { getElementPosition } from "../util"; +import binarySearch from "./binarySearch"; import DragElement from "./dragElement"; import generateHitAreas from "./generateHitAreas"; import { DropHint, HitArea } from "./types"; @@ -114,18 +115,14 @@ export class DragAndDropHandler { this.currentItem = null; } - private canMoveToArea(area: HitArea): boolean { + private canMoveToArea(area: HitArea, currentItem: NodeElement): boolean { if (!this.onCanMoveTo) { return true; } - if (!this.currentItem) { - return false; - } - const positionName = getPositionName(area.position); - return this.onCanMoveTo(this.currentItem.node, area.node, positionName); + return this.onCanMoveTo(currentItem.node, area.node, positionName); } private clear(): void { @@ -147,37 +144,26 @@ export class DragAndDropHandler { return null; } - let low = 0; - let high = this.hitAreas.length; - while (low < high) { - const mid = (low + high) >> 1; - const area = this.hitAreas[mid]; - - if (!area) { - return null; - } - + return binarySearch(this.hitAreas, (area) => { if (y < area.top) { - high = mid; + return 1; } else if (y > area.bottom) { - low = mid + 1; + return -1; } else { - return area; + return 0; } - } - - return null; + }); } - private generateHitAreas(): void { + private generateHitAreas(currentNode: Node): void { const tree = this.getTree(); - if (!this.currentItem || !tree) { + if (!tree) { this.hitAreas = []; } else { this.hitAreas = generateHitAreas( tree, - this.currentItem.node, + currentNode, this.getTreeDimensions().bottom, ); } @@ -198,12 +184,13 @@ export class DragAndDropHandler { }; } + /* Move the dragged node to the selected position in the tree. */ private moveItem(positionInfo: PositionInfo): void { if ( this.currentItem && this.hoveredArea && this.hoveredArea.position !== Position.None && - this.canMoveToArea(this.hoveredArea) + this.canMoveToArea(this.hoveredArea, this.currentItem) ) { const movedNode = this.currentItem.node; const targetNode = this.hoveredArea.node; @@ -351,7 +338,7 @@ export class DragAndDropHandler { positionInfo.pageY, ); - if (area && this.canMoveToArea(area)) { + if (area && this.canMoveToArea(area, this.currentItem)) { if (!area.node.isFolder()) { this.stopOpenFolderTimer(); } @@ -440,11 +427,9 @@ export class DragAndDropHandler { this.removeHitAreas(); if (this.currentItem) { - this.generateHitAreas(); - - this.currentItem = this.getNodeElementForNode( - this.currentItem.node, - ); + const currentNode = this.currentItem.node; + this.generateHitAreas(currentNode); + this.currentItem = this.getNodeElementForNode(currentNode); if (this.isDragging) { this.currentItem.element.classList.add("jqtree-moving"); diff --git a/src/test/dragAndDropHandler/binarySearch.test.ts b/src/test/dragAndDropHandler/binarySearch.test.ts new file mode 100644 index 00000000..6e6d7259 --- /dev/null +++ b/src/test/dragAndDropHandler/binarySearch.test.ts @@ -0,0 +1,31 @@ +import binarySearch from "../../dragAndDropHandler/binarySearch"; + +it("returns null when the array is empty", () => { + const compareFn = (_item: number) => 0; + + const result = binarySearch([], compareFn); + expect(result).toBeNull(); +}); + +it("finds a value", () => { + const compareFn = (item: number) => item - 5; + + const result = binarySearch([1, 5, 7, 9], compareFn); + expect(result).toEqual(5); +}); + +it("returns null when the value doesn't exist", () => { + const compareFn = (item: number) => item - 6; + + const result = binarySearch([1, 5, 7, 9], compareFn); + expect(result).toBeNull(); +}); + +it("handles undefined values in the array", () => { + const compareFn = (item: number) => item - 6; + const array = [1, 5, 7, 9]; + (array as any)[1] = undefined; // eslint-disable-line @typescript-eslint/no-unsafe-member-access + + const result = binarySearch(array, compareFn); + expect(result).toBeNull(); +}); diff --git a/src/test/dragAndDropHandler/index.test.ts b/src/test/dragAndDropHandler/index.test.ts new file mode 100644 index 00000000..6ce7efeb --- /dev/null +++ b/src/test/dragAndDropHandler/index.test.ts @@ -0,0 +1,571 @@ +import { DragAndDropHandler } from "../../dragAndDropHandler"; +import { GetTree } from "../../jqtreeMethodTypes"; +import { DragMethod, OnIsMoveHandle } from "../../jqtreeOptions"; +import { Node } from "../../node"; +import NodeElement from "../../nodeElement"; +import { Position } from "../../position"; +import { generateHtmlElementsForTree } from "../support/testUtil"; + +interface CreateDragAndDropHandlerParams { + getTree?: GetTree; + onDragMove?: DragMethod; + onIsMoveHandle?: OnIsMoveHandle; + tree: Node; +} + +const createDragAndDropHandler = ({ + getTree, + onDragMove, + onIsMoveHandle, + tree, +}: CreateDragAndDropHandlerParams) => { + const getScrollLeft = jest.fn(); + const openNode = jest.fn(); + const refreshElements = jest.fn(); + const triggerEvent = jest.fn(); + + const elementForTree = generateHtmlElementsForTree(tree); + + const getNodeElementForNode = jest.fn( + (node: Node) => + new NodeElement({ + getScrollLeft, + node, + treeElement: elementForTree, + }), + ); + + const getNodeElement = jest.fn((element: HTMLElement) => { + let resultNode: Node | null = null; + + tree.iterate((node) => { + if ( + node.element === element || + node.element === element.parentElement + ) { + resultNode = node; + return false; + } + + return true; + }); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (resultNode) { + return new NodeElement({ + getScrollLeft, + node: resultNode, + treeElement: elementForTree, + }); + } else { + return null; + } + }); + + return new DragAndDropHandler({ + getNodeElement, + getNodeElementForNode, + getScrollLeft, + getTree: getTree ?? jest.fn(() => tree), + onDragMove, + onIsMoveHandle, + openFolderDelay: false, + openNode, + refreshElements, + slide: false, + treeElement: elementForTree, + triggerEvent, + }); +}; + +beforeEach(() => { + document.body.innerHTML = ""; +}); + +describe(".mouseCapture", () => { + it("sets the current item and returns true when a node can be moved", () => { + const tree = new Node(null, true); + const node1 = new Node({ name: "node1" }); + tree.addChild(node1); + const node2 = new Node({ name: "node2" }); + tree.addChild(node2); + + const dragAndDropHandler = createDragAndDropHandler({ tree }); + expect(dragAndDropHandler.currentItem).toBeNull(); + + const positionInfo = { + originalEvent: new Event("click"), + pageX: 10, + pageY: 10, + target: node1.element as HTMLElement, + }; + + expect(dragAndDropHandler.mouseCapture(positionInfo)).toBeTrue(); + expect(dragAndDropHandler.currentItem?.node).toBe(node1); + }); + + it("doesn'set the current item and returns false when no node can be moved", () => { + const tree = new Node(null, true); + const node1 = new Node({ name: "node1" }); + tree.addChild(node1); + const node2 = new Node({ name: "node2" }); + tree.addChild(node2); + + const element = document.createElement("div"); + + const dragAndDropHandler = createDragAndDropHandler({ tree }); + + const positionInfo = { + originalEvent: new Event("click"), + pageX: 200, + pageY: 10, + target: element, + }; + + expect(dragAndDropHandler.mouseCapture(positionInfo)).toBeFalse(); + expect(dragAndDropHandler.currentItem).toBeNull(); + }); + + it("capures the node when an element inside a node element is clicked", () => { + const tree = new Node(null, true); + const node1 = new Node({ name: "node1" }); + tree.addChild(node1); + const node2 = new Node({ name: "node2" }); + tree.addChild(node2); + + const dragAndDropHandler = createDragAndDropHandler({ tree }); + + const element = document.createElement("div"); + (node2.element as HTMLElement).appendChild(element); + + const positionInfo = { + originalEvent: new Event("click"), + pageX: 10, + pageY: 30, + target: element, + }; + + expect(dragAndDropHandler.mouseCapture(positionInfo)).toBeTrue(); + expect(dragAndDropHandler.currentItem?.node).toBe(node2); + }); + + it("doesn't capture the node and returns null when an input element is clicked", () => { + const tree = new Node(null, true); + const node1 = new Node({ name: "node1" }); + tree.addChild(node1); + const node2 = new Node({ name: "node2" }); + tree.addChild(node2); + + const dragAndDropHandler = createDragAndDropHandler({ tree }); + + const element = document.createElement("input"); + (node2.element as HTMLElement).appendChild(element); + + const positionInfo = { + originalEvent: new Event("click"), + pageX: 10, + pageY: 30, + target: element, + }; + + expect(dragAndDropHandler.mouseCapture(positionInfo)).toBeNull(); + expect(dragAndDropHandler.currentItem).toBeNull(); + }); + + it("captures the node when onIsMoveHandle returns true", () => { + const tree = new Node(null, true); + const node1 = new Node({ name: "node1" }); + tree.addChild(node1); + const node2 = new Node({ name: "node2" }); + tree.addChild(node2); + + const onIsMoveHandle = jest.fn( + (jQueryElement: JQuery) => jQueryElement.get(0) === node1.element, + ); + const dragAndDropHandler = createDragAndDropHandler({ + onIsMoveHandle, + tree, + }); + + const positionInfo = { + originalEvent: new Event("click"), + pageX: 10, + pageY: 10, + target: node1.element as HTMLElement, + }; + + expect(dragAndDropHandler.mouseCapture(positionInfo)).toBeTrue(); + expect(dragAndDropHandler.currentItem?.node).toBe(node1); + + expect(onIsMoveHandle).toHaveBeenCalled(); + }); + + it("doesn't capture the node when onIsMoveHandle returns false", () => { + const tree = new Node(null, true); + const node1 = new Node({ name: "node1" }); + tree.addChild(node1); + const node2 = new Node({ name: "node2" }); + tree.addChild(node2); + + const onIsMoveHandle = jest.fn(() => false); + const dragAndDropHandler = createDragAndDropHandler({ + onIsMoveHandle, + tree, + }); + + const positionInfo = { + originalEvent: new Event("click"), + pageX: 10, + pageY: 10, + target: node1.element as HTMLElement, + }; + + expect(dragAndDropHandler.mouseCapture(positionInfo)).toBeNull(); + expect(dragAndDropHandler.currentItem).toBeNull(); + + expect(onIsMoveHandle).toHaveBeenCalled(); + }); +}); + +describe(".mouseStart", () => { + it("sets dragging to true and returns true", () => { + const tree = new Node(null, true); + const node1 = new Node({ name: "node1" }); + tree.addChild(node1); + const node2 = new Node({ name: "node2" }); + tree.addChild(node2); + + const dragAndDropHandler = createDragAndDropHandler({ tree }); + + // Set current item + const positionInfo = { + originalEvent: new Event("click"), + pageX: 10, + pageY: 10, + target: node1.element as HTMLElement, + }; + + dragAndDropHandler.mouseCapture(positionInfo); + expect(dragAndDropHandler.currentItem?.node).toBe(node1); + expect(dragAndDropHandler.isDragging).toBeFalse(); + + // mouseStart + expect(dragAndDropHandler.mouseStart(positionInfo)).toBeTrue(); + expect(dragAndDropHandler.isDragging).toBeTrue(); + }); + + it("adds the jqtree-moving css class", () => { + const tree = new Node(null, true); + const node1 = new Node({ name: "node1" }); + tree.addChild(node1); + const node2 = new Node({ name: "node2" }); + tree.addChild(node2); + + const dragAndDropHandler = createDragAndDropHandler({ tree }); + // Set current item + const positionInfo = { + originalEvent: new Event("click"), + pageX: 10, + pageY: 10, + target: node1.element as HTMLElement, + }; + + dragAndDropHandler.mouseCapture(positionInfo); + + // mouseStart + dragAndDropHandler.mouseStart(positionInfo); + + expect(node1.element?.classList).toContain("jqtree-moving"); + }); + + it("creates a drag element", () => { + const tree = new Node(null, true); + const node1 = new Node({ name: "node1" }); + tree.addChild(node1); + const node2 = new Node({ name: "node2" }); + tree.addChild(node2); + + const dragAndDropHandler = createDragAndDropHandler({ tree }); + + // Set current item + const positionInfo = { + originalEvent: new Event("click"), + pageX: 10, + pageY: 10, + target: node1.element as HTMLElement, + }; + + dragAndDropHandler.mouseCapture(positionInfo); + + // mouseStart + dragAndDropHandler.mouseStart(positionInfo); + + expect(document.querySelector(".jqtree-dragging")).toBeInTheDocument(); + }); + + it("sets dragging to false and returns false when there is no current item", () => { + const tree = new Node(null, true); + const node1 = new Node({ name: "node1" }); + tree.addChild(node1); + const node2 = new Node({ name: "node2" }); + tree.addChild(node2); + + const dragAndDropHandler = createDragAndDropHandler({ tree }); + + const positionInfo = { + originalEvent: new Event("click"), + pageX: 10, + pageY: 10, + target: node1.element as HTMLElement, + }; + + expect(dragAndDropHandler.mouseStart(positionInfo)).toBeFalse(); + expect(dragAndDropHandler.isDragging).toBeFalse(); + }); +}); + +describe(".mouseDrag", () => { + it("moves the drag element and returns true", () => { + const tree = new Node(null, true); + const node1 = new Node({ name: "node1" }); + tree.addChild(node1); + const node2 = new Node({ name: "node2" }); + tree.addChild(node2); + + const dragAndDropHandler = createDragAndDropHandler({ tree }); + + // Start dragging + const positionInfo = { + originalEvent: new Event("click"), + pageX: 10, + pageY: 10, + target: node1.element as HTMLElement, + }; + + dragAndDropHandler.mouseCapture(positionInfo); + + dragAndDropHandler.mouseStart(positionInfo); + expect(dragAndDropHandler.isDragging).toBeTrue(); + + // Move mouse + const dragResult = dragAndDropHandler.mouseDrag({ + originalEvent: new Event("mousemove"), + pageX: 15, + pageY: 30, + target: node2.element as HTMLElement, + }); + expect(dragResult).toBeTrue(); + + const dragElement = document.querySelector(".jqtree-dragging"); + expect(dragElement).toHaveStyle({ + left: "5px", + position: "absolute", + top: "20px", + }); + }); + + it("changes the hovered area", () => { + const tree = new Node(null, true); + const node1 = new Node({ name: "node1" }); + tree.addChild(node1); + const node2 = new Node({ name: "node2" }); + tree.addChild(node2); + + const dragAndDropHandler = createDragAndDropHandler({ tree }); + + // Start dragging + const positionInfo = { + originalEvent: new Event("click"), + pageX: 10, + pageY: 10, + target: node1.element as HTMLElement, + }; + + dragAndDropHandler.mouseCapture(positionInfo); + + dragAndDropHandler.mouseStart(positionInfo); + expect(dragAndDropHandler.isDragging).toBeTrue(); + expect(dragAndDropHandler.hoveredArea).toBeNull(); + + // Move mouse + dragAndDropHandler.mouseDrag({ + originalEvent: new Event("mousemove"), + pageX: 15, + pageY: 30, + target: node2.element as HTMLElement, + }); + + expect(dragAndDropHandler.hoveredArea).toEqual( + expect.objectContaining({ + bottom: 38, + node: node2, + position: Position.Inside, + top: 20, + }), + ); + }); + + it("returns false when dragging hasn't started", () => { + const tree = new Node(null, true); + const node1 = new Node({ name: "node1" }); + tree.addChild(node1); + const node2 = new Node({ name: "node2" }); + tree.addChild(node2); + + const dragAndDropHandler = createDragAndDropHandler({ tree }); + + const dragResult = dragAndDropHandler.mouseDrag({ + originalEvent: new Event("mousemove"), + pageX: 15, + pageY: 30, + target: node2.element as HTMLElement, + }); + expect(dragResult).toBeFalse(); + }); + + it("sets area to null when no area is found", () => { + const tree = new Node(null, true); + const node1 = new Node({ name: "node1" }); + tree.addChild(node1); + const node2 = new Node({ name: "node2" }); + tree.addChild(node2); + + const dragAndDropHandler = createDragAndDropHandler({ + tree, + }); + + // Start dragging + const positionInfo = { + originalEvent: new Event("click"), + pageX: 10, + pageY: 30, + target: node1.element as HTMLElement, + }; + + dragAndDropHandler.mouseCapture(positionInfo); + + dragAndDropHandler.mouseStart(positionInfo); + + dragAndDropHandler.mouseDrag({ + originalEvent: new Event("mousemove"), + pageX: 15, + pageY: 200, + target: document.body, + }); + + expect(dragAndDropHandler.hoveredArea).toBeNull(); + }); + + it("calls onDragMove when no area is found and onDragMove is defined", () => { + const tree = new Node(null, true); + const node1 = new Node({ name: "node1" }); + tree.addChild(node1); + const node2 = new Node({ name: "node2" }); + tree.addChild(node2); + + const onDragMove = jest.fn(); + + const dragAndDropHandler = createDragAndDropHandler({ + onDragMove, + tree, + }); + + // Start dragging + const positionInfo = { + originalEvent: new Event("click"), + pageX: 10, + pageY: 30, + target: node1.element as HTMLElement, + }; + + dragAndDropHandler.mouseCapture(positionInfo); + + dragAndDropHandler.mouseStart(positionInfo); + expect(dragAndDropHandler.isDragging).toBeTrue(); + expect(dragAndDropHandler.hoveredArea).toBeNull(); + + const positionInfoForDragging = { + originalEvent: new Event("mousemove"), + pageX: 15, + pageY: 200, + target: document.body, + }; + + // Move mouse + dragAndDropHandler.mouseDrag(positionInfoForDragging); + + expect(onDragMove).toHaveBeenCalledWith( + node1, + positionInfoForDragging.originalEvent, + ); + }); +}); + +describe(".refresh", () => { + it("generates hit areas", () => { + const tree = new Node(null, true); + const node1 = new Node({ name: "node1" }); + tree.addChild(node1); + const node2 = new Node({ name: "node2" }); + tree.addChild(node2); + + const dragAndDropHandler = createDragAndDropHandler({ tree }); + + // Set current item + const positionInfo = { + originalEvent: new Event("click"), + pageX: 10, + pageY: 10, + target: node1.element as HTMLElement, + }; + + dragAndDropHandler.mouseCapture(positionInfo); + expect(dragAndDropHandler.currentItem?.node).toBe(node1); + + // Call refresh + dragAndDropHandler.refresh(); + + expect(dragAndDropHandler.hitAreas).toMatchObject([ + expect.objectContaining({ + bottom: 38, + node: node2, + position: Position.Inside, + top: 20, + }), + expect.objectContaining({ + bottom: 56, + node: node2, + position: Position.After, + top: 38, + }), + ]); + }); + + it("doesn't generates hit areas when the tree is set to null", () => { + const tree = new Node(null, true); + const node1 = new Node({ name: "node1" }); + tree.addChild(node1); + const node2 = new Node({ name: "node2" }); + tree.addChild(node2); + + const getTree = jest.fn(() => null); + + const dragAndDropHandler = createDragAndDropHandler({ getTree, tree }); + + // Set current item + const positionInfo = { + originalEvent: new Event("click"), + pageX: 10, + pageY: 10, + target: node1.element as HTMLElement, + }; + + dragAndDropHandler.mouseCapture(positionInfo); + expect(dragAndDropHandler.currentItem?.node).toBe(node1); + + // Call refresh + dragAndDropHandler.refresh(); + + expect(dragAndDropHandler.hitAreas).toBeEmpty(); + }); +}); diff --git a/src/test/mouseHandler.test.ts b/src/test/mouseHandler.test.ts new file mode 100644 index 00000000..b877a1f8 --- /dev/null +++ b/src/test/mouseHandler.test.ts @@ -0,0 +1,424 @@ +import MouseHandler from "../mouseHandler"; +import { Node } from "../node"; + +interface CreateMouseHandlerParams { + element: HTMLElement; + getNode?: jest.Mock; + onClickButton?: jest.Mock; + onMouseCapture?: jest.Mock; + onMouseDrag?: jest.Mock; + onMouseStart?: jest.Mock; + onMouseStop?: jest.Mock; + triggerEvent?: jest.Mock; +} + +const createMouseHandler = ({ + element, + getNode = jest.fn(), + onClickButton = jest.fn(), + onMouseCapture = jest.fn(), + onMouseDrag = jest.fn(), + onMouseStart = jest.fn(), + onMouseStop = jest.fn(), + triggerEvent = jest.fn(), +}: CreateMouseHandlerParams) => { + const getMouseDelay = jest.fn(); + const onClickTitle = jest.fn(); + + return new MouseHandler({ + element, + getMouseDelay, + getNode, + onClickButton, + onClickTitle, + onMouseCapture, + onMouseDrag, + onMouseStart, + onMouseStop, + triggerEvent, + useContextMenu: true, + }); +}; + +describe("handleClick", () => { + it("handles a button click", () => { + const element = document.createElement("div"); + + const button = document.createElement("button"); + button.classList.add("jqtree-toggler"); + element.appendChild(button); + + document.body.append(element); + + const node = new Node(); + + const getNode = jest.fn((element: HTMLElement) => { + if (element === button) { + return node; + } else { + return null; + } + }); + + const onClickButton = jest.fn(); + + createMouseHandler({ element, getNode, onClickButton }); + + const event = new MouseEvent("click", { bubbles: true }); + button.dispatchEvent(event); + + expect(onClickButton).toHaveBeenCalledWith(node); + }); + + it("handles a click with an empty target", () => { + const element = document.createElement("div"); + const onClickButton = jest.fn(); + createMouseHandler({ element, onClickButton }); + + const event = new MouseEvent("click"); + jest.spyOn(event, "target", "get").mockReturnValue(null); + + element.dispatchEvent(event); + + expect(onClickButton).not.toHaveBeenCalled(); + }); +}); + +describe("handleContextmenu", () => { + it("handles a context menu event on a node", () => { + const treeElement = document.createElement("ul"); + treeElement.classList.add("jqtree-tree"); + document.body.appendChild(treeElement); + + const nodeElement = document.createElement("div"); + nodeElement.className = "jqtree-element"; + treeElement.appendChild(nodeElement); + + const node = new Node(); + + const getNode = jest.fn((element: HTMLElement) => { + if (element === nodeElement) { + return node; + } else { + return null; + } + }); + + const triggerEvent = jest.fn(); + + createMouseHandler({ element: nodeElement, getNode, triggerEvent }); + + const event = new MouseEvent("contextmenu", { bubbles: true }); + nodeElement.dispatchEvent(event); + + expect(triggerEvent).toHaveBeenCalledWith("tree.contextmenu", { + click_event: event, + node, + }); + }); + + it("handles a context menu event that's not on a node", () => { + const element = document.createElement("div"); + document.body.appendChild(element); + + const getNode = jest.fn(() => null); + const triggerEvent = jest.fn(); + + createMouseHandler({ element, getNode, triggerEvent }); + + const event = new MouseEvent("contextmenu", { bubbles: true }); + element.dispatchEvent(event); + + expect(triggerEvent).not.toHaveBeenCalled(); + }); + + it("handles a context menu event without a target", () => { + const element = document.createElement("div"); + document.body.appendChild(element); + + const triggerEvent = jest.fn(); + + createMouseHandler({ element, triggerEvent }); + + const event = new MouseEvent("contextmenu", { bubbles: true }); + jest.spyOn(event, "target", "get").mockReturnValue(null); + + element.dispatchEvent(event); + + expect(triggerEvent).not.toHaveBeenCalled(); + }); +}); + +describe("handleDblclick", () => { + it("handles a double click on a label", () => { + const element = document.createElement("div"); + + const label = document.createElement("div"); + label.classList.add("jqtree-element"); + element.appendChild(label); + + document.body.append(element); + + const node = new Node(); + + const getNode = jest.fn((element: HTMLElement) => { + if (element === label) { + return node; + } else { + return null; + } + }); + + const triggerEvent = jest.fn(); + + createMouseHandler({ element, getNode, triggerEvent }); + + const event = new MouseEvent("dblclick", { bubbles: true }); + label.dispatchEvent(event); + + expect(triggerEvent).toHaveBeenCalledWith("tree.dblclick", { + click_event: event, + node, + }); + }); + + it("handles a double click event without a target", () => { + const element = document.createElement("div"); + document.body.appendChild(element); + + const triggerEvent = jest.fn(); + + createMouseHandler({ element, triggerEvent }); + + const event = new MouseEvent("dblclick", { bubbles: true }); + jest.spyOn(event, "target", "get").mockReturnValue(null); + + element.dispatchEvent(event); + + expect(triggerEvent).not.toHaveBeenCalled(); + }); +}); + +describe("touchStart", () => { + it("handles a touchstart event", () => { + const element = document.createElement("div"); + document.body.append(element); + + const onMouseCapture = jest.fn(); + + createMouseHandler({ element, onMouseCapture }); + + const touch = { + pageX: 0, + pageY: 0, + }; + + const event = new TouchEvent("touchstart", { + bubbles: true, + touches: [touch as Touch], + }); + element.dispatchEvent(event); + + expect(onMouseCapture).toHaveBeenCalledWith({ + originalEvent: event, + pageX: 0, + pageY: 0, + target: undefined, + }); + }); + + it("handles a touchstart event with multiple touches", () => { + const element = document.createElement("div"); + document.body.append(element); + + const onMouseCapture = jest.fn(); + + createMouseHandler({ element, onMouseCapture }); + + const touch = { + pageX: 0, + pageY: 0, + } as Touch; + + const event = new TouchEvent("touchstart", { + bubbles: true, + touches: [touch, touch], + }); + element.dispatchEvent(event); + + expect(onMouseCapture).not.toHaveBeenCalled(); + }); + + it("handles a touchstart event without touches", () => { + const element = document.createElement("div"); + document.body.append(element); + + const onMouseCapture = jest.fn(); + + createMouseHandler({ element, onMouseCapture }); + + const event = new TouchEvent("touchstart", { + bubbles: true, + touches: [], + }); + element.dispatchEvent(event); + + expect(onMouseCapture).not.toHaveBeenCalled(); + }); +}); + +describe("touchEnd", () => { + it("handles a touchend event after a touchstart and a touchmove event", () => { + const element = document.createElement("div"); + document.body.append(element); + + const onMouseCapture = jest.fn(() => true); + const onMouseStart = jest.fn(() => true); + const onMouseStop = jest.fn(); + + createMouseHandler({ + element, + onMouseCapture, + onMouseStart, + onMouseStop, + }); + + const touch = { + pageX: 0, + pageY: 0, + }; + + const touchStartEvent = new TouchEvent("touchstart", { + bubbles: true, + touches: [touch as Touch], + }); + element.dispatchEvent(touchStartEvent); + + const touchMoveEvent = new TouchEvent("touchmove", { + bubbles: true, + touches: [touch as Touch], + }); + element.dispatchEvent(touchMoveEvent); + + const touchEndEvent = new TouchEvent("touchend", { + bubbles: true, + touches: [touch as Touch], + }); + element.dispatchEvent(touchEndEvent); + + expect(onMouseStop).toHaveBeenCalledWith({ + originalEvent: touchEndEvent, + pageX: 0, + pageY: 0, + }); + }); + + it("handles a touchend with multiple touches", () => { + const element = document.createElement("div"); + document.body.append(element); + + const onMouseCapture = jest.fn(() => true); + const onMouseStart = jest.fn(() => true); + const onMouseStop = jest.fn(); + + createMouseHandler({ + element, + onMouseCapture, + onMouseStart, + onMouseStop, + }); + + const touch = { + pageX: 0, + pageY: 0, + } as Touch; + + const touchStartEvent = new TouchEvent("touchstart", { + bubbles: true, + touches: [touch], + }); + element.dispatchEvent(touchStartEvent); + + const touchMoveEvent = new TouchEvent("touchmove", { + bubbles: true, + touches: [touch], + }); + element.dispatchEvent(touchMoveEvent); + + const touchEndEvent = new TouchEvent("touchend", { + bubbles: true, + touches: [touch, touch], + }); + element.dispatchEvent(touchEndEvent); + + expect(onMouseStop).not.toHaveBeenCalled(); + }); +}); + +describe("touchMove", () => { + it("handles a touchmove event without touches", () => { + const element = document.createElement("div"); + document.body.append(element); + + const onMouseCapture = jest.fn(() => true); + const onMouseDrag = jest.fn(); + + createMouseHandler({ + element, + onMouseCapture, + onMouseDrag, + }); + + const touch = { + pageX: 0, + pageY: 0, + } as Touch; + + const touchStartEvent = new TouchEvent("touchstart", { + bubbles: true, + touches: [touch], + }); + element.dispatchEvent(touchStartEvent); + + const touchMoveEvent = new TouchEvent("touchmove", { + bubbles: true, + touches: [], + }); + element.dispatchEvent(touchMoveEvent); + + expect(onMouseDrag).not.toHaveBeenCalled(); + }); + + it("handles a touchmove event with multiple touches", () => { + const element = document.createElement("div"); + document.body.append(element); + + const onMouseCapture = jest.fn(() => true); + const onMouseDrag = jest.fn(); + + createMouseHandler({ + element, + onMouseCapture, + onMouseDrag, + }); + + const touch = { + pageX: 0, + pageY: 0, + } as Touch; + + const touchStartEvent = new TouchEvent("touchstart", { + bubbles: true, + touches: [touch], + }); + element.dispatchEvent(touchStartEvent); + + const touchMoveEvent = new TouchEvent("touchmove", { + bubbles: true, + touches: [touch, touch], + }); + element.dispatchEvent(touchMoveEvent); + + expect(onMouseDrag).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/saveStateHandler.test.ts b/src/test/saveStateHandler.test.ts new file mode 100644 index 00000000..f340210f --- /dev/null +++ b/src/test/saveStateHandler.test.ts @@ -0,0 +1,178 @@ +import { OnFinishOpenNode } from "../jqtreeMethodTypes"; +import { Node } from "../node"; +import SaveStateHandler from "../saveStateHandler"; + +const createSaveStateHandler = ({ + addToSelection = jest.fn(), + getNodeById = jest.fn(), + getSelectedNodes = jest.fn(), + openNode = jest.fn(), + refreshElements = jest.fn(), + removeFromSelection = jest.fn(), +}) => { + const getTree = jest.fn(); + + return new SaveStateHandler({ + addToSelection, + getNodeById, + getSelectedNodes, + getTree, + openNode, + refreshElements, + removeFromSelection, + saveState: true, + }); +}; + +describe("getStateFromStorage", () => { + afterEach(() => { + localStorage.clear(); + }); + + it("returns null when the state is not in local storage", () => { + localStorage.clear(); + + const saveStateHandler = createSaveStateHandler({}); + expect(saveStateHandler.getStateFromStorage()).toBeNull(); + }); + + it("returns an array of selected nodes when 'selected_node' in the states is a number", () => { + localStorage.setItem("tree", JSON.stringify({ selected_node: 123 })); + + const saveStateHandler = createSaveStateHandler({}); + expect(saveStateHandler.getStateFromStorage()).toEqual({ + selected_node: [123], + }); + }); +}); + +describe("setInitialState", () => { + it("deselects nodes that are currently selected", () => { + const node = new Node({ id: 123 }); + + const getSelectedNodes = jest.fn(() => [node]); + const removeFromSelection = jest.fn(); + + const saveStateHandler = createSaveStateHandler({ + getSelectedNodes, + removeFromSelection, + }); + saveStateHandler.setInitialState({}); + + expect(removeFromSelection).toHaveBeenCalledWith(node); + }); +}); + +describe("setInitialStateOnDemand", () => { + it("doesn't open a node when open_nodes in the state is empty", () => { + const openNode = jest.fn(); + + const saveStateHandler = createSaveStateHandler({ openNode }); + saveStateHandler.setInitialStateOnDemand({}, jest.fn()); + + expect(openNode).not.toHaveBeenCalled(); + }); + + it("opens a node when the node id is in open_nodes in the state", () => { + const node = new Node({ id: 123 }); + const getNodeById = jest.fn((nodeId) => { + if (nodeId === 123) { + return node; + } else { + return null; + } + }); + const openNode = jest.fn(); + + const saveStateHandler = createSaveStateHandler({ + getNodeById, + openNode, + }); + saveStateHandler.setInitialStateOnDemand( + { open_nodes: [123] }, + jest.fn(), + ); + + expect(openNode).toHaveBeenCalledWith(node, false); + }); + + it("selects a node and redraws the tree when the node id is in selected_node in the state", () => { + const node = new Node({ id: 123 }); + const getNodeById = jest.fn((nodeId) => { + if (nodeId === 123) { + return node; + } else { + return null; + } + }); + const addToSelection = jest.fn(); + const refreshElements = jest.fn(); + + const saveStateHandler = createSaveStateHandler({ + addToSelection, + getNodeById, + refreshElements, + }); + + saveStateHandler.setInitialStateOnDemand( + { open_nodes: [123], selected_node: [123] }, + jest.fn(), + ); + + expect(addToSelection).toHaveBeenCalledWith(node); + expect(refreshElements).toHaveBeenCalledWith(null); + }); + + it("opens nodes recursively", () => { + const node1 = new Node({ id: 1, load_on_demand: true }); + const node2 = new Node({ id: 2 }); + let calledGetNodeByIdForNode2 = false; + + const getNodeById = jest.fn((nodeId) => { + switch (nodeId) { + case 1: + return node1; + case 2: { + // Return the node the second time. + if (calledGetNodeByIdForNode2) { + return node2; + } else { + calledGetNodeByIdForNode2 = true; + return null; + } + } + default: + return null; + } + }); + + const openNode = jest.fn( + (node: Node, _slide: boolean, onFinished?: OnFinishOpenNode) => { + node.load_on_demand = false; + + if (onFinished) { + onFinished(node); + } + }, + ); + + const saveStateHandler = createSaveStateHandler({ + getNodeById, + openNode, + }); + + saveStateHandler.setInitialStateOnDemand( + { open_nodes: [1, 2] }, + jest.fn(), + ); + + expect(openNode).toHaveBeenNthCalledWith( + 1, + node1, + false, + expect.toBeFunction(), + ); + expect(openNode).toHaveBeenNthCalledWith(2, node1, false); + expect(openNode).toHaveBeenNthCalledWith(3, node2, false); + }); +}); diff --git a/src/test/support/testUtil.ts b/src/test/support/testUtil.ts index 6f660195..54f66085 100644 --- a/src/test/support/testUtil.ts +++ b/src/test/support/testUtil.ts @@ -1,3 +1,14 @@ +import { mockElementBoundingClientRect } from "jsdom-testing-mocks"; + +import { Node } from "../../node"; + +interface Rect { + height: number; + width: number; + x: number; + y: number; +} + export const singleChild = ($el: JQuery, selector: string): JQuery => { const $result = $el.children(selector); @@ -22,3 +33,50 @@ export const togglerLink = (liNode: HTMLElement | JQuery): JQuery => const nodeElement = (liNode: HTMLElement | JQuery): JQuery => singleChild(jQuery(liNode), "div.jqtree-element "); + +export const mockLayout = (element: HTMLElement, rect: Rect) => { + jest.spyOn(element, "clientHeight", "get").mockReturnValue(rect.height); + jest.spyOn(element, "clientWidth", "get").mockReturnValue(rect.width); + jest.spyOn(element, "offsetParent", "get").mockReturnValue( + element.parentElement, + ); + + mockElementBoundingClientRect(element, rect); +}; + +export const generateHtmlElementsForTree = (tree: Node) => { + let y = 0; + + function generateHtmlElementsForNode( + node: Node, + parentElement: HTMLElement, + x: number, + ) { + const isTree = node.tree === node; + const element = document.createElement("div"); + parentElement.append(element); + + if (!isTree) { + mockLayout(element, { height: 20, width: 100 - x, x, y }); + node.element = element; + y += 20; + } + + if (node.hasChildren() && (node.is_open || isTree)) { + for (const child of node.children) { + generateHtmlElementsForNode( + child, + element, + isTree ? x : x + 10, + ); + } + } + + return element; + } + + const treeElement = generateHtmlElementsForNode(tree, document.body, 0); + mockLayout(treeElement, { height: y, width: 100, x: 0, y: 0 }); + + return treeElement; +}; diff --git a/tsconfig.json b/tsconfig.json index e6e4ea1f..c41ac446 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "rootDir": "src", + "skipLibCheck": true, "strictNullChecks": true, "strictPropertyInitialization": false, "strict": true,