diff --git a/package-lock.json b/package-lock.json index 11d670f279..93ca72a90b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1693,6 +1693,285 @@ "loader-utils": "^1.2.3" } }, + "@testing-library/dom": { + "version": "7.28.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.28.1.tgz", + "integrity": "sha512-acv3l6kDwZkQif/YqJjstT3ks5aaI33uxGNVIQmdKzbZ2eMKgg3EV2tB84GDdc72k3Kjhl6mO8yUt6StVIdRDg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^4.2.0", + "aria-query": "^4.2.2", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.4", + "lz-string": "^1.4.4", + "pretty-format": "^26.6.2" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "dev": true, + "requires": { + "@babel/highlight": "^7.10.4" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz", + "integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==", + "dev": true + }, + "@babel/highlight": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz", + "integrity": "sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.10.4", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + }, + "dependencies": { + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + } + } + }, + "@babel/runtime": { + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz", + "integrity": "sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "@types/yargs": { + "version": "15.0.10", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.10.tgz", + "integrity": "sha512-z8PNtlhrj7eJNLmrAivM7rjBESG6JwC5xP3RVk12i/8HVP7Xnx/sEmERnRImyEuUaJfO942X0qMOYsoupaJbZQ==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "aria-query": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz", + "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.10.2", + "@babel/runtime-corejs3": "^7.10.2" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "pretty-format": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", + "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + } + } + }, + "react-is": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz", + "integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==", + "dev": true + }, + "regenerator-runtime": { + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", + "dev": true + } + } + }, + "@testing-library/react": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-11.2.2.tgz", + "integrity": "sha512-jaxm0hwUjv+hzC+UFEywic7buDC9JQ1q3cDsrWVSDAPmLotfA6E6kUHlYm/zOeGCac6g48DR36tFHxl7Zb+N5A==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^7.28.1" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz", + "integrity": "sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "regenerator-runtime": { + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", + "dev": true + } + } + }, + "@testing-library/react-hooks": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-3.4.2.tgz", + "integrity": "sha512-RfPG0ckOzUIVeIqlOc1YztKgFW+ON8Y5xaSPbiBkfj9nMkkiLhLeBXT5icfPX65oJV/zCZu4z8EVnUc6GY9C5A==", + "dev": true, + "requires": { + "@babel/runtime": "^7.5.4", + "@types/testing-library__react-hooks": "^3.4.0" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz", + "integrity": "sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "regenerator-runtime": { + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", + "dev": true + } + } + }, + "@testing-library/user-event": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-12.3.0.tgz", + "integrity": "sha512-A4TZofjkOH42ydTtHZcGNhwYjonkVIGBi4pmNweUgjDEGmWHuZf4k7hLd6QTL+rkSOgrd3TOCFDNyK9KO0reeA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.10.2" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz", + "integrity": "sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "regenerator-runtime": { + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", + "dev": true + } + } + }, + "@types/aria-query": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.0.tgz", + "integrity": "sha512-iIgQNzCm0v7QMhhe4Jjn9uRh+I6GoPmt03CbEtwx3ao8/EfoQcmgtqH4vQ5Db/lxiIGaWDv6nwvunuh0RyX0+A==", + "dev": true + }, "@types/babel__core": { "version": "7.1.8", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.8.tgz", @@ -2090,6 +2369,15 @@ "@types/react": "*" } }, + "@types/testing-library__react-hooks": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@types/testing-library__react-hooks/-/testing-library__react-hooks-3.4.1.tgz", + "integrity": "sha512-G4JdzEcq61fUyV6wVW9ebHWEiLK2iQvaBuCHHn9eMSbZzVh4Z4wHnUGIvQOYCCYeu5DnUtFyNYuAAgbSaO/43Q==", + "dev": true, + "requires": { + "@types/react-test-renderer": "*" + } + }, "@types/validator": { "version": "13.1.1", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.1.1.tgz", @@ -5091,6 +5379,12 @@ "esutils": "^2.0.2" } }, + "dom-accessibility-api": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz", + "integrity": "sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ==", + "dev": true + }, "dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", @@ -8954,6 +9248,12 @@ } } }, + "lz-string": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", + "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=", + "dev": true + }, "make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", diff --git a/package.json b/package.json index 813017464a..1a436da6c7 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,9 @@ }, "devDependencies": { "@sillsdev/react-localize-crowdin": "^1.0.0", + "@testing-library/react": "^11.2.2", + "@testing-library/react-hooks": "^3.4.2", + "@testing-library/user-event": "^12.3.0", "@types/jest": "^26.0.16", "@types/node": "^14.14.10", "@types/nspell": "^2.1.0", diff --git a/src/components/TreeView/TreeDepiction.tsx b/src/components/TreeView/TreeDepiction.tsx index 4ea94fc856..e875867b44 100644 --- a/src/components/TreeView/TreeDepiction.tsx +++ b/src/components/TreeView/TreeDepiction.tsx @@ -14,7 +14,7 @@ import { } from "../../resources/tree"; import DomainTile, { Direction } from "./DomainTile"; import SemanticDomainWithSubdomains from "../../types/SemanticDomain"; -import TreeViewHeader from "./TreeViewHeader"; +import { TreeViewHeader } from "./TreeViewHeader"; export const MAX_TILE_WIDTH = 150; export const MIN_TILE_WIDTH = 75; diff --git a/src/components/TreeView/TreeViewHeader.tsx b/src/components/TreeView/TreeViewHeader.tsx index 9315ab02fa..0e03adf234 100644 --- a/src/components/TreeView/TreeViewHeader.tsx +++ b/src/components/TreeView/TreeViewHeader.tsx @@ -6,118 +6,146 @@ import { TextField, Typography, } from "@material-ui/core"; -import React from "react"; +import React, { useCallback, useEffect, useState } from "react"; import Bounce from "react-reveal/Bounce"; import SemanticDomainWithSubdomains from "../../types/SemanticDomain"; import DomainTile, { Direction } from "./DomainTile"; -interface TreeHeaderProps { +export interface TreeHeaderProps { currentDomain: SemanticDomainWithSubdomains; animate: (domain: SemanticDomainWithSubdomains) => Promise; bounceState: number; bounce: () => void; } -interface TreeHeaderState { - input: string; +export function TreeViewHeader(props: TreeHeaderProps) { + const { + getLeftBrother, + getRightBrother, + searchAndSelectDomain, + handleChange, + } = useTreeViewNavigation(props); + + return ( + + + {getLeftBrother(props) ? ( + { + props.animate(e); + props.bounce(); + }} + direction={Direction.Left} + /> + ) : null} + + + + + + + + + + + {getRightBrother(props) ? ( + { + props.animate(e); + props.bounce(); + }} + direction={Direction.Right} + /> + ) : null} + + + ); } -export default class TreeViewHeader extends React.Component< - TreeHeaderProps, - TreeHeaderState -> { - animating: boolean; - - constructor(props: TreeHeaderProps) { - super(props); - this.state = { input: props.currentDomain.id }; - this.animating = false; - - this.searchAndSelectDomain = this.searchAndSelectDomain.bind(this); - this.navigateDomainArrowKeys = this.navigateDomainArrowKeys.bind(this); - this.handleChange = this.handleChange.bind(this); - this.updateDomain = this.updateDomain.bind(this); - } +// exported for unit testing only +export function useTreeViewNavigation(props: TreeHeaderProps) { + const [input, setInput] = useState(props.currentDomain.id); + // Gets the domain 'navigationAmount' away from the currentDomain (negative to the left, positive to the right) + function getBrotherDomain( + navigationAmount: number, + props: TreeHeaderProps + ): SemanticDomainWithSubdomains | undefined { + if (props.currentDomain.parentDomain) { + const brotherDomains = props.currentDomain.parentDomain.subdomains; + let index = brotherDomains.findIndex( + (domain) => props.currentDomain.id === domain.id + ); - componentDidMount() { - window.addEventListener("keydown", this.navigateDomainArrowKeys); - } + index += navigationAmount; + if (0 <= index && index < brotherDomains.length) + return brotherDomains[index]; + } - componentWillUnmount() { - window.removeEventListener("keydown", this.navigateDomainArrowKeys); + // No brother domain navigationAmount over from currentDomain + return undefined; } - // Change the input on typing - handleChange(event: React.ChangeEvent) { - this.setState({ input: event.target.value }); + function getRightBrother( + props: TreeHeaderProps + ): SemanticDomainWithSubdomains | undefined { + return getBrotherDomain(1, props); } - // Dispatch the search for a specified domain, and switches to it if it exists - searchAndSelectDomain(event: React.KeyboardEvent) { - // stopPropagation() prevents keystrokes from reaching ReviewEntries, - // but requires the search function be called onKeyDown - if (event.stopPropagation) { - event.stopPropagation(); - } - event.bubbles = false; - - if (event.key === "Enter") { - event.preventDefault(); - // Find parent domain - let parent: SemanticDomainWithSubdomains | undefined = this.props - .currentDomain; - while (parent.parentDomain !== undefined) parent = parent.parentDomain; - - // Search for domain - if (!isNaN(parseInt(this.state.input))) { - let i: number = 0; - while (parent) { - parent = this.searchDomainByNumber( - parent, - this.state.input.slice(0, i * 2 + 1) - ); - if (parent && parent.id === this.state.input) { - this.props.animate(parent); - this.props.bounce(); - this.setState({ input: "" }); - (event.target as any).value = ""; - break; - } else if (parent && parent.subdomains.length === 0) { - break; - } - i++; - } - } else { - parent = this.searchDomainByName(parent, this.state.input); - if (parent) { - this.props.animate(parent); - this.props.bounce(); - this.setState({ input: "" }); - (event.target as any).value = ""; - } - } - } + function getLeftBrother( + props: TreeHeaderProps + ): SemanticDomainWithSubdomains | undefined { + return getBrotherDomain(-1, props); } // Navigate tree via arrow keys - navigateDomainArrowKeys(event: KeyboardEvent) { - if (event.key === "ArrowLeft") { - const domain = this.getBrotherDomain(-1); - if (domain && domain.id !== this.props.currentDomain.id) - this.props.animate(domain); - } else if (event.key === "ArrowRight") { - const domain = this.getBrotherDomain(1); - if (domain && domain.id !== this.props.currentDomain.id) - this.props.animate(domain); - } else if (event.key === "ArrowDown") { - if (this.props.currentDomain.parentDomain) - this.props.animate(this.props.currentDomain.parentDomain); - } - } + const navigateDomainArrowKeys = useCallback( + (event: KeyboardEvent) => { + if (event.key === "ArrowLeft") { + const domain = getBrotherDomain(-1, props); + if (domain && domain.id !== props.currentDomain.id) + props.animate(domain); + } else if (event.key === "ArrowRight") { + const domain = getBrotherDomain(1, props); + if (domain && domain.id !== props.currentDomain.id) + props.animate(domain); + } else if (event.key === "ArrowUp") { + if (props.currentDomain.parentDomain) + props.animate(props.currentDomain.parentDomain); + } + }, + [props] + ); // Search for a semantic domain by number - searchDomainByNumber( + function searchDomainByNumber( parent: SemanticDomainWithSubdomains, number: string ): SemanticDomainWithSubdomains | undefined { @@ -129,7 +157,7 @@ export default class TreeViewHeader extends React.Component< } // Searches for a semantic domain by name - searchDomainByName( + function searchDomainByName( domain: SemanticDomainWithSubdomains, target: string ): SemanticDomainWithSubdomains | undefined { @@ -141,107 +169,75 @@ export default class TreeViewHeader extends React.Component< if (domain.subdomains.length > 0) { let tempDomain: SemanticDomainWithSubdomains | undefined; for (const sub of domain.subdomains) { - tempDomain = this.searchDomainByName(sub, target); + tempDomain = searchDomainByName(sub, target); if (check(tempDomain)) return tempDomain; } } } - // Switches currentDomain to the domain navigationAmount off from this domain, assuming that domain exists - navigateDomain(navigationAmount: number) { - if (this.props.currentDomain.parentDomain) { - const brotherDomain = this.getBrotherDomain(navigationAmount); - if (brotherDomain) this.props.animate(brotherDomain); + // Dispatch the search for a specified domain, and switches to it if it exists + function searchAndSelectDomain(event: React.KeyboardEvent) { + // stopPropagation() prevents keystrokes from reaching ReviewEntries, + // but requires the search function be called onKeyDown + if (event.stopPropagation) { + event.stopPropagation(); } - } + event.bubbles = false; - // Gets the domain 'navigationAmount' away from the currentDomain (negative to the left, positive to the right) - getBrotherDomain( - navigationAmount: number - ): SemanticDomainWithSubdomains | undefined { - if (this.props.currentDomain.parentDomain) { - const brotherDomains = this.props.currentDomain.parentDomain.subdomains; - let index = brotherDomains.findIndex( - (domain) => this.props.currentDomain.id === domain.id - ); + if (event.key === "Enter") { + event.preventDefault(); + // Find parent domain + let parent: SemanticDomainWithSubdomains | undefined = + props.currentDomain; + while (parent.parentDomain !== undefined) { + parent = parent.parentDomain; + } - index += navigationAmount; - if (0 <= index && index < brotherDomains.length) - return brotherDomains[index]; + // Search for domain + if (!isNaN(parseInt(input))) { + let i: number = 0; + while (parent) { + parent = searchDomainByNumber(parent, input.slice(0, i * 2 + 1)); + if (parent && parent.id === input) { + props.animate(parent); + props.bounce(); + setInput(""); + (event.target as any).value = ""; + break; + } else if (parent && parent.subdomains.length === 0) { + break; + } + i++; + } + } else { + parent = searchDomainByName(parent, input); + if (parent) { + props.animate(parent); + props.bounce(); + setInput(""); + (event.target as any).value = ""; + } + } } - - // No brother domain navigationAmount over from currentDomain - return undefined; - } - - // Switches current semantic domain + updates search bar - updateDomain() { - this.setState((_, props) => ({ input: props.currentDomain.id })); } - // Creates the L/R button + select button + search bar - render() { - const domainL = this.getBrotherDomain(-1); - const domainR = this.getBrotherDomain(1); - return ( - - - {domainL ? ( - { - this.props.animate(e); - this.props.bounce(); - }} - direction={Direction.Left} - /> - ) : null} - - - - - - - - - - - {domainR ? ( - { - this.props.animate(e); - this.props.bounce(); - }} - direction={Direction.Right} - /> - ) : null} - - - ); + // Change the input on typing + function handleChange(event: React.ChangeEvent) { + setInput(event.target.value); } + // Add event listeners + useEffect(() => { + window.addEventListener("keydown", navigateDomainArrowKeys); + // Remove event listeners on cleanup + return () => { + window.removeEventListener("keydown", navigateDomainArrowKeys); + }; + }, [navigateDomainArrowKeys]); + + return { + getRightBrother, + getLeftBrother, + searchAndSelectDomain, + handleChange, + }; } diff --git a/src/components/TreeView/tests/TreeViewHeader.test.tsx b/src/components/TreeView/tests/TreeViewHeader.test.tsx index 5fe267cd3a..286c9e87b2 100644 --- a/src/components/TreeView/tests/TreeViewHeader.test.tsx +++ b/src/components/TreeView/tests/TreeViewHeader.test.tsx @@ -1,159 +1,289 @@ import React from "react"; -import renderer, { ReactTestRenderer } from "react-test-renderer"; - -import SemanticDomainWithSubdomains from "../../../types/SemanticDomain"; -import TreeViewHeader from "../TreeViewHeader"; +import { render, screen } from "@testing-library/react"; +import { renderHook, act } from "@testing-library/react-hooks"; +import userEvent from "@testing-library/user-event"; import MockDomain from "./MockSemanticDomain"; - -// Variable event -var event = { - bubbles: false, - key: "Enter", - preventDefault: jest.fn(), - target: { - value: "", - }, -}; +import SemanticDomainWithSubdomains from "../../../types/SemanticDomain"; +import { + TreeViewHeader, + TreeHeaderProps, + useTreeViewNavigation, +} from "../TreeViewHeader"; // Handles -var treeMaster: ReactTestRenderer; -var treeHandle: TreeViewHeader; const MOCK_ANIMATE = jest.fn(); const MOCK_BOUNCE = jest.fn(); +const MOCK_STOP_PROP = jest.fn(); +const testProps: TreeHeaderProps = { + animate: MOCK_ANIMATE, + currentDomain: MockDomain, + bounceState: 0, + bounce: MOCK_BOUNCE, +}; +// These props have a currentDomain with a parent and two brothers +const upOneWithBrothersProps: TreeHeaderProps = { + animate: MOCK_ANIMATE, + currentDomain: MockDomain.subdomains[1], + bounceState: 0, + bounce: MOCK_BOUNCE, +}; +const eventListeners: Map = new Map< + string, + EventListener +>(); + +window.addEventListener = jest.fn((event, cb) => { + eventListeners.set(event, cb as EventListener); +}); beforeEach(() => { - setTree(MockDomain.subdomains[1]); - MOCK_ANIMATE.mockClear(); + jest.clearAllMocks(); + eventListeners.clear(); }); -describe("Tests TreeViewHeader", () => { - it("Renders correctly", () => { - // Default snapshot test - snapTest("default view"); - }); +describe("TreeViewHeader", () => { + describe("searchAndSelectDomain", () => { + function setupSimulatedInputTest(input: string) { + // Simulate the user typing a string + const simulatedInput = { + target: { value: input }, + } as React.ChangeEvent; - // onKeyDown - it("Search & select domain switches semantic domain if given number found", () => { - treeHandle.setState({ input: MockDomain.id }); - event.target.value = "not empty"; - treeHandle.searchAndSelectDomain((event as any) as React.KeyboardEvent); + const keyboardTarget = new EventTarget(); + // Simulate the user typing the enter key + const simulatedEnterKey: Partial = { + bubbles: true, + key: "Enter", + preventDefault: jest.fn(), + target: keyboardTarget, + stopPropagation: MOCK_STOP_PROP, + }; - expect(MOCK_ANIMATE).toHaveBeenCalledWith(MockDomain); - expect(MOCK_BOUNCE).toHaveBeenCalled(); - expect(treeHandle.state.input).toEqual(""); - expect(event.target.value).toEqual(""); - }); + return { simulatedInput, simulatedEnterKey }; + } - it("Search & select domain does not switch semantic domain if given number not found", () => { - const TEST: string = "10"; - treeHandle.setState({ input: TEST }); - event.target.value = TEST; - treeHandle.searchAndSelectDomain((event as any) as React.KeyboardEvent); + it("switches semantic domain if given number found", () => { + const TEST: string = "1.0"; - expect(MOCK_ANIMATE).toHaveBeenCalledTimes(0); - expect(treeHandle.state.input).toEqual(TEST); - expect(event.target.value).toEqual(TEST); - }); + const { result } = renderHook(() => useTreeViewNavigation(testProps)); - it("Search & select domain switches on a length 5 number", () => { - const leafNode: SemanticDomainWithSubdomains = - MockDomain.subdomains[2].subdomains[0].subdomains[0].subdomains[0]; - treeHandle.setState({ - input: leafNode.id, + // Simulate the user typing + const { simulatedInput, simulatedEnterKey } = setupSimulatedInputTest( + TEST + ); + + // When testing hooks any call that results in a state change needs to be wrapped in + // an act call to avoid warnings and make sure the state change is complete before we test + // for the results + act(() => result.current.handleChange(simulatedInput)); + act(() => + result.current.searchAndSelectDomain( + simulatedEnterKey as React.KeyboardEvent + ) + ); + + expect(MOCK_STOP_PROP).toHaveBeenCalled(); + expect(MOCK_BOUNCE).toHaveBeenCalled(); + expect(MOCK_ANIMATE).toHaveBeenCalledWith(MockDomain.subdomains[0]); }); - event.target.value = leafNode.id; - treeHandle.searchAndSelectDomain((event as any) as React.KeyboardEvent); - expect(MOCK_ANIMATE).toHaveBeenCalledWith(leafNode); - expect(treeHandle.state.input).toEqual(""); - expect(event.target.value).toEqual(""); - }); + it("does not switch semantic domain if given number not found", () => { + const TEST: string = "10"; - it("Search & select domain does not switch semantic domain on a number of length past a leaf node", () => { - const TEST: string = - MockDomain.subdomains[2].subdomains[0].subdomains[0].subdomains[0].id + - ".1"; - treeHandle.setState({ input: TEST }); - event.target.value = TEST; - treeHandle.searchAndSelectDomain((event as any) as React.KeyboardEvent); + const { result } = renderHook(() => useTreeViewNavigation(testProps)); - expect(MOCK_ANIMATE).toHaveBeenCalledTimes(0); - expect(treeHandle.state.input).toEqual(TEST); - expect(event.target.value).toEqual(TEST); - }); + // Simulate the user typing + const { simulatedInput, simulatedEnterKey } = setupSimulatedInputTest( + TEST + ); - it("Search & select domain switches semantic domain if given name found", () => { - treeHandle.setState({ input: MockDomain.subdomains[2].name }); - event.target.value = "not empty"; - treeHandle.searchAndSelectDomain((event as any) as React.KeyboardEvent); + // When testing hooks any call that results in a state change needs to be wrapped in + // an act call to avoid warnings and make sure the state change is complete before we test + // for the results + act(() => result.current.handleChange(simulatedInput)); + act(() => + result.current.searchAndSelectDomain( + simulatedEnterKey as React.KeyboardEvent + ) + ); - expect(MOCK_ANIMATE).toHaveBeenCalledWith(MockDomain.subdomains[2]); - expect(treeHandle.state.input).toEqual(""); - expect(event.target.value).toEqual(""); - }); + expect(MOCK_ANIMATE).toHaveBeenCalledTimes(0); + }); - it("Search & select domain does not switch semantic domain if given name not found", () => { - const TEST: string = "itsatrap"; - treeHandle.setState({ input: TEST }); - event.target.value = TEST; - treeHandle.searchAndSelectDomain((event as any) as React.KeyboardEvent); + it("does not switch semantic domain on realistic but non-existent subdomain", () => { + const TEST: string = "1.2.1.1.1.1"; - expect(MOCK_ANIMATE).toHaveBeenCalledTimes(0); - expect(treeHandle.state.input).toEqual(TEST); - expect(event.target.value).toEqual(TEST); + const { result } = renderHook(() => useTreeViewNavigation(testProps)); + + // Simulate the user typing + const { simulatedInput, simulatedEnterKey } = setupSimulatedInputTest( + TEST + ); + + // When testing hooks any call that results in a state change needs to be wrapped in + // an act call to avoid warnings and make sure the state change is complete before we test + // for the results + act(() => result.current.handleChange(simulatedInput)); + act(() => + result.current.searchAndSelectDomain( + simulatedEnterKey as React.KeyboardEvent + ) + ); + + expect(MOCK_ANIMATE).toHaveBeenCalledTimes(0); + }); + + it("switches on a length 5 number", () => { + const leafNode: SemanticDomainWithSubdomains = + MockDomain.subdomains[2].subdomains[0].subdomains[0].subdomains[0]; + + const { result } = renderHook(() => useTreeViewNavigation(testProps)); + + // Simulate the user typing the leafNode.id + const { simulatedInput, simulatedEnterKey } = setupSimulatedInputTest( + leafNode.id + ); + + // When testing hooks any call that results in a state change needs to be wrapped in + // an act call to avoid warnings and make sure the state change is complete before we test + // for the results + act(() => result.current.handleChange(simulatedInput)); + act(() => + result.current.searchAndSelectDomain( + simulatedEnterKey as React.KeyboardEvent + ) + ); + + expect(MOCK_ANIMATE).toHaveBeenCalledWith(leafNode); + }); + + it("switches semantic domain if given name found", () => { + const { result } = renderHook(() => useTreeViewNavigation(testProps)); + + const { simulatedInput, simulatedEnterKey } = setupSimulatedInputTest( + MockDomain.subdomains[0].name + ); + + // When testing hooks any call that results in a state change needs to be wrapped in + // an act call to avoid warnings and make sure the state change is complete before we test + // for the results + act(() => result.current.handleChange(simulatedInput)); + act(() => + result.current.searchAndSelectDomain( + simulatedEnterKey as React.KeyboardEvent + ) + ); + + expect(MOCK_ANIMATE).toHaveBeenCalledWith(MockDomain.subdomains[0]); + }); + + it("does not switch semantic domain if given name not found", () => { + const TEST: string = "itsatrap"; + const { result } = renderHook(() => useTreeViewNavigation(testProps)); + const { simulatedInput, simulatedEnterKey } = setupSimulatedInputTest( + TEST + ); + // When testing hooks any call that results in a state change needs to be wrapped in + // an act call to avoid warnings and make sure the state change is complete before we test + // for the results + act(() => result.current.handleChange(simulatedInput)); + act(() => + result.current.searchAndSelectDomain( + simulatedEnterKey as React.KeyboardEvent + ) + ); + + expect(MOCK_ANIMATE).toHaveBeenCalledTimes(0); + }); }); - // getBrotherDomain - it("provides the proper brother domains", () => { - // Standard navigation - expect(treeHandle.getBrotherDomain(-1)).toEqual(MockDomain.subdomains[0]); - expect(treeHandle.getBrotherDomain(1)).toEqual(MockDomain.subdomains[2]); + describe("getLeftBrother and getRightBrother", () => { + it("return undefined when there are no brothers", () => { + const { result } = renderHook(() => useTreeViewNavigation(testProps)); + + // The top domain (used in testProps) has no brother on either side + expect(result.current.getLeftBrother(testProps)).toEqual(undefined); + expect(result.current.getRightBrother(testProps)).toEqual(undefined); + }); - // Check with indices out-of-bounds - expect(treeHandle.getBrotherDomain(-2)).toEqual(undefined); - expect(treeHandle.getBrotherDomain(2)).toEqual(undefined); + // getBrotherDomain + it("return the expected brothers", () => { + const { result } = renderHook(() => + useTreeViewNavigation(upOneWithBrothersProps) + ); - // Check that a domain w/ no parentDomain has no brotherDomains - setTree(MockDomain); - expect(treeHandle.getBrotherDomain(-1)).toEqual(undefined); + // The top domain (used in testProps) has no brother on either side + expect(result.current.getLeftBrother(upOneWithBrothersProps)).toEqual( + MockDomain.subdomains[0] + ); + expect(result.current.getRightBrother(upOneWithBrothersProps)).toEqual( + MockDomain.subdomains[2] + ); + }); }); - // navigateKeys - it("navigateKeys w/ Arrow Down switches to parent domain", () => { - event.key = "ArrowDown"; - treeHandle.navigateDomainArrowKeys((event as any) as KeyboardEvent); - expect(MOCK_ANIMATE).toHaveBeenCalledWith(MockDomain); + // Integration tests, verify the component uses the hooks to achieve the desired UX + it("typing non-matching domain search data does not clear input, or attempt to navigate", () => { + render(); + expect( + (screen.getByTestId("testSearch") as HTMLInputElement).value + ).toEqual(""); + userEvent.type(screen.getByTestId("testSearch"), "flibbertigibbet{enter}"); + expect( + (screen.getByTestId("testSearch") as HTMLInputElement).value + ).toEqual("flibbertigibbet"); + // verify that no attempt to switch domains happened + expect(MOCK_ANIMATE).toHaveBeenCalledTimes(0); }); - it("navigateKeys w/ Arrow Left switches to left brother domain", () => { - event.key = "ArrowLeft"; - treeHandle.navigateDomainArrowKeys((event as any) as KeyboardEvent); - expect(MOCK_ANIMATE).toHaveBeenCalledWith(MockDomain.subdomains[0]); + it("typing valid domain number navigates and clears input", () => { + render(); + expect( + (screen.getByTestId("testSearch") as HTMLInputElement).value + ).toEqual(""); + userEvent.type(screen.getByTestId("testSearch"), "1.2{enter}"); + expect( + (screen.getByTestId("testSearch") as HTMLInputElement).value + ).toEqual(""); + // verify that we're testing with the matching domain + expect(MockDomain.subdomains[2].id).toEqual("1.2"); + // verify that we would switch to the domain requested + expect(MOCK_ANIMATE).toHaveBeenCalledWith(MockDomain.subdomains[2]); }); - it("navigateKeys w/ Arrow Right switches to right brother domain", () => { - event.key = "ArrowRight"; - treeHandle.navigateDomainArrowKeys((event as any) as KeyboardEvent); + it("typing right arrow key moves to right sibling", () => { + render(); + const keyDownHandler = eventListeners.get("keydown"); + expect(keyDownHandler).not.toBeUndefined(); + const simulatedArrowKey: Partial = { + key: "ArrowRight", + }; + keyDownHandler!.call(null, simulatedArrowKey as Event); + // verify that we would switch to the domain requested expect(MOCK_ANIMATE).toHaveBeenCalledWith(MockDomain.subdomains[2]); }); -}); -// Creates the tree -function setTree(domain: SemanticDomainWithSubdomains) { - renderer.act(() => { - treeMaster = renderer.create( - - ); + it("typing left arrow key moves to left sibling", () => { + render(); + const keyDownHandler = eventListeners.get("keydown"); + expect(keyDownHandler).not.toBeUndefined(); + const simulatedArrowKey: Partial = { + key: "ArrowLeft", + }; + keyDownHandler!.call(null, simulatedArrowKey as Event); + // verify that we would switch to the domain requested + expect(MOCK_ANIMATE).toHaveBeenCalledWith(MockDomain.subdomains[0]); }); - treeHandle = treeMaster.root.findByType(TreeViewHeader).instance; -} -// Perform a snapshot test -function snapTest(name: string) { - expect(treeMaster.toJSON()).toMatchSnapshot(); -} + it("typing up arrow key moves to parent domain", () => { + render(); + const keyDownHandler = eventListeners.get("keydown"); + expect(keyDownHandler).not.toBeUndefined(); + const simulatedArrowKey: Partial = { + key: "ArrowUp", + }; + keyDownHandler!.call(null, simulatedArrowKey as Event); + // verify that we would switch to the domain requested + expect(MOCK_ANIMATE).toHaveBeenCalledWith(MockDomain); + }); +}); diff --git a/src/components/TreeView/tests/__snapshots__/TreeDepiction.test.tsx.snap b/src/components/TreeView/tests/__snapshots__/TreeDepiction.test.tsx.snap index 9154003a54..504a11d8dc 100644 --- a/src/components/TreeView/tests/__snapshots__/TreeDepiction.test.tsx.snap +++ b/src/components/TreeView/tests/__snapshots__/TreeDepiction.test.tsx.snap @@ -117,6 +117,7 @@ Array [ autoComplete="off" autoFocus={false} className="MuiInputBase-input MuiInput-input" + data-testid="testSearch" disabled={false} id="name" onAnimationStart={[Function]} @@ -856,6 +857,7 @@ Array [ autoComplete="off" autoFocus={false} className="MuiInputBase-input MuiInput-input" + data-testid="testSearch" disabled={false} id="name" onAnimationStart={[Function]} @@ -1240,6 +1242,7 @@ Array [ autoComplete="off" autoFocus={false} className="MuiInputBase-input MuiInput-input" + data-testid="testSearch" disabled={false} id="name" onAnimationStart={[Function]} @@ -2118,6 +2121,7 @@ Array [ autoComplete="off" autoFocus={false} className="MuiInputBase-input MuiInput-input" + data-testid="testSearch" disabled={false} id="name" onAnimationStart={[Function]} @@ -2579,6 +2583,7 @@ Array [ autoComplete="off" autoFocus={false} className="MuiInputBase-input MuiInput-input" + data-testid="testSearch" disabled={false} id="name" onAnimationStart={[Function]} diff --git a/src/components/TreeView/tests/__snapshots__/TreeViewComponent.test.tsx.snap b/src/components/TreeView/tests/__snapshots__/TreeViewComponent.test.tsx.snap index 42eb8b0049..ce7fdc4873 100644 --- a/src/components/TreeView/tests/__snapshots__/TreeViewComponent.test.tsx.snap +++ b/src/components/TreeView/tests/__snapshots__/TreeViewComponent.test.tsx.snap @@ -123,6 +123,7 @@ exports[`Tests AddWords Constructs correctly 1`] = ` autoComplete="off" autoFocus={false} className="MuiInputBase-input MuiInput-input" + data-testid="testSearch" disabled={false} id="name" onAnimationStart={[Function]} diff --git a/src/components/TreeView/tests/__snapshots__/TreeViewHeader.test.tsx.snap b/src/components/TreeView/tests/__snapshots__/TreeViewHeader.test.tsx.snap deleted file mode 100644 index bed314246c..0000000000 --- a/src/components/TreeView/tests/__snapshots__/TreeViewHeader.test.tsx.snap +++ /dev/null @@ -1,299 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Tests TreeViewHeader Renders correctly 1`] = ` -
    -
  • -
    - -
    -
  • -
  • -
    -
    -
    - -
    -
    - -
    - -
    -
    -
    -
    -
  • -
  • -
    - -
    -
  • -
-`;