diff --git a/.eslintrc.json b/.eslintrc.json index 710c3bd6..39773f7d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -11,7 +11,8 @@ "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": "latest", - "sourceType": "module" + "sourceType": "module", + "project": ["./tsconfig.base.json"] }, "plugins": ["@typescript-eslint"], "rules": { @@ -23,6 +24,7 @@ "@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/no-empty-interface": "off", "@typescript-eslint/no-empty-function": "off", - "@typescript-eslint/no-this-alias": "off" + "@typescript-eslint/no-this-alias": "off", + "@typescript-eslint/no-floating-promises": "warn" } } diff --git a/package-lock.json b/package-lock.json index 977ce37f..56d6ebd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "dc": "^4.2.7", "debug": "^4.3.4", "dom-to-image-more": "^2.10.1", - "express": "^4.18.1", + "express": "^4.19.2", "file-saver": "^2.0.5", "helmet": "^5.1.0", "lodash": "^4.17.21", @@ -54,7 +54,7 @@ "@angularclass/hmr": "^3.0.0", "@babel/plugin-proposal-decorators": "^7.18.10", "@ngtools/webpack": "^13.3.9", - "@playwright/test": "^1.39.0", + "@playwright/test": "^1.43.1", "@types/aws-param-store": "^2.1.2", "@types/browser-update": "^3.3.0", "@types/compression": "^1.7.2", @@ -4302,12 +4302,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.39.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.39.0.tgz", - "integrity": "sha512-3u1iFqgzl7zr004bGPYiN/5EZpRUSFddQBra8Rqll5N0/vfpqlP9I9EXqAoGacuAbX6c9Ulg/Cjqglp5VkK6UQ==", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz", + "integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==", "dev": true, "dependencies": { - "playwright": "1.39.0" + "playwright": "1.43.1" }, "bin": { "playwright": "cli.js" @@ -6948,20 +6948,20 @@ "dev": true }, "node_modules/body-parser": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz", - "integrity": "sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", "dependencies": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.10.3", - "raw-body": "2.5.1", + "qs": "6.11.0", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -7003,9 +7003,9 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/body-parser/node_modules/qs": { - "version": "6.10.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", - "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==", + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", "dependencies": { "side-channel": "^1.0.4" }, @@ -8020,9 +8020,9 @@ ] }, "node_modules/content-type": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", - "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "engines": { "node": ">= 0.6" } @@ -8037,9 +8037,9 @@ } }, "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "engines": { "node": ">= 0.6" } @@ -11225,16 +11225,16 @@ } }, "node_modules/express": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.1.tgz", - "integrity": "sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q==", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.0", + "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -11250,7 +11250,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "0.1.7", "proxy-addr": "~2.0.7", - "qs": "6.10.3", + "qs": "6.11.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.18.0", @@ -11279,9 +11279,9 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/express/node_modules/qs": { - "version": "6.10.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", - "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==", + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", "dependencies": { "side-channel": "^1.0.4" }, @@ -16942,12 +16942,12 @@ } }, "node_modules/playwright": { - "version": "1.39.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.39.0.tgz", - "integrity": "sha512-naE5QT11uC/Oiq0BwZ50gDmy8c8WLPRTEWuSSFVG2egBka/1qMoSqYQcROMT9zLwJ86oPofcTH2jBY/5wWOgIw==", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz", + "integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==", "dev": true, "dependencies": { - "playwright-core": "1.39.0" + "playwright-core": "1.43.1" }, "bin": { "playwright": "cli.js" @@ -16960,9 +16960,9 @@ } }, "node_modules/playwright-core": { - "version": "1.39.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.39.0.tgz", - "integrity": "sha512-+k4pdZgs1qiM+OUkSjx96YiKsXsmb59evFoqv8SKO067qBA+Z2s/dCzJij/ZhdQcs2zlTAgRKfeiiLm8PQ2qvw==", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz", + "integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==", "dev": true, "bin": { "playwright-core": "cli.js" @@ -17978,9 +17978,9 @@ } }, "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -25601,12 +25601,12 @@ } }, "@playwright/test": { - "version": "1.39.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.39.0.tgz", - "integrity": "sha512-3u1iFqgzl7zr004bGPYiN/5EZpRUSFddQBra8Rqll5N0/vfpqlP9I9EXqAoGacuAbX6c9Ulg/Cjqglp5VkK6UQ==", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz", + "integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==", "dev": true, "requires": { - "playwright": "1.39.0" + "playwright": "1.43.1" } }, "@popperjs/core": { @@ -27891,20 +27891,20 @@ "dev": true }, "body-parser": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz", - "integrity": "sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", "requires": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.10.3", - "raw-body": "2.5.1", + "qs": "6.11.0", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -27936,9 +27936,9 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "qs": { - "version": "6.10.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", - "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==", + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", "requires": { "side-channel": "^1.0.4" } @@ -28720,9 +28720,9 @@ } }, "content-type": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", - "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" }, "convert-source-map": { "version": "1.8.0", @@ -28734,9 +28734,9 @@ } }, "cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" }, "cookie-signature": { "version": "1.0.6", @@ -31127,16 +31127,16 @@ } }, "express": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.1.tgz", - "integrity": "sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q==", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", "requires": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.0", + "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -31152,7 +31152,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "0.1.7", "proxy-addr": "~2.0.7", - "qs": "6.10.3", + "qs": "6.11.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.18.0", @@ -31178,9 +31178,9 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "qs": { - "version": "6.10.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", - "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==", + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", "requires": { "side-channel": "^1.0.4" } @@ -35507,19 +35507,19 @@ } }, "playwright": { - "version": "1.39.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.39.0.tgz", - "integrity": "sha512-naE5QT11uC/Oiq0BwZ50gDmy8c8WLPRTEWuSSFVG2egBka/1qMoSqYQcROMT9zLwJ86oPofcTH2jBY/5wWOgIw==", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz", + "integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==", "dev": true, "requires": { "fsevents": "2.3.2", - "playwright-core": "1.39.0" + "playwright-core": "1.43.1" } }, "playwright-core": { - "version": "1.39.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.39.0.tgz", - "integrity": "sha512-+k4pdZgs1qiM+OUkSjx96YiKsXsmb59evFoqv8SKO067qBA+Z2s/dCzJij/ZhdQcs2zlTAgRKfeiiLm8PQ2qvw==", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz", + "integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==", "dev": true }, "portfinder": { @@ -36187,9 +36187,9 @@ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" }, "raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "requires": { "bytes": "3.1.2", "http-errors": "2.0.0", diff --git a/package.json b/package.json index ff5d75aa..a92ebd8d 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "mongo:start:windows": "sh ./scripts/start-mongo.sh 'C:\\data\\db'", "data:local": "npm run clean:data && sh ./scripts/get-data-local.sh", "e2e": "playwright test --trace on", - "e2e:ui": "playwright test --ui" + "e2e:ui": "playwright test --ui", + "e2e:update": "npm install -D @playwright/test@latest; npx playwright install --with-deps; npx playwright --version" }, "pre-commit": [ "test" @@ -69,7 +70,7 @@ "dc": "^4.2.7", "debug": "^4.3.4", "dom-to-image-more": "^2.10.1", - "express": "^4.18.1", + "express": "^4.19.2", "file-saver": "^2.0.5", "helmet": "^5.1.0", "lodash": "^4.17.21", @@ -94,7 +95,7 @@ "@angularclass/hmr": "^3.0.0", "@babel/plugin-proposal-decorators": "^7.18.10", "@ngtools/webpack": "^13.3.9", - "@playwright/test": "^1.39.0", + "@playwright/test": "^1.43.1", "@types/aws-param-store": "^2.1.2", "@types/browser-update": "^3.3.0", "@types/compression": "^1.7.2", diff --git a/src/app/core/components/footer/footer.component.spec.ts b/src/app/core/components/footer/footer.component.spec.ts index 9154ab96..6fc4041c 100644 --- a/src/app/core/components/footer/footer.component.spec.ts +++ b/src/app/core/components/footer/footer.component.spec.ts @@ -72,6 +72,8 @@ describe('Component: Footer', () => { const link = element.querySelector('.footer-logo a') as HTMLElement; expect(link).toBeTruthy(); + // https://github.com/angular/angular/issues/45202 + // eslint-disable-next-line @typescript-eslint/no-floating-promises router.navigate(['/about']); tick(); expect(location.path()).toBe('/about'); diff --git a/src/app/core/components/header/header.component.spec.ts b/src/app/core/components/header/header.component.spec.ts index c972f8ca..c77798b7 100644 --- a/src/app/core/components/header/header.component.spec.ts +++ b/src/app/core/components/header/header.component.spec.ts @@ -69,6 +69,8 @@ describe('Component: Header', () => { const link = element.querySelector('.header-logo a') as HTMLElement; expect(link).toBeTruthy(); + // https://github.com/angular/angular/issues/45202 + // eslint-disable-next-line @typescript-eslint/no-floating-promises router.navigate(['/nominated-targets']); tick(); expect(location.path()).toBe('/nominated-targets'); diff --git a/src/app/core/services/api.service.ts b/src/app/core/services/api.service.ts index 401098ef..0420af61 100644 --- a/src/app/core/services/api.service.ts +++ b/src/app/core/services/api.service.ts @@ -50,7 +50,7 @@ export class ApiService { return url; } - getGene(id: string): Observable { + getGene(id: string): Observable { return this.http.get(this.getBaseUrl() + '/api/genes/' + id, { headers: new HttpHeaders(defaultHeaders), }); diff --git a/src/app/features/charts/components/median-barchart/median-barchart.component.spec.ts b/src/app/features/charts/components/median-barchart/median-barchart.component.spec.ts index 6b73bc01..6e3f835f 100644 --- a/src/app/features/charts/components/median-barchart/median-barchart.component.spec.ts +++ b/src/app/features/charts/components/median-barchart/median-barchart.component.spec.ts @@ -54,8 +54,8 @@ describe('Component: BarChart - Median', () => { let component: MedianBarChartComponent; let element: HTMLElement; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ + beforeEach(waitForAsync(async () => { + await TestBed.configureTestingModule({ declarations: [MedianBarChartComponent], imports: [RouterTestingModule], providers: [HelperService], diff --git a/src/app/features/genes/components/gene-comparison-tool/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.html b/src/app/features/genes/components/gene-comparison-tool/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.html index ce33000e..c81ac286 100644 --- a/src/app/features/genes/components/gene-comparison-tool/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.html +++ b/src/app/features/genes/components/gene-comparison-tool/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.html @@ -18,7 +18,9 @@
P-Value
-
{{ getSignificantFigures(data.value, 3) }}
+
+ {{ getSignificantFigures(data.value, 3) }} +
{{ getSignificantFigures(data.pValue, 3) }}
@@ -36,17 +38,23 @@ [style]="getIntervalPositions(data)" >
-
{{ getSignificantFigures(data.intervalMin, 3) }}
+
+ {{ getSignificantFigures(data.intervalMin, 3) }} +
-
{{ getSignificantFigures(data.intervalMax, 3) }}
+
+ {{ getSignificantFigures(data.intervalMax, 3) }} +
-
{{ getSignificantFigures(data.value, 3) }}
+
+ {{ getSignificantFigures(data.value, 3) }} +
diff --git a/src/app/features/genes/components/gene-comparison-tool/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.ts b/src/app/features/genes/components/gene-comparison-tool/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.ts index c3eb7e98..daec3512 100644 --- a/src/app/features/genes/components/gene-comparison-tool/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.ts +++ b/src/app/features/genes/components/gene-comparison-tool/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.ts @@ -96,7 +96,7 @@ export class GeneComparisonToolDetailsPanelComponent { } getSignificantFigures(n: any, b: any) { - const emdash = '—'; // Shift+Option+Hyphen + const emdash = '\u2014'; // Shift+Option+Hyphen if (n === null || n === undefined) return emdash; return this.helperService.getSignificantFigures(n, b); diff --git a/src/app/features/genes/components/gene-comparison-tool/gene-comparison-tool.component.html b/src/app/features/genes/components/gene-comparison-tool/gene-comparison-tool.component.html index bdb7fc11..42772416 100644 --- a/src/app/features/genes/components/gene-comparison-tool/gene-comparison-tool.component.html +++ b/src/app/features/genes/components/gene-comparison-tool/gene-comparison-tool.component.html @@ -284,11 +284,23 @@

Gene Comparison Tool

-
Pinned Genes ({{ pinnedGenes.length }}/50)
+
+ + Pinned Genes ({{ pinnedItems.length }}/50) + +
+
+ Pinned Genes ({{ uniquePinnedGenesCount }}/50)    +
+
+ {{ pinnedItems.length }} Proteins +
+
+
- +
Gene Comparison Tool
@@ -458,16 +471,17 @@

Gene Comparison Tool

diff --git a/src/app/features/genes/components/gene-comparison-tool/gene-comparison-tool.component.scss b/src/app/features/genes/components/gene-comparison-tool/gene-comparison-tool.component.scss index 309b8246..313ae8d1 100644 --- a/src/app/features/genes/components/gene-comparison-tool/gene-comparison-tool.component.scss +++ b/src/app/features/genes/components/gene-comparison-tool/gene-comparison-tool.component.scss @@ -618,6 +618,12 @@ flex-grow: 1; } } + + #pinned-proteins { + font-weight: normal; + padding: 5px 0; + font-size: 14px; + } } button { diff --git a/src/app/features/genes/components/gene-comparison-tool/gene-comparison-tool.component.spec.ts b/src/app/features/genes/components/gene-comparison-tool/gene-comparison-tool.component.spec.ts index ace93c52..80f9ffb4 100644 --- a/src/app/features/genes/components/gene-comparison-tool/gene-comparison-tool.component.spec.ts +++ b/src/app/features/genes/components/gene-comparison-tool/gene-comparison-tool.component.spec.ts @@ -27,6 +27,7 @@ import { ApiService, HelperService } from '../../../../core/services'; import { GeneService } from '../../../../features/genes/services'; import { routes } from '../../../../app.routing'; import { comparisonGeneEmptyHGNCMock, comparisonGeneMock1, comparisonGeneMock2 } from '../../../../testing'; +import { GCTGeneTissue } from '../../../../models'; const DEFAULT_SIGNIFICANCE_THRESHOLD = 0.05; @@ -47,8 +48,8 @@ describe('Component: GeneComparisonToolComponent', () => { let element: HTMLElement; let route: ActivatedRoute; - beforeEach(fakeAsync(() => { - TestBed.configureTestingModule({ + beforeEach(fakeAsync(async () => { + await TestBed.configureTestingModule({ declarations: [ GeneComparisonToolComponent, GeneComparisonToolDetailsPanelComponent, @@ -105,12 +106,13 @@ describe('Component: GeneComparisonToolComponent', () => { fixture.detectChanges(); expect(component.genes).toEqual([comparisonGeneMock1, comparisonGeneMock2]); - expect(component.pinnedGenes).toEqual([comparisonGeneMock1]); + expect(component.pinnedItems).toEqual([comparisonGeneMock1]); flush(); })); it('should search', () => { component.initData([comparisonGeneMock1, comparisonGeneMock2]); + component.refresh(); fixture.detectChanges(); const input = element.querySelector( @@ -179,7 +181,7 @@ describe('Component: GeneComparisonToolComponent', () => { el = element.querySelector('#pinned-genes-header') as HTMLElement; - expect(el.textContent).toBe('Pinned Genes (1/50)'); + expect(el.textContent?.trim()).toBe('Pinned Genes (1/50)'); }); it('should pin/upin gene', () => { @@ -188,15 +190,15 @@ describe('Component: GeneComparisonToolComponent', () => { component.clearPinnedGenes(); fixture.detectChanges(); - expect(component.pinnedGenes.length).toEqual(0); + expect(component.pinnedItems.length).toEqual(0); - component.pinGene(comparisonGeneMock1, true); + component.onPinGeneClick(comparisonGeneMock1); fixture.detectChanges(); - expect(component.pinnedGenes.length).toEqual(1); + expect(component.pinnedItems.length).toEqual(1); - component.unpinGene(comparisonGeneMock1, true); + component.onUnPinGeneClick(comparisonGeneMock1, true); fixture.detectChanges(); - expect(component.pinnedGenes.length).toEqual(0); + expect(component.pinnedItems.length).toEqual(0); }); it('should clear pinned genes', fakeAsync(() => { @@ -205,7 +207,7 @@ describe('Component: GeneComparisonToolComponent', () => { component.clearPinnedGenes(); fixture.detectChanges(); - expect(component.pinnedGenes.length).toEqual(0); + expect(component.pinnedItems.length).toEqual(0); flush(); })); @@ -396,7 +398,7 @@ describe('Component: GeneComparisonToolComponent', () => { expect( element.querySelector(TOGGLE_CLASS)?.querySelector('input')?.checked ).toBeTrue(); - expect(component.getUrlParam('significance')[0]).toEqual( + expect(component.getUrlParam('significance')).toEqual( threshold ); }; @@ -518,5 +520,32 @@ describe('Component: GeneComparisonToolComponent', () => { const expected2 = 'ENSG00000147065'; expect(label2).toBe(expected2); }); + + it('should set circle size to zero for undefined pValues', () => { + let tissue: GCTGeneTissue | undefined; + // undefined values should result in a circle size of zero + expect(tissue).toBeUndefined(); + const result = component.getCircleSize(tissue?.adj_p_val); + expect(result).toBe(0); + }); + + it('should set circle size to zero for null pValues', () => { + // null values should result in a circle size of zero pixels + const pValue = null; + const result = component.getCircleSize(pValue); + expect(result).toBe(0); + }); + + it('should set circle size for pValues within acceptable ranges', () => { + let expectedSizeInPixels = 0; + let pValue = 0.5; + let result = component.getCircleSize(pValue); + expect(result).toBe(expectedSizeInPixels); + + expectedSizeInPixels = 33; + pValue = 0.04; + result = component.getCircleSize(pValue); + expect(result).toBe(expectedSizeInPixels); + }); }); }); diff --git a/src/app/features/genes/components/gene-comparison-tool/gene-comparison-tool.component.ts b/src/app/features/genes/components/gene-comparison-tool/gene-comparison-tool.component.ts index 47e3fd2f..2c675120 100644 --- a/src/app/features/genes/components/gene-comparison-tool/gene-comparison-tool.component.ts +++ b/src/app/features/genes/components/gene-comparison-tool/gene-comparison-tool.component.ts @@ -97,9 +97,15 @@ export class GeneComparisonToolComponent implements OnInit, AVI, OnDestroy { urlParamsSubscription: Subscription | undefined; /* Pinned ---------------------------------------------------------------- */ - pinnedGenes: GCTGene[] = []; - pinnedGenesCache: { [key: string]: GCTGene[] } = {}; - pendingPinnedGenes: GCTGene[] = []; + initialLoad = true; + + lastPinnedCategory = ''; + lastPinnedSubCategory = ''; + + pinnedItems: GCTGene[] = []; + uniquePinnedGenesCount = 0; + pinnedItemsCache: GCTGene[] = []; + pendingPinnedItems: GCTGene[] = []; maxPinnedGenes = 50; /* ----------------------------------------------------------------------- */ @@ -237,52 +243,91 @@ export class GeneComparisonToolComponent implements OnInit, AVI, OnDestroy { return property.split('.').reduce((o: any, i: any) => o[i], gene); } - initData(genes: GCTGene[]) { + getUid(item: GCTGene) { + // rna is just the ensembl gene id + // protein is a combination of ensembl gene id and uniprotid + if (this.category === 'RNA - Differential Expression') + return item.ensembl_gene_id; + else + return item.ensembl_gene_id + item.uniprotid; + } + + // when current category is RNA, this method will always return a list of ensembl gene ids, regardless of previous category + // when current category is Protein, this method will return either ensg (if previous category was RNA) or ensg + uniprot (if previous category was Protein) + getPreviousPins() { + // if the last pinned category is blank, then this means this is the initial load + // note: we only need to check the category for blank but subcategory will also be blank + // In this scenario, we check the url to see if this was a shared url with pinned genes/proteins + if (this.lastPinnedCategory === '') { + // check the url for pinned genes/proteins + this.setLastPinnedCategories(); + return this.getUrlParam('pinned', true); + } + + if (this.currentCategoriesMatchLastPinnedCategories()) { + // load from cache since it has been previously cached + // uid works for both proten and rna cases + return this.pinnedItemsCache.map((g: GCTGene) => g.uid); + } else { + // categories don't match, so grab it from the cache and format it + if (this.category === 'RNA - Differential Expression') { + // if the current category is RNA, we only need the previous ensgs + // instead of getting the uid, we need to get the ensg + return this.pinnedItemsCache.map((g: GCTGene) => g.ensembl_gene_id); + } else { + // if the current category is Protein, we need the uid + // because the previous category and subcategory may or may not match + // e.g. same category and different subcategory OR different category + return this.pinnedItemsCache.map((g: GCTGene) => g.uid); + } + } + } + + initData(items: GCTGene[]) { + // hide brain region columns initially this.brainRegionsColumns.forEach(c => c.visible = false); + + const itemsToPin: GCTGene[] = []; - const pinnedGenes: GCTGene[] = []; - const currentPinnedGenesCache = this.getPinnedGenesCache( - this.category, - this.subCategory - ); - const urlPins = currentPinnedGenesCache.length - ? currentPinnedGenesCache.map((g: GCTGene) => g.uid) - : this.getUrlParam('pinned', true); - - genes.forEach((gene: GCTGene) => { - gene.uid = gene.ensembl_gene_id; - gene.search_array = [ - gene.ensembl_gene_id.toLowerCase(), - gene.hgnc_symbol.toLowerCase(), + // load the previous pins and format previousPins + const previousPins = this.getPreviousPins(); + + items.forEach((item: GCTGene) => { + item.uid = this.getUid(item); + item.search_array = [ + item.ensembl_gene_id.toLowerCase(), + item.hgnc_symbol.toLowerCase(), ]; - if ('Protein - Differential Expression' === this.category) { - gene.uid += gene.uniprotid; - gene.search_array.push(gene.uniprotid?.toLowerCase() || ''); - - if ( - urlPins.includes(gene.uid) || - urlPins.includes(gene.ensembl_gene_id) - ) { - pinnedGenes.push(gene); + if (this.category === 'Protein - Differential Expression') { + item.search_array.push(item.uniprotid?.toLowerCase() || ''); + + // if there is a match on uid or ensembl_gene_id, add it to pinnedGenes + // if it wasn't added already + if (this.lastPinnedCategory === 'RNA - Differential Expression') { + // previousPins would be a list of ensg + if (previousPins.includes(item.ensembl_gene_id)) + itemsToPin.push(item); + } else { + // previousPins would be a list of ensg+uniprotids + if (previousPins.includes(item.uid)) + itemsToPin.push(item); } } else { - // Filter to get a list of ENSGs - if ( - urlPins.map((id: string) => id.substring(0, 15)).includes(gene.uid) - ) { - pinnedGenes.push(gene); + if (previousPins.includes(item.uid)) { + itemsToPin.push(item); } } - gene.search_string = gene.search_array.join(); + item.search_string = item.search_array.join(); + // apply filters this.filters.forEach((filter: GCTFilter) => { if (!filter.field) { return; } - const value = this.getGeneProperty(gene, filter.field); + const value = this.getGeneProperty(item, filter.field); if (value) { if (Array.isArray(value)) { @@ -296,19 +341,26 @@ export class GeneComparisonToolComponent implements OnInit, AVI, OnDestroy { }); // add tissue columns - gene.tissues?.forEach((tissue: GCTGeneTissue) => { - //if (!this.brainRegionsColumns.map(c => c.field).includes(tissue.name)) { + item.tissues?.forEach((tissue: GCTGeneTissue) => { const column = this.brainRegionsColumns.find(c => c.field === tissue.name); if (column) column.visible = true; - //} }); }); + // on initial load, we want to cache any items + if (this.initialLoad) { + this.initialLoad = false; + this.setPinnedItemsCache(itemsToPin); + } + + this.uniquePinnedGenesCount = this.getCountOfUniqueGenes(); + this.updateVisibleColumns(); if (!this.sortField || !this.columns.includes(this.sortField)) { this.sortField = this.columns[0]; + this.sortOrder = -1; } const preSelection = this.helperService.getGCTSelection(); @@ -317,25 +369,28 @@ export class GeneComparisonToolComponent implements OnInit, AVI, OnDestroy { this.searchTerm = preSelection.join(','); } - if (pinnedGenes.length) { - pinnedGenes.sort((a, b) => + if (itemsToPin.length) { + itemsToPin.sort((a, b) => a.ensembl_gene_id > b.ensembl_gene_id ? 1 : -1 ); if ( 'Protein - Differential Expression' === this.category && - pinnedGenes.length > this.maxPinnedGenes + this.uniquePinnedGenesCount > this.maxPinnedGenes ) { - this.pendingPinnedGenes = pinnedGenes; + this.pendingPinnedItems = itemsToPin; this.pinnedGenesModal.show(); } else { - this.pinnedGenes = []; - this.pendingPinnedGenes = []; - this.pinGenes(pinnedGenes); + this.pinnedItems = []; + this.pendingPinnedItems = []; + this.uniquePinnedGenesCount = this.getCountOfUniqueGenes(); + this.pinGenes(itemsToPin); } + } else { + this.pinnedItems = []; } - this.genes = genes; + this.genes = items; } /* ----------------------------------------------------------------------- */ @@ -343,6 +398,7 @@ export class GeneComparisonToolComponent implements OnInit, AVI, OnDestroy { /* ----------------------------------------------------------------------- */ updateSubCategories() { + // update subcategory label text if ('Protein - Differential Expression' === this.category) { this.subCategoryLabel = 'Profiling Method'; } else { @@ -351,6 +407,7 @@ export class GeneComparisonToolComponent implements OnInit, AVI, OnDestroy { this.subCategories = cloneDeep(variables.subCategories)[this.category]; + // default to first option if subcategory not defined/found if ( !this.subCategory || !this.subCategories.find( @@ -362,18 +419,11 @@ export class GeneComparisonToolComponent implements OnInit, AVI, OnDestroy { } onCategoryChange() { - // Make sure we clear the protein cache so pins are converted from RNA to Protein - if (this.category === 'RNA - Differential Expression') { - this.clearPinnedGenesCache('Protein - Differential Expression'); - } - this.updateSubCategories(); - this.updateUrl(); this.loadGenes(); } onSubCategoryChange() { - this.updateUrl(); this.loadGenes(); } @@ -613,96 +663,205 @@ export class GeneComparisonToolComponent implements OnInit, AVI, OnDestroy { /* Pin/Unpin /* ----------------------------------------------------------------------- */ + currentCategoriesMatchLastPinnedCategories() { + // returns if the current categories match the last pinned categories + return this.lastPinnedCategory === this.category + && this.lastPinnedSubCategory === this.subCategory; + } + + setLastPinnedCategories() { + this.lastPinnedCategory = this.category; + this.lastPinnedSubCategory = this.subCategory; + } + getPinnedGenesCacheKey(category: string, subCategory?: string) { return (category + (subCategory ? '-' + subCategory : '')) .replace(/[^a-z0-9]/gi, '') .toLowerCase(); } - getPinnedGenesCache(category: string, subCategory?: string) { - const key = this.getPinnedGenesCacheKey(category, subCategory); - return this.pinnedGenesCache[key] || []; + setPinnedItemsCache(genes: GCTGene[]) { + this.pinnedItemsCache = genes; } - setPinnedGenesCache(genes: GCTGene[], category: string, subCategory: string) { - const key = this.getPinnedGenesCacheKey(category, subCategory); - this.pinnedGenesCache[key] = genes; - } - - clearPinnedGenesCache(category?: string, subCategory?: string) { - if (!category && !subCategory) { - this.pinnedGenesCache = {}; - } else { - const key = this.getPinnedGenesCacheKey(category as string, subCategory); - - if (subCategory) { - delete this.pinnedGenesCache[key]; - } else { - for (const k in this.pinnedGenesCache) { - if (k.indexOf(key) === 0) { - delete this.pinnedGenesCache[k]; - } - } - } - } + clearPinnedItemsCache() { + this.pinnedItemsCache = []; + this.uniquePinnedGenesCount = this.getCountOfUniqueGenes(); } refreshPinnedGenes() { - this.setPinnedGenesCache(this.pinnedGenes, this.category, this.subCategory); + this.setPinnedItemsCache(this.pinnedItems); this.filter(); this.updateUrl(); } + onPinGeneClick(gene: GCTGene) { + // user-initiated gene pin means we set the last pinned categories + this.setLastPinnedCategories(); + + this.pinGene(gene); + + if (this.category === 'Protein - Differential Expression') + this.uniquePinnedGenesCount = this.getCountOfUniqueGenes(); + } + pinGene(gene: GCTGene, refresh = true) { - const index = this.pinnedGenes.findIndex( + const index = this.pinnedItems.findIndex( (g: GCTGene) => g.uid === gene.uid - ); - - if (index > -1 || this.pinnedGenes.length >= this.maxPinnedGenes) { - return; + ); + if (this.category === 'RNA - Differential Expression') { + if (index > -1 || this.pinnedItems.length >= this.maxPinnedGenes) + return; + } else { + // the same unique id exists, so don't allow it to be added + if (index > -1) + return; + + if (this.uniquePinnedGenesCount >= this.maxPinnedGenes) { + // border condition: if we are at the max allowable pinned genes + // check if the pinned genes list has a gene with the ensembl id, + // in which case the protein can be added + const ensemblIndex = this.pinnedItems.findIndex( + (g: GCTGene) => g.ensembl_gene_id === gene.ensembl_gene_id + ); + if (ensemblIndex < 0) { + this.showUnableToAddItemErrorToast(); + return; + } + } } - - this.pinnedGenes.push(gene); + + this.pinnedItems.push(gene); + this.uniquePinnedGenesCount = this.getCountOfUniqueGenes(); if (refresh) { - this.clearPinnedGenesCache(); + this.clearPinnedItemsCache(); this.refreshPinnedGenes(); } } + getCountOfUniqueGenes() { + // this method is used for protein views since there can be multiple pinned proteins + // that have the same ensg value but different uniprotids + // so this will return the count of genes with unique ensgs + const uids = this.pinnedItems.map(g => g.ensembl_gene_id); + const uniqueUids = new Set(uids); + return uniqueUids.size; + } + + showMaxPinnedRowsErrorToast(rows: number) { + let message = ''; + if (rows === 0) { + message = 'No rows were added because you reached the maximum of ' + + this.maxPinnedGenes + ' pinned Ensembl identifiers.'; + } else if (rows === 1) { + message = 'Only ' + rows + ' row was added, because you reached the maximum of ' + + this.maxPinnedGenes + ' pinned Ensembl identifiers.'; + } else { + message = 'Only ' + rows + ' rows were added, because you reached the maximum of ' + + this.maxPinnedGenes + ' pinned Ensembl identifiers.'; + } + + const self = this; + this.messageService.clear(); + this.messageService.add({ + severity: 'warn', + sticky: true, + summary: '', + detail: message, + }); + setTimeout(() => { + self.messageService.clear(); + }, 5000); + } + pinGenes(genes: GCTGene[]) { - const remaining = this.maxPinnedGenes - this.pinnedGenes.length; + const remaining = this.maxPinnedGenes - this.pinnedItems.length; if (remaining < 1) { return; - } else if (remaining < genes?.length) { - const self = this; - this.messageService.clear(); - this.messageService.add({ - severity: 'info', - sticky: true, - summary: '', - detail: - 'Only ' + - remaining + - ' genes were added, because you reached the maxium of ' + - this.maxPinnedGenes + - ' pinned genes. ', - }); - setTimeout(() => { - self.messageService.clear(); - }, 5000); + } else { + if (this.category === 'RNA - Differential Expression') { + if (remaining < genes?.length) { + this.showMaxPinnedRowsErrorToast(remaining); + } + genes.slice(0, remaining).forEach((g: GCTGene) => { + this.pinGene(g, false); + }); + } else { + genes.slice(0, genes.length).forEach((g: GCTGene) => { + this.pinGene(g, false); + }); + } } - genes.slice(0, remaining).forEach((g: GCTGene) => { - this.pinGene(g, false); + if (this.category === 'Protein - Differential Expression') + this.uniquePinnedGenesCount = this.getCountOfUniqueGenes(); + } + + showUnableToAddItemErrorToast() { + const self = this; + this.messageService.clear(); + this.messageService.add({ + severity: 'warn', + sticky: true, + summary: '', + detail: + 'Could not pin the requested item because you reached the maximum of ' + + this.maxPinnedGenes + + ' pinned Ensembl identifiers.', }); + setTimeout(() => { + self.messageService.clear(); + }, 5000); + } - this.refreshPinnedGenes(); + ensgExistsInProteins(ensemblGeneId: string) { + const ensemblIndex = this.pinnedItems.findIndex( + (g: GCTGene) => g.ensembl_gene_id === ensemblGeneId + ); + if (ensemblIndex < 0) { + return false; + } + return true; + } + + pinProteins(proteins: GCTGene[]) { + let remaining = this.maxPinnedGenes - this.uniquePinnedGenesCount; + + let proteinsAdded = 0; + + let showToast = false; + for (let i = 0; i < proteins.length; i++) { + // if remaining count is zero, show alert toast + if (remaining <= 0) { + // check border condition: when there are no remaining ensg slots, it is still possible there + // are proteins that could be added + showToast = true; + if (this.ensgExistsInProteins(proteins[i].ensembl_gene_id)) { + // if the gene exists, we can still add the protein + this.pinGene(proteins[i], false); + proteinsAdded++; + remaining = this.maxPinnedGenes - this.getCountOfUniqueGenes(); + } + } else { + // add protein to pinned collection + this.pinGene(proteins[i], false); + proteinsAdded++; + // have to call method below since we need to recompute the count of unique genes + remaining = this.maxPinnedGenes - this.getCountOfUniqueGenes(); + } + } + if (showToast) { + this.showMaxPinnedRowsErrorToast(proteinsAdded); + } + this.uniquePinnedGenesCount = this.getCountOfUniqueGenes(); } - unpinGene(gene: GCTGene, refresh = true) { - const index = this.pinnedGenes.findIndex( + onUnPinGeneClick(gene: GCTGene, refresh = true) { + this.setLastPinnedCategories(); + + const index = this.pinnedItems.findIndex( (g: GCTGene) => g.uid === gene.uid ); @@ -710,41 +869,72 @@ export class GeneComparisonToolComponent implements OnInit, AVI, OnDestroy { return; } - this.pinnedGenes.splice(index, 1); + this.pinnedItems.splice(index, 1); if (refresh) { - this.clearPinnedGenesCache(); + this.clearPinnedItemsCache(); this.refreshPinnedGenes(); } + + if (this.category === 'Protein - Differential Expression') + this.uniquePinnedGenesCount = this.getCountOfUniqueGenes(); + } + + onClearAllClick() { + this.setLastPinnedCategories(); + this.clearPinnedGenes(); } clearPinnedGenes() { - this.pinnedGenes = []; - this.clearPinnedGenesCache(); + this.pinnedItems = []; + this.clearPinnedItemsCache(); this.refreshPinnedGenes(); } getPinnedEnsemblGeneIds() { - return this.pinnedGenes.map((g: GCTGene) => g.ensembl_gene_id); + return this.pinnedItems.map((g: GCTGene) => g.ensembl_gene_id); } getPinnedUniProtIds() { - return this.pinnedGenes.map((g: GCTGene) => g.uniprotid); + return this.pinnedItems.map((g: GCTGene) => g.uniprotid); + } + + getPinDisabledStatus() { + if (this.category === 'RNA - Differential Expression') + return this.pinnedItems.length >= this.maxPinnedGenes; + else { + // default to showing pin/pin all button for protein view + return false; + } + } + + onPinAllClick() { + this.setLastPinnedCategories(); + if (this.category === 'RNA - Differential Expression') + this.pinFilteredGenes(); + else + this.pinFilteredProteins(); } pinFilteredGenes() { this.pinGenes(this.genesTable.filteredValue); + this.refreshPinnedGenes(); + } + + pinFilteredProteins() { + this.pinProteins(this.genesTable.filteredValue); + this.refreshPinnedGenes(); } onPinnedGenesModalChange(response: boolean) { if (response) { - this.pinnedGenes = []; - this.pinGenes(this.pendingPinnedGenes); + this.pinnedItems = []; + this.pinGenes(this.pendingPinnedItems); } else { this.category = this.categories[0].value; this.onCategoryChange(); } - this.pendingPinnedGenes = []; + this.pendingPinnedItems = []; } /* ----------------------------------------------------------------------- */ @@ -779,15 +969,15 @@ export class GeneComparisonToolComponent implements OnInit, AVI, OnDestroy { params['sortOrder'] = this.sortOrder; } - if (this.pinnedGenes.length > 0) { - params['pinned'] = this.pinnedGenes.map( - (g: GCTGene) => g.uid || g.ensembl_gene_id + if (this.pinnedItems.length > 0) { + params['pinned'] = this.pinnedItems.map( + (g: GCTGene) => g.uid ); params['pinned'].sort(); } if (this.significanceThresholdActive) { - params['significance'] = [this.significanceThreshold]; + params['significance'] = this.significanceThreshold; } this.urlParams = params; @@ -901,13 +1091,14 @@ export class GeneComparisonToolComponent implements OnInit, AVI, OnDestroy { } } - getCircleSize(pval: number | undefined) { + getCircleSize(pval: number | null | undefined) { // define min and max size of possible circles in pixels const MIN_SIZE = 6; const MAX_SIZE = 50; - // shouldn't be undefined but if it is, don't show a circle - if (pval === undefined) + // pval shouldn't be undefined but if it is, don't show a circle + // null means there is no data in which case, also don't show a circle + if (pval === null || pval === undefined) return 0; // if significance cutoff radio button selected and @@ -1000,7 +1191,7 @@ export class GeneComparisonToolComponent implements OnInit, AVI, OnDestroy { ]; const data: any[][] = []; - this.pinnedGenes.forEach((g: GCTGene) => { + this.pinnedItems.forEach((g: GCTGene) => { const baseRow = [ g.ensembl_gene_id, g.hgnc_symbol, diff --git a/src/app/features/genes/components/gene-details/gene-details.component.ts b/src/app/features/genes/components/gene-details/gene-details.component.ts index d4b03a22..c9c204d6 100644 --- a/src/app/features/genes/components/gene-details/gene-details.component.ts +++ b/src/app/features/genes/components/gene-details/gene-details.component.ts @@ -135,40 +135,47 @@ export class GeneDetailsComponent implements OnInit, AfterViewInit { if (params.get('id')) { this.geneService .getGene(params.get('id') as string) - .subscribe((gene: Gene) => { - this.gene = gene; - - this.panels.forEach((p: Panel) => { - if (p.name == 'nominations' && !this.gene?.total_nominations) { - p.disabled = true; - } else if ( - p.name == 'experimental-validation' && - !this.gene?.experimental_validation?.length - ) { - p.disabled = true; - } else { - p.disabled = false; + .subscribe((gene) => { + if (!gene) { + this.helperService.setLoading(false); + // https://github.com/angular/angular/issues/45202 + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.router.navigateByUrl('/404-not-found', { skipLocationChange: true }); + } else { + this.gene = gene; + + this.panels.forEach((p: Panel) => { + if (p.name == 'nominations' && !this.gene?.total_nominations) { + p.disabled = true; + } else if ( + p.name == 'experimental-validation' && + !this.gene?.experimental_validation?.length + ) { + p.disabled = true; + } else { + p.disabled = false; + } + }); + + const nominationsPanel = this.panels.find( + (p) => p.name == 'nominations' + ); + if (nominationsPanel) { + nominationsPanel.disabled = !this.gene.total_nominations ? true : false; } - }); - const nominationsPanel = this.panels.find( - (p) => p.name == 'nominations' - ); - if (nominationsPanel) { - nominationsPanel.disabled = !this.gene.total_nominations ? true : false; - } + const experimentalValidationPanel = this.panels.find( + (p) => p.name == 'experimental-validation' + ); + if (experimentalValidationPanel) { + experimentalValidationPanel.disabled = !this.gene + .experimental_validation?.length + ? true + : false; + } - const experimentalValidationPanel = this.panels.find( - (p) => p.name == 'experimental-validation' - ); - if (experimentalValidationPanel) { - experimentalValidationPanel.disabled = !this.gene - .experimental_validation?.length - ? true - : false; + this.helperService.setLoading(false); } - - this.helperService.setLoading(false); }); } diff --git a/src/app/features/genes/components/gene-evidence-rna/gene-evidence-rna.component.ts b/src/app/features/genes/components/gene-evidence-rna/gene-evidence-rna.component.ts index 7acce494..c71aaea6 100644 --- a/src/app/features/genes/components/gene-evidence-rna/gene-evidence-rna.component.ts +++ b/src/app/features/genes/components/gene-evidence-rna/gene-evidence-rna.component.ts @@ -1,10 +1,11 @@ -import { Component, Input, AfterViewInit } from '@angular/core'; +import { AfterViewChecked, Component, Input, ViewChild } from '@angular/core'; import { Gene, MedianExpression, RnaDifferentialExpression, } from '../../../../models'; +import { BoxPlotComponent } from '../../../charts/components'; import { GeneService } from '../../services'; import { HelperService } from '../../../../core/services'; @@ -13,7 +14,7 @@ import { HelperService } from '../../../../core/services'; templateUrl: './gene-evidence-rna.component.html', styleUrls: ['./gene-evidence-rna.component.scss'], }) -export class GeneEvidenceRnaComponent implements AfterViewInit { +export class GeneEvidenceRnaComponent implements AfterViewChecked { _gene: Gene | undefined; get gene(): Gene | undefined { return this._gene; @@ -35,6 +36,9 @@ export class GeneEvidenceRnaComponent implements AfterViewInit { consistencyOfChangeChartData: any | undefined; + @ViewChild(BoxPlotComponent) boxPlotComponent: BoxPlotComponent | null = null; + hasScrolled = false; + constructor( private helperService: HelperService, private geneService: GeneService @@ -52,6 +56,8 @@ export class GeneEvidenceRnaComponent implements AfterViewInit { this.differentialExpressionYAxisMax = undefined; this.consistencyOfChangeChartData = undefined; + + this.hasScrolled = false; } init() { @@ -71,13 +77,20 @@ export class GeneEvidenceRnaComponent implements AfterViewInit { this.initConsistencyOfChange(); } - ngAfterViewInit() { - const hash = window.location.hash.substr(1); - if (hash) { - const target = document.getElementById(hash); - if (target) { - // TODO determine if there are async calls altering the offset height - window.scrollTo(0, this.helperService.getOffset(target).top - 150); + ngAfterViewChecked() { + this.scrollToAnchorLink(); + } + + scrollToAnchorLink() { + // AG-1408 - wait for differential expression box plot to finish loading before scrolling + if (this.boxPlotComponent?.isInitialized && !this.hasScrolled) { + const hash = window.location.hash.slice(1); + if (hash) { + const target = document.getElementById(hash); + if (target) { + window.scrollTo(0, this.helperService.getOffset(target).top - 150); + this.hasScrolled = true; + } } } } diff --git a/src/app/features/genes/components/gene-network/gene-network.component.ts b/src/app/features/genes/components/gene-network/gene-network.component.ts index f6d7c0d9..91af46ed 100644 --- a/src/app/features/genes/components/gene-network/gene-network.component.ts +++ b/src/app/features/genes/components/gene-network/gene-network.component.ts @@ -91,6 +91,8 @@ export class GeneNetworkComponent implements OnInit { } navigateToSimilarGenes() { + // https://github.com/angular/angular/issues/45202 + // eslint-disable-next-line @typescript-eslint/no-floating-promises this.router.navigate([ '/genes/' + this._gene?.ensembl_gene_id + '/similar', ]); diff --git a/src/app/features/genes/components/gene-nominated-targets/gene-nominated-targets.component.spec.ts b/src/app/features/genes/components/gene-nominated-targets/gene-nominated-targets.component.spec.ts index 5cdd541e..96d9c5ba 100644 --- a/src/app/features/genes/components/gene-nominated-targets/gene-nominated-targets.component.spec.ts +++ b/src/app/features/genes/components/gene-nominated-targets/gene-nominated-targets.component.spec.ts @@ -10,6 +10,9 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; // -------------------------------------------------------------------------- // import { GeneNominatedTargetsComponent } from './'; import { ApiService, HelperService } from '../../../../core/services'; +import { Gene, GenesResponse } from '../../../../models'; +import { geneMock1, targetNominationMock1 } from '../../../../testing/gene-mocks'; +import { of } from 'rxjs'; // -------------------------------------------------------------------------- // // Tests @@ -17,6 +20,12 @@ import { ApiService, HelperService } from '../../../../core/services'; describe('Component: Gene Nominated Targets', () => { let fixture: ComponentFixture; let component: GeneNominatedTargetsComponent; + let element: HTMLElement; + let mockApiService: ApiService; + + const COLUMN_INDICES = { + 'cohort_study': 4 + }; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -29,10 +38,120 @@ describe('Component: Gene Nominated Targets', () => { beforeEach(async () => { fixture = TestBed.createComponent(GeneNominatedTargetsComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); + const setUp = (genes: Gene[]) => { + const genesResponse: GenesResponse = { + items: genes + }; + mockApiService = TestBed.inject(ApiService); + spyOn(mockApiService, 'getNominatedGenes').and.returnValue( + of(genesResponse) + ); + fixture.detectChanges(); + element = fixture.nativeElement; + + expect(mockApiService.getNominatedGenes).toHaveBeenCalled(); + + const table = element.querySelector('table'); + expect(table).not.toBeNull(); + + const rows = Array.from( + table?.querySelectorAll('tbody tr') || [] + ) as HTMLTableRowElement[]; + + return { rows }; + }; + it('should create', () => { + setUp([geneMock1]); expect(component).toBeTruthy(); }); + + it('should not show null study values', () => { + const gene: Gene = { + ...geneMock1, + target_nominations: [ + { ...targetNominationMock1, study: null }, + { ...targetNominationMock1, study: 'XYZ Study, ABC Study' }, + { ...targetNominationMock1, study: '' }, + { ...targetNominationMock1, study: 'Study 123, Study 456' }, + ], + }; + const { rows } = setUp([gene]); + expect(rows.length).toBe(1); + + const cols = rows[0].cells; + expect(cols.length).toBeGreaterThan(COLUMN_INDICES.cohort_study); + + expect(cols[COLUMN_INDICES.cohort_study].textContent?.trim()).toEqual( + 'ABC Study, Study 123, Study 456, XYZ Study' + ); + }); + + it('should display sorted, unique study values', () => { + const expectedStudyString = 'ACT, Banner, BLSA, Kronos, MSBB, ROSMAP'; + const { rows } = setUp([geneMock1]); + + expect(rows.length).toBe(1); + + const cols = rows[0].cells; + expect(cols.length).toBeGreaterThan(COLUMN_INDICES.cohort_study); + + expect(cols[COLUMN_INDICES.cohort_study].textContent?.trim()).toEqual( + expectedStudyString + ); + }); + + it('should correctly flatten comma separated arrays', () => { + setUp([]); + + expect(component.commaFlattenArray([])).toEqual([]); + + expect( + component.commaFlattenArray(['ACT, BLSA, Banner', 'ACT, BLSA, Banner']) + ).toEqual(['ACT', 'BLSA', 'Banner', 'ACT', 'BLSA', 'Banner']); + + expect(component.commaFlattenArray(['A, B, C', 'D', 'E, F, G, H'])).toEqual( + ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'] + ); + + expect(component.commaFlattenArray(['A', 'B', 'C'])).toEqual([ + 'A', + 'B', + 'C', + ]); + }); + + it('should correctly format display values', () => { + setUp([]); + + expect( + component.getCommaSeparatedStringOfUniqueSortedValues([]) + ).toEqual(''); + + expect( + component.getCommaSeparatedStringOfUniqueSortedValues([ + 'ACT', + 'BLSA', + 'Banner', + 'ACT', + 'BLSA', + 'Banner', + ]) + ).toEqual('ACT, Banner, BLSA'); + + expect( + component.getCommaSeparatedStringOfUniqueSortedValues([ + 'Z', + 'Y', + 'X', + 'A', + 'B', + 'C', + 'B', + 'C', + ]) + ).toEqual('A, B, C, X, Y, Z'); + }); }); diff --git a/src/app/features/genes/components/gene-nominated-targets/gene-nominated-targets.component.ts b/src/app/features/genes/components/gene-nominated-targets/gene-nominated-targets.component.ts index befd6a8b..7db6dccb 100644 --- a/src/app/features/genes/components/gene-nominated-targets/gene-nominated-targets.component.ts +++ b/src/app/features/genes/components/gene-nominated-targets/gene-nominated-targets.component.ts @@ -97,8 +97,8 @@ export class GeneNominatedTargetsComponent implements OnInit { // First map all entries nested in the data to a new array if (de.target_nominations?.length) { teamsArray = de.target_nominations.map((nt: TargetNomination) => nt.team); - studyArray = de.target_nominations.map( - (nt: TargetNomination) => nt.study + studyArray = this.removeNullAndEmptyStrings( + de.target_nominations.map((nt: TargetNomination) => nt.study) ); programsArray = de.target_nominations.map( (nt: TargetNomination) => nt.source @@ -120,37 +120,14 @@ export class GeneNominatedTargetsComponent implements OnInit { inputDataArray = this.commaFlattenArray(inputDataArray); // Populate targetNomination display fields - de.teams_display_value = ''; - if (teamsArray.length) { - de.teams_display_value = teamsArray - .filter(this.getUnique) - .sort((a: string, b: string) => a.localeCompare(b)) - .join(', '); - } - - de.study_display_value = ''; - if (teamsArray.length) { - de.study_display_value = studyArray - .filter(this.getUnique) - .sort((a: string, b: string) => a.localeCompare(b)) - .join(', '); - } - - de.programs_display_value = ''; - if (programsArray.length) { - de.programs_display_value = programsArray - .filter(this.getUnique) - .sort((a: string, b: string) => a.localeCompare(b)) - .join(', '); - } - - de.input_data_display_value = ''; - if (inputDataArray.length) { - de.input_data_display_value = inputDataArray - .filter(this.getUnique) - .sort((a: string, b: string) => a.localeCompare(b)) - .join(', '); - } + de.teams_display_value = + this.getCommaSeparatedStringOfUniqueSortedValues(teamsArray); + de.study_display_value = + this.getCommaSeparatedStringOfUniqueSortedValues(studyArray); + de.programs_display_value = + this.getCommaSeparatedStringOfUniqueSortedValues(programsArray); + de.input_data_display_value = + this.getCommaSeparatedStringOfUniqueSortedValues(inputDataArray); de.initial_nomination_display_value = initialNominationArray.length ? Math.min(...initialNominationArray) @@ -185,31 +162,37 @@ export class GeneNominatedTargetsComponent implements OnInit { }); } + removeNullAndEmptyStrings(items: (string | null)[]) { + return items.filter((item) => Boolean(item)) as string[]; + } + getUnique(value: string, index: number, self: any) { return self.indexOf(value) === index; } - commaFlattenArray(array: any[]): any[] { - const finalArray: any[] = []; - if (array.length) { - array.forEach((t) => { - if (t) { - const i = t.indexOf(', '); - if (i > -1) { - const tmpArray = t.split(', '); - finalArray.push(tmpArray[0]); - finalArray.push(tmpArray[1]); - } else { - finalArray.push(t); - } - } else { - finalArray.push(''); - } - }); - array = finalArray; - } + commaFlattenArray(array: string[]): string[] { + const finalArray: string[] = []; + array.forEach((t) => { + const i = t.indexOf(', '); + if (i > -1) { + const tmpArray = t.split(', '); + tmpArray.forEach((val) => finalArray.push(val)); + } else { + finalArray.push(t); + } + }); + return finalArray; + } - return array; + getCommaSeparatedStringOfUniqueSortedValues(inputArray: string[]) { + let display_value = ''; + if (inputArray.length) { + display_value = inputArray + .filter(this.getUnique) + .sort((a: string, b: string) => a.localeCompare(b)) + .join(', '); + } + return display_value; } onSearch(event: any) { diff --git a/src/app/features/genes/components/gene-search/gene-search.component.ts b/src/app/features/genes/components/gene-search/gene-search.component.ts index 8a8e0fd0..f7a02316 100644 --- a/src/app/features/genes/components/gene-search/gene-search.component.ts +++ b/src/app/features/genes/components/gene-search/gene-search.component.ts @@ -157,6 +157,8 @@ export class GeneSearchComponent extends Unsub implements AfterViewInit { this.results = []; this.showGeneResults = false; this.searchNavigated.emit(); + // https://github.com/angular/angular/issues/45202 + // eslint-disable-next-line @typescript-eslint/no-floating-promises this.router.navigate(['/genes/' + id]); } diff --git a/src/app/features/genes/components/gene-similar/gene-similar.component.html b/src/app/features/genes/components/gene-similar/gene-similar.component.html index 6fd7c42c..3fa20da2 100644 --- a/src/app/features/genes/components/gene-similar/gene-similar.component.html +++ b/src/app/features/genes/components/gene-similar/gene-similar.component.html @@ -32,6 +32,7 @@

Similar Genes

[genes]="genes" [columns]="tableColumns" sortField="hgnc_symbol" + [sortOrder]="1" heading="Similar Genes" [gctLink]="true" gctLinkTooltip="Use Agora's Gene Comparison Tool to compare similar genes in this list" diff --git a/src/app/features/genes/components/gene-similar/gene-similar.component.spec.ts b/src/app/features/genes/components/gene-similar/gene-similar.component.spec.ts index a5fc1649..66a39378 100644 --- a/src/app/features/genes/components/gene-similar/gene-similar.component.spec.ts +++ b/src/app/features/genes/components/gene-similar/gene-similar.component.spec.ts @@ -11,6 +11,7 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { GeneSimilarComponent } from './'; import { GeneService } from '../../services'; import { ApiService, HelperService } from '../../../../core/services'; +import { geneMock1, geneMock2, geneMock3 } from '../../../../testing'; // -------------------------------------------------------------------------- // // Tests @@ -18,6 +19,7 @@ import { ApiService, HelperService } from '../../../../core/services'; describe('Component: Gene Similar', () => { let fixture: ComponentFixture; let component: GeneSimilarComponent; + let element: HTMLElement; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -31,9 +33,33 @@ describe('Component: Gene Similar', () => { fixture = TestBed.createComponent(GeneSimilarComponent); component = fixture.componentInstance; fixture.detectChanges(); + element = fixture.nativeElement; }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should order by hgnc-symbol ascending', () => { + const expectedHgnc = [ + geneMock1.hgnc_symbol, + geneMock2.hgnc_symbol, + geneMock3.hgnc_symbol, + ]; + expectedHgnc.sort(); + + component.genes = [geneMock1, geneMock2, geneMock3]; + fixture.detectChanges(); + + const table = element.querySelector('.similar-gene-table'); + expect(table).not.toBeNull(); + + const rows = Array.from( + table?.querySelectorAll('tbody tr') || [] + ) as HTMLTableRowElement[]; + expect(rows.length).toBe(3); + + const actualHgnc = rows.map(row => row.cells[0].textContent?.trim()); + expect(actualHgnc).toEqual(expectedHgnc); + }); }); diff --git a/src/app/features/genes/components/gene-similar/gene-similar.component.ts b/src/app/features/genes/components/gene-similar/gene-similar.component.ts index 8414a5ad..1d475610 100644 --- a/src/app/features/genes/components/gene-similar/gene-similar.component.ts +++ b/src/app/features/genes/components/gene-similar/gene-similar.component.ts @@ -69,9 +69,16 @@ export class GeneSimilarComponent implements OnInit { this.helperService.setLoading(true); this.geneService .getGene(params.get('id') as string) - .subscribe((gene: Gene) => { - this.gene = gene; - this.init(); + .subscribe((gene: Gene | null) => { + if (!gene) { + this.helperService.setLoading(false); + // https://github.com/angular/angular/issues/45202 + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.router.navigateByUrl('/404-not-found', { skipLocationChange: true }); + } else { + this.gene = gene; + this.init(); + } }); } }); @@ -139,6 +146,8 @@ export class GeneSimilarComponent implements OnInit { navigateToGeneComparisonTool() { const ids: string[] = this.genes.map((g: Gene) => g.ensembl_gene_id); this.helperService.setGCTSelection(ids); + // https://github.com/angular/angular/issues/45202 + // eslint-disable-next-line @typescript-eslint/no-floating-promises this.router.navigate(['/genes/comparison']); } } diff --git a/src/app/features/genes/components/gene-table/gene-table.component.ts b/src/app/features/genes/components/gene-table/gene-table.component.ts index beacff8b..fc80a076 100644 --- a/src/app/features/genes/components/gene-table/gene-table.component.ts +++ b/src/app/features/genes/components/gene-table/gene-table.component.ts @@ -139,6 +139,8 @@ export class GeneTableComponent implements OnInit { } navigateToGene(gene: any) { + // https://github.com/angular/angular/issues/45202 + // eslint-disable-next-line @typescript-eslint/no-floating-promises this.router.navigate(['/genes/' + gene.ensembl_gene_id]); } @@ -174,12 +176,16 @@ export class GeneTableComponent implements OnInit { navigateToGeneComparisonTool() { if (typeof this.gctLink === 'object') { + // https://github.com/angular/angular/issues/45202 + // eslint-disable-next-line @typescript-eslint/no-floating-promises this.router.navigate(['/genes/comparison'], { queryParams: this.gctLink, }); } else { const ids: string[] = this._genes.map((g: Gene) => g.ensembl_gene_id); this.helperService.setGCTSelection(ids); + // https://github.com/angular/angular/issues/45202 + // eslint-disable-next-line @typescript-eslint/no-floating-promises this.router.navigate(['/genes/comparison']); } } diff --git a/src/app/features/genes/services/gene.service.ts b/src/app/features/genes/services/gene.service.ts index 01889bff..d6ffc12e 100644 --- a/src/app/features/genes/services/gene.service.ts +++ b/src/app/features/genes/services/gene.service.ts @@ -27,13 +27,15 @@ export class GeneService { // ------------------------------------------------------------------------ // - getGene(id: string): Observable { + getGene(id: string): Observable { if (this.genes[id]) { return of(this.genes[id]); } - + return this.apiService.getGene(id).pipe( - map((gene: Gene) => { + map((gene: Gene | null) => { + if (!gene) + return null; gene.similar_genes_network = this.getSimilarGenesNetwork(gene); return (this.genes[id] = gene); }) diff --git a/src/app/models/genes.ts b/src/app/models/genes.ts index 55f0f6ef..6f8e5939 100644 --- a/src/app/models/genes.ts +++ b/src/app/models/genes.ts @@ -19,7 +19,7 @@ export interface TargetNomination { predicted_therapeutic_direction: string; data_used_to_support_target_selection: string; data_synapseid: string; - study: string; + study: string | null; input_data: string; validation_study_details: string; initial_nomination: number; diff --git a/src/app/testing/api-service-stub.ts b/src/app/testing/api-service-stub.ts index cc94237d..a7491cb5 100644 --- a/src/app/testing/api-service-stub.ts +++ b/src/app/testing/api-service-stub.ts @@ -20,7 +20,7 @@ import { @Injectable() export class ApiServiceStub { - getGene(id: string): Observable { + getGene(id: string): Observable { return of(geneMock1); } diff --git a/src/app/testing/gene-mocks.ts b/src/app/testing/gene-mocks.ts index e8062f86..c26bdea0 100644 --- a/src/app/testing/gene-mocks.ts +++ b/src/app/testing/gene-mocks.ts @@ -1,6 +1,24 @@ /* eslint-disable */ -import { Gene, GCTGene } from '../models'; +import { Gene, GCTGene, TargetNomination } from '../models'; + +export const targetNominationMock1: TargetNomination = { + source: 'Treat-AD', + team: 'Emory-Sage-SGC', + rank: '7', + hgnc_symbol: 'MSN', + target_choice_justification: + 'MSN was identified as a potential driver protein based on protein coexpression analysis. The group of proteins coexpressed with MSN is conserved across the 3 datasets considered, is enriched for inflammatory processes, and for protein products of genes near loci previously associated with AD risk. MSN has increased abundance in AD across all 3 cohorts examined, and progressively increases in asymptomatic (prodromal) AD to symptomatic AD, and also correlates with both hallmark AD pathology scores (CERAD for amyloid burden; and Braak for Tau extent of spread). MSN is highly expressed as a marker of disease-associated microglia and/or endothelial cell types.', + predicted_therapeutic_direction: + 'Antagonism predicted to reduce disease progression. Phosphorylation downstream of Rho/Rock influences actin, focal adhesion binding; may have redundancy with EZR and RDX, complicating targeting. MSN-directed therapeutics that improve microglial motility and/or phagocytosis competence would reduce abeta/amyloid plaque burden.', + data_used_to_support_target_selection: + 'Discovery quantitative proteomics of FrCx \r\n WPCNA of multiple and consensus cohorts\r\n ANOVA', + data_synapseid: 'syn17008058', + study: 'ACT, BLSA, Banner', + input_data: 'Protein', + validation_study_details: 'validation studies ongoing', + initial_nomination: 2018, +}; export const geneMock1: Gene = { _id: '628ea1be0e8d04279fdbaa26', diff --git a/src/app/testing/gene-service-stub.ts b/src/app/testing/gene-service-stub.ts index c70dbf18..295b37bd 100644 --- a/src/app/testing/gene-service-stub.ts +++ b/src/app/testing/gene-service-stub.ts @@ -30,7 +30,7 @@ export class GeneServiceStub { this.geneService = new GeneService(new ApiServiceStub() as ApiService); } - getGene(id: string): Observable { + getGene(id: string): Observable { return this.geneService.getGene(id); } diff --git a/tests/gene-comparison-tool-pinning-cache.spec.ts b/tests/gene-comparison-tool-pinning-cache.spec.ts new file mode 100644 index 00000000..6fc0b530 --- /dev/null +++ b/tests/gene-comparison-tool-pinning-cache.spec.ts @@ -0,0 +1,416 @@ +import { expect, test } from '@playwright/test'; +import { + GCT_CATEGORIES, + GCT_PROTEIN_SUBCATEGORIES, + GCT_RNA_SUBCATEGORIES, + URL_GCT, + URL_GCT_PROTEIN_TMT, +} from './helpers/constants'; +import { + gene2WithMultipleProteinsTMT, + geneWithMultipleProteinsTMT, +} from './helpers/data'; +import { + changeGctCategory, + changeGctSubcategory, + expectGctPageLoaded, +} from './helpers/gct'; +import { + confirmPinnedItemsByGeneName, + confirmPinnedItemsByItemName, + confirmPinnedItemsCount, + confirmPinnedProteins, + expectPinnedGenesCountText, + expectPinnedProteinsCountText, + pinAllItemsViaSearchByGene, + pinGeneViaSearch, + pinMultipleGenesViaSearch, +} from './helpers/gct-pinning'; + +test.describe('GCT: Caching pinned genes', () => { + test('Pinned genes are maintained when switching between RNA subcategories', async ({ + page, + }) => { + const genes = ['CYB561A3', 'MANBAL', 'PLEC', 'GFAP']; + const nGenes = genes.length; + + await page.goto(URL_GCT); + await expectGctPageLoaded( + page, + GCT_CATEGORIES.RNA, + GCT_RNA_SUBCATEGORIES.AD + ); + + await pinMultipleGenesViaSearch(page, genes); + + await test.step('confirm # genes pinned on AD subcategory page', async () => { + await expectPinnedGenesCountText(page, nGenes); + await confirmPinnedItemsCount(page, nGenes); + }); + + await changeGctSubcategory( + page, + GCT_CATEGORIES.RNA, + GCT_RNA_SUBCATEGORIES.AD, + GCT_RNA_SUBCATEGORIES.AD_AOD + ); + + await changeGctSubcategory( + page, + GCT_CATEGORIES.RNA, + GCT_RNA_SUBCATEGORIES.AD_AOD, + GCT_RNA_SUBCATEGORIES.AD_SEX_F + ); + + await changeGctSubcategory( + page, + GCT_CATEGORIES.RNA, + GCT_RNA_SUBCATEGORIES.AD_SEX_F, + GCT_RNA_SUBCATEGORIES.AD_SEX_M + ); + + // back to original subcategory + await changeGctSubcategory( + page, + GCT_CATEGORIES.RNA, + GCT_RNA_SUBCATEGORIES.AD_SEX_M, + GCT_RNA_SUBCATEGORIES.AD + ); + + await test.step('confirm same genes pinned on AD subcategory page', async () => { + await expectPinnedGenesCountText(page, nGenes); + await confirmPinnedItemsCount(page, nGenes); + await confirmPinnedItemsByItemName(page, genes); + }); + }); + + test('Pinned genes are maintained when switching categories: RNA -> Protein -> RNA', async ({ + page, + }) => { + const genes = ['CYB561A3', 'MANBAL', 'PLEC', 'GFAP']; + const nGenes = genes.length; + + await page.goto(URL_GCT); + await expectGctPageLoaded( + page, + GCT_CATEGORIES.RNA, + GCT_RNA_SUBCATEGORIES.AD + ); + + await pinMultipleGenesViaSearch(page, genes); + + await test.step('confirm # genes pinned on RNA page', async () => { + await expectPinnedGenesCountText(page, nGenes); + await confirmPinnedItemsCount(page, nGenes); + }); + + await changeGctCategory(page, GCT_CATEGORIES.RNA, GCT_CATEGORIES.PROTEIN); + + await changeGctSubcategory( + page, + GCT_CATEGORIES.PROTEIN, + GCT_PROTEIN_SUBCATEGORIES.SRM, + GCT_PROTEIN_SUBCATEGORIES.TMT + ); + + await changeGctSubcategory( + page, + GCT_CATEGORIES.PROTEIN, + GCT_PROTEIN_SUBCATEGORIES.TMT, + GCT_PROTEIN_SUBCATEGORIES.LFQ + ); + + // back to original category + await changeGctCategory(page, GCT_CATEGORIES.PROTEIN, GCT_CATEGORIES.RNA); + + await test.step('confirm same genes pinned on RNA page', async () => { + await expectPinnedGenesCountText(page, nGenes); + await confirmPinnedItemsCount(page, nGenes); + await confirmPinnedItemsByItemName(page, genes); + }); + }); + + test( + 'Pinned proteins are maintained when switching between Protein subcategories', + async ({ page }) => { + const gene1 = geneWithMultipleProteinsTMT.name; + const gene2 = gene2WithMultipleProteinsTMT.name; + const genes = [gene1, gene2]; + const nGenes = genes.length; + + const proteins1 = geneWithMultipleProteinsTMT.uniProtIds; + const proteins2 = gene2WithMultipleProteinsTMT.uniProtIds; + const nProteins = proteins1.length + proteins2.length; + + await page.goto(URL_GCT_PROTEIN_TMT); + await expectGctPageLoaded( + page, + GCT_CATEGORIES.PROTEIN, + GCT_PROTEIN_SUBCATEGORIES.TMT + ); + + for (const gene of genes) { + await pinAllItemsViaSearchByGene(page, gene); + } + + await test.step('confirm # genes and proteins pinned on Protein page', async () => { + await expectPinnedGenesCountText(page, nGenes); + await expectPinnedProteinsCountText(page, nProteins); + await confirmPinnedItemsCount(page, nProteins); + }); + + await changeGctSubcategory( + page, + GCT_CATEGORIES.PROTEIN, + GCT_PROTEIN_SUBCATEGORIES.TMT, + GCT_PROTEIN_SUBCATEGORIES.SRM + ); + + await changeGctSubcategory( + page, + GCT_CATEGORIES.PROTEIN, + GCT_PROTEIN_SUBCATEGORIES.SRM, + GCT_PROTEIN_SUBCATEGORIES.LFQ + ); + + // back to original subcategory + await changeGctSubcategory( + page, + GCT_CATEGORIES.PROTEIN, + GCT_PROTEIN_SUBCATEGORIES.LFQ, + GCT_PROTEIN_SUBCATEGORIES.TMT + ); + + await test.step('confirm same genes and proteins pinned on Protein page', async () => { + await expectPinnedGenesCountText(page, nGenes); + await expectPinnedProteinsCountText(page, nProteins); + await confirmPinnedItemsCount(page, nProteins); + + await confirmPinnedProteins(page, gene1, proteins1); + await confirmPinnedProteins(page, gene2, proteins2); + }); + } + ); + + test( + 'Pinned proteins are maintained when switching categories: Protein -> RNA -> Protein', + async ({ page }) => { + const gene1 = geneWithMultipleProteinsTMT.name; + const gene2 = gene2WithMultipleProteinsTMT.name; + const genes = [gene1, gene2]; + const nGenes = genes.length; + + const proteins1 = geneWithMultipleProteinsTMT.uniProtIds; + const proteins2 = gene2WithMultipleProteinsTMT.uniProtIds; + const nProteins = proteins1.length + proteins2.length; + + await page.goto(URL_GCT_PROTEIN_TMT); + await expectGctPageLoaded( + page, + GCT_CATEGORIES.PROTEIN, + GCT_PROTEIN_SUBCATEGORIES.TMT + ); + + for (const gene of genes) { + await pinAllItemsViaSearchByGene(page, gene); + } + + await test.step('confirm # genes and proteins pinned on Protein page', async () => { + await expectPinnedGenesCountText(page, nGenes); + await expectPinnedProteinsCountText(page, nProteins); + await confirmPinnedItemsCount(page, nProteins); + }); + + await changeGctCategory(page, GCT_CATEGORIES.PROTEIN, GCT_CATEGORIES.RNA); + + await changeGctSubcategory( + page, + GCT_CATEGORIES.RNA, + GCT_RNA_SUBCATEGORIES.AD, + GCT_RNA_SUBCATEGORIES.AD_AOD + ); + + // back to original category + await changeGctCategory(page, GCT_CATEGORIES.RNA, GCT_CATEGORIES.PROTEIN); + + // back to original subcategory + await changeGctSubcategory( + page, + GCT_CATEGORIES.PROTEIN, + GCT_PROTEIN_SUBCATEGORIES.SRM, + GCT_PROTEIN_SUBCATEGORIES.TMT + ); + + await test.step('confirm same genes and proteins pinned on Protein page', async () => { + await expectPinnedGenesCountText(page, nGenes); + await expectPinnedProteinsCountText(page, nProteins); + + await confirmPinnedItemsCount(page, nProteins); + await confirmPinnedProteins(page, gene1, proteins1); + await confirmPinnedProteins(page, gene2, proteins2); + }); + } + ); + + test( + 'Last pinned genes are maintained even if proteins were pinned initially', + async ({ page }) => { + const rnaCategoryGene = geneWithMultipleProteinsTMT.name; + const rnaCategoryProteins = geneWithMultipleProteinsTMT.uniProtIds; + const proteinCategoryGene = gene2WithMultipleProteinsTMT.name; + + await test.step('pin proteins in Protein category', async () => { + await page.goto(URL_GCT_PROTEIN_TMT); + await expectGctPageLoaded( + page, + GCT_CATEGORIES.PROTEIN, + GCT_PROTEIN_SUBCATEGORIES.TMT + ); + + await pinAllItemsViaSearchByGene(page, proteinCategoryGene); + }); + + await test.step('pin genes in RNA category', async () => { + await changeGctCategory( + page, + GCT_CATEGORIES.PROTEIN, + GCT_CATEGORIES.RNA + ); + + await test.step('clear all items', async () => { + await page.getByRole('button', { name: 'Clear All' }).click(); + await expect(page.getByText(/Pinned Genes/i)).not.toBeVisible(); + }); + + await pinGeneViaSearch(page, rnaCategoryGene); + + await test.step('confirm pinned gene', async () => { + await expectPinnedGenesCountText(page, 1); + await confirmPinnedItemsByGeneName(page, rnaCategoryGene, 1); + }); + }); + + await test.step('Protein category pins have changed', async () => { + await changeGctCategory( + page, + GCT_CATEGORIES.RNA, + GCT_CATEGORIES.PROTEIN + ); + await changeGctSubcategory( + page, + GCT_CATEGORIES.PROTEIN, + GCT_PROTEIN_SUBCATEGORIES.SRM, + GCT_PROTEIN_SUBCATEGORIES.TMT + ); + await expectPinnedGenesCountText(page, 1); + await expectPinnedProteinsCountText(page, rnaCategoryProteins.length); + await confirmPinnedProteins(page, rnaCategoryGene, rnaCategoryProteins); + }); + + await test.step('RNA category pins are maintained', async () => { + await changeGctCategory( + page, + GCT_CATEGORIES.PROTEIN, + GCT_CATEGORIES.RNA + ); + + await test.step('confirm pinned gene', async () => { + await expectPinnedGenesCountText(page, 1); + await confirmPinnedItemsByGeneName(page, rnaCategoryGene, 1); + }); + }); + } + ); + + test( + 'Last pinned proteins are maintained even if genes were pinned initially', + async ({ page }) => { + const rnaCategoryGene = geneWithMultipleProteinsTMT.name; + const proteinCategoryGene = gene2WithMultipleProteinsTMT.name; + const proteinCategoryProteins = gene2WithMultipleProteinsTMT.uniProtIds; + + await test.step('pin genes in RNA category', async () => { + await page.goto(URL_GCT); + await expectGctPageLoaded( + page, + GCT_CATEGORIES.RNA, + GCT_RNA_SUBCATEGORIES.AD + ); + + await pinGeneViaSearch(page, rnaCategoryGene); + await confirmPinnedItemsByGeneName(page, rnaCategoryGene, 1); + }); + + await test.step('pin proteins in Protein category', async () => { + await changeGctCategory( + page, + GCT_CATEGORIES.RNA, + GCT_CATEGORIES.PROTEIN + ); + await changeGctSubcategory( + page, + GCT_CATEGORIES.PROTEIN, + GCT_PROTEIN_SUBCATEGORIES.SRM, + GCT_PROTEIN_SUBCATEGORIES.TMT + ); + + await test.step('clear all items', async () => { + await page.getByRole('button', { name: 'Clear All' }).click(); + await expect(page.getByText(/Pinned Genes/i)).not.toBeVisible(); + }); + + await pinAllItemsViaSearchByGene(page, proteinCategoryGene); + + await test.step('confirm pinned items', async () => { + await expectPinnedGenesCountText(page, 1); + await expectPinnedProteinsCountText( + page, + proteinCategoryProteins.length + ); + await confirmPinnedProteins( + page, + proteinCategoryGene, + proteinCategoryProteins + ); + }); + }); + + await test.step('RNA category pins have changed', async () => { + await changeGctCategory( + page, + GCT_CATEGORIES.PROTEIN, + GCT_CATEGORIES.RNA + ); + await expectPinnedGenesCountText(page, 1); + await confirmPinnedItemsByGeneName(page, proteinCategoryGene, 1); + }); + + await test.step('Protein category pins are maintained', async () => { + await changeGctCategory( + page, + GCT_CATEGORIES.RNA, + GCT_CATEGORIES.PROTEIN + ); + await changeGctSubcategory( + page, + GCT_CATEGORIES.PROTEIN, + GCT_PROTEIN_SUBCATEGORIES.SRM, + GCT_PROTEIN_SUBCATEGORIES.TMT + ); + + await test.step('confirm pinned items', async () => { + await expectPinnedGenesCountText(page, 1); + await expectPinnedProteinsCountText( + page, + proteinCategoryProteins.length + ); + await confirmPinnedProteins( + page, + proteinCategoryGene, + proteinCategoryProteins + ); + }); + }); + } + ); +}); diff --git a/tests/gene-comparison-tool-pinning-ui.spec.ts b/tests/gene-comparison-tool-pinning-ui.spec.ts new file mode 100644 index 00000000..134b49d9 --- /dev/null +++ b/tests/gene-comparison-tool-pinning-ui.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test'; +import { baseURL } from '../playwright.config'; +import { + GCT_CATEGORIES, + GCT_RNA_SUBCATEGORIES, + URL_GCT, +} from './helpers/constants'; +import { expectGctPageLoaded } from './helpers/gct'; +import { + expectPinnedGenesCountText, + expectTooManyPinnedGenesToast, + formatPinnedGenesQueryParam, + getPinnedItemsFromUrl, + pinGeneViaSearch, +} from './helpers/gct-pinning'; + +test.describe('GCT: Pinning Genes via UI', () => { + test('one gene can be directly pinned', async ({ page }) => { + const geneName = 'GFAP'; + const geneEnsemblId = 'ENSG00000131095'; + + await page.goto(URL_GCT); + await expectGctPageLoaded( + page, + GCT_CATEGORIES.RNA, + GCT_RNA_SUBCATEGORIES.AD + ); + + await pinGeneViaSearch(page, geneName); + await expectPinnedGenesCountText(page, 1); + + await test.step('url has been updated', async () => { + const expectedUrl = `${baseURL}${URL_GCT}?${formatPinnedGenesQueryParam([ + geneEnsemblId, + ])}`; + expect(page.url()).toEqual(expectedUrl); + }); + }); + + test('only 50 genes are pinned when pinning all genes matched by a quick filter', async ({ + page, + }) => { + await page.goto(URL_GCT); + await expectGctPageLoaded( + page, + GCT_CATEGORIES.RNA, + GCT_RNA_SUBCATEGORIES.AD + ); + + await test.step('apply a quick filter', async () => { + await page.getByRole('button', { name: 'Filter Genes' }).click(); + await page.getByRole('button', { name: 'Quick Filters' }).click(); + await page.getByText('All Nominated targets').click(); + + const filterPanel = page + .locator('.gct-filter-panel-pane-inner') + .filter({ hasText: 'Quick Filters' }); + const filterCloseBtn = filterPanel.locator('.gct-filter-panel-close'); + await filterCloseBtn.click(); + await expect(filterPanel).not.toBeVisible(); + }); + + await test.step('pin genes', async () => { + const pinAllBtn = page.getByRole('button', { name: 'Pin All' }); + await pinAllBtn.click(); + await expect(pinAllBtn).toBeDisabled(); + }); + + await test.step('50 genes were pinned', async () => { + await expectTooManyPinnedGenesToast(page); + await expectPinnedGenesCountText(page, 50); + + const genes = getPinnedItemsFromUrl(page.url()); + expect(genes).toHaveLength(50); + }); + }); +}); diff --git a/tests/gene-comparison-tool-pinning-url.spec.ts b/tests/gene-comparison-tool-pinning-url.spec.ts new file mode 100644 index 00000000..82732fcc --- /dev/null +++ b/tests/gene-comparison-tool-pinning-url.spec.ts @@ -0,0 +1,337 @@ +import { expect, test } from '@playwright/test'; +import { + GCT_CATEGORIES, + GCT_PROTEIN_SUBCATEGORIES, + GCT_RNA_SUBCATEGORIES, + URL_GCT, + URL_GCT_PROTEIN, + URL_GCT_PROTEIN_TMT, +} from './helpers/constants'; +import { + fiftyGenes, + fiftyOneGenes, + fiftyProteinsToFiftyUniqueGenesTMT, + geneWithMultipleProteinsTMT, +} from './helpers/data'; +import { expectGctPageLoaded, getGeneRowButtons } from './helpers/gct'; +import { + confirmPinnedItemsByGeneName, + confirmPinnedItemsCount, + expectPinnedGenesCountText, + expectPinnedProteinsCountText, + expectTooManyPinnedGenesToast, + formatPinnedGenesQueryParam, + getPinnedItemsFromUrl, +} from './helpers/gct-pinning'; + +test.describe('GCT: Pinning Genes from URL', () => { + test('when RNA url does not include pinned genes, no genes are pinned', async ({ + page, + }) => { + await page.goto(URL_GCT); + await expectGctPageLoaded( + page, + GCT_CATEGORIES.RNA, + GCT_RNA_SUBCATEGORIES.AD + ); + + await expect(page.getByText('Pinned Genes')).not.toBeVisible(); + await expect(page.getByText('All Genes')).not.toBeVisible(); + await confirmPinnedItemsCount(page, 0); + }); + + test('when Protein url does not include pinned genes, no genes are pinned', async ({ + page, + }) => { + await page.goto(URL_GCT_PROTEIN); + await expectGctPageLoaded( + page, + GCT_CATEGORIES.PROTEIN, + GCT_PROTEIN_SUBCATEGORIES.SRM + ); + + await expect(page.getByText('Pinned Genes')).not.toBeVisible(); + await expect(page.getByText('All Genes')).not.toBeVisible(); + await confirmPinnedItemsCount(page, 0); + }); + + test('when RNA url includes 50 pinned genes, all genes are pinned', async ({ + page, + }) => { + const url = `${URL_GCT}?${formatPinnedGenesQueryParam(fiftyGenes)}`; + await page.goto(url); + + await expectGctPageLoaded( + page, + GCT_CATEGORIES.RNA, + GCT_RNA_SUBCATEGORIES.AD + ); + + await test.step('confirm pinned all 50 genes', async () => { + await expect(page.getByText('All Genes')).toBeVisible(); + await expectPinnedGenesCountText(page, 50); + await confirmPinnedItemsCount(page, 50); + await expect(page.getByRole('alert')).not.toBeVisible(); + }); + + await test.step('confirm cannot pin other genes', async () => { + const btns = await getGeneRowButtons(page, 'PYGL'); + await expect(btns.open).not.toBeDisabled(); + await expect(btns.pin).toBeDisabled(); + }); + }); + + test('when RNA url includes >50 pinned genes, only 50 genes are pinned and toast is displayed', async ({ + page, + }) => { + const url = `${URL_GCT}?${formatPinnedGenesQueryParam(fiftyOneGenes)}`; + await page.goto(url); + + await expectGctPageLoaded( + page, + GCT_CATEGORIES.RNA, + GCT_RNA_SUBCATEGORIES.AD + ); + + await test.step('confirm only pinned 50 genes', async () => { + await expect(page.getByText('All Genes')).toBeVisible(); + await expectPinnedGenesCountText(page, 50); + await confirmPinnedItemsCount(page, 50); + await expectTooManyPinnedGenesToast(page); + }); + + await test.step('confirm url dropped 51st gene', () => { + const genes = getPinnedItemsFromUrl(page.url()); + expect(genes).toHaveLength(50); + expect(genes).toEqual(fiftyOneGenes.sort().slice(0, -1)); + }); + }); + + test('when RNA url includes invalid gene, that gene is dropped from the url', async ({ + page, + }) => { + const validGeneId = geneWithMultipleProteinsTMT.ensemblId; + const url = `${URL_GCT}?${formatPinnedGenesQueryParam([ + validGeneId, + 'invalidGene', + ])}`; + await page.goto(url); + + await expectGctPageLoaded( + page, + GCT_CATEGORIES.RNA, + GCT_RNA_SUBCATEGORIES.AD + ); + + await test.step('confirm toast not shown', async () => { + await expect(page.getByRole('alert')).not.toBeVisible(); + }); + + await test.step('confirm only pinned 1 gene', async () => { + await expect(page.getByText('All Genes')).toBeVisible(); + await expectPinnedGenesCountText(page, 1); + await confirmPinnedItemsCount(page, 1); + }); + + await test.step('confirm url dropped invalid gene', () => { + const genes = getPinnedItemsFromUrl(page.url()); + expect(genes).toHaveLength(1); + expect(genes).toEqual([validGeneId]); + }); + }); + + test.fail('when RNA url includes proteins, the related gene is pinned', { + annotation: { + type: 'fail', + description: 'Since AG-1425, only genes will be pinned from RNA url' + } + }, + async ({ + page, + }) => { + const geneProteins = geneWithMultipleProteinsTMT.uniProtIds.map( + (uniProtId) => `${geneWithMultipleProteinsTMT.ensemblId}${uniProtId}` + ); + const url = `${URL_GCT}?${formatPinnedGenesQueryParam(geneProteins)}`; + await page.goto(url); + + await expectGctPageLoaded( + page, + GCT_CATEGORIES.RNA, + GCT_RNA_SUBCATEGORIES.AD + ); + + await confirmPinnedItemsByGeneName( + page, + geneWithMultipleProteinsTMT.name, + 1 + ); + await expectPinnedGenesCountText(page, 1); + await confirmPinnedItemsCount(page, 1); + }); + + test.fail( + 'when Protein url includes a gene, all related proteins are pinned', { + annotation: { + type: 'fail', + description: 'Since AG-1425, only proteins will be pinned from Protein url' + } + }, + async ({ page }) => { + const url = `${URL_GCT_PROTEIN_TMT}&${formatPinnedGenesQueryParam([ + geneWithMultipleProteinsTMT.ensemblId, + ])}`; + await page.goto(url); + await expectGctPageLoaded( + page, + GCT_CATEGORIES.PROTEIN, + GCT_PROTEIN_SUBCATEGORIES.TMT + ); + + await confirmPinnedItemsByGeneName( + page, + geneWithMultipleProteinsTMT.name, + geneWithMultipleProteinsTMT.uniProtIds.length + ); + + await test.step('confirm counts', async () => { + await confirmPinnedItemsCount(page, 5); + await expectPinnedGenesCountText(page, 1); + await expectPinnedProteinsCountText(page, 5); + }); + } + ); + + test( + 'when Protein url includes 50 proteins from 50 unique genes, all proteins are pinned', + async ({ page }) => { + const url = `${URL_GCT_PROTEIN_TMT}&${formatPinnedGenesQueryParam( + fiftyProteinsToFiftyUniqueGenesTMT + )}`; + await page.goto(url); + await expectGctPageLoaded( + page, + GCT_CATEGORIES.PROTEIN, + GCT_PROTEIN_SUBCATEGORIES.TMT + ); + + await test.step('confirm counts', async () => { + await confirmPinnedItemsCount(page, 50); + await expectPinnedGenesCountText(page, 50); + await expectPinnedProteinsCountText(page, 50); + }); + } + ); + + test( + 'when Protein url includes >50 proteins from 50 unique genes, all proteins are pinned', + async ({ page }) => { + const fortyNineProteinsToUniqueGenes = + fiftyProteinsToFiftyUniqueGenesTMT.slice(0, -1); + const oneGeneWithManyProteins = + geneWithMultipleProteinsTMT.uniProtIds.map( + (uniProtId) => `${geneWithMultipleProteinsTMT.ensemblId}${uniProtId}` + ); + const allProteins = [ + ...fortyNineProteinsToUniqueGenes, + ...oneGeneWithManyProteins, + ]; + const url = `${URL_GCT_PROTEIN_TMT}&${formatPinnedGenesQueryParam( + allProteins + )}`; + + await page.goto(url); + + await expectGctPageLoaded( + page, + GCT_CATEGORIES.PROTEIN, + GCT_PROTEIN_SUBCATEGORIES.TMT + ); + + await test.step('confirm counts', async () => { + await expectPinnedGenesCountText(page, 50); + await confirmPinnedItemsCount(page, allProteins.length); + await expectPinnedProteinsCountText(page, allProteins.length); + }); + + await confirmPinnedItemsByGeneName( + page, + geneWithMultipleProteinsTMT.name, + geneWithMultipleProteinsTMT.uniProtIds.length + ); + } + ); + + test( + 'when Protein url includes proteins from 51 unique genes, only proteins from 50 genes are pinned', + async ({ page }) => { + const oneGeneWithManyProteins = + geneWithMultipleProteinsTMT.uniProtIds.map( + (uniProtId) => `${geneWithMultipleProteinsTMT.ensemblId}${uniProtId}` + ); + const allProteins = [ + ...fiftyProteinsToFiftyUniqueGenesTMT, + ...oneGeneWithManyProteins, + ]; + const expectedPinnedProteins = [ + ...fiftyProteinsToFiftyUniqueGenesTMT.slice(0, -1), + ...oneGeneWithManyProteins, + ]; + + const url = `${URL_GCT_PROTEIN_TMT}&${formatPinnedGenesQueryParam( + allProteins + )}`; + + await page.goto(url); + + await expectGctPageLoaded( + page, + GCT_CATEGORIES.PROTEIN, + GCT_PROTEIN_SUBCATEGORIES.TMT + ); + + await test.step('confirm counts', async () => { + await expectPinnedGenesCountText(page, 50); + await confirmPinnedItemsCount(page, expectedPinnedProteins.length); + await expectPinnedProteinsCountText( + page, + expectedPinnedProteins.length + ); + }); + } + ); + + test( + 'when Protein url includes invalid protein, that protein is dropped from the url', + async ({ page }) => { + const validGeneProtein = fiftyProteinsToFiftyUniqueGenesTMT[1]; + const url = `${URL_GCT_PROTEIN_TMT}&${formatPinnedGenesQueryParam([ + validGeneProtein, + 'invalidGeneProtein', + ])}`; + await page.goto(url); + + await expectGctPageLoaded( + page, + GCT_CATEGORIES.PROTEIN, + GCT_PROTEIN_SUBCATEGORIES.TMT + ); + + await test.step('confirm toast not shown', async () => { + await expect(page.getByRole('alert')).not.toBeVisible(); + }); + + await test.step('confirm only pinned 1 protein', async () => { + await expectPinnedGenesCountText(page, 1); + await confirmPinnedItemsCount(page, 1); + await expectPinnedProteinsCountText(page, 1); + }); + + await test.step('confirm url dropped invalid protein', () => { + const geneProteins = getPinnedItemsFromUrl(page.url()); + expect(geneProteins).toHaveLength(1); + expect(geneProteins).toEqual([validGeneProtein]); + }); + } + ); +}); diff --git a/tests/gene-comparison-tool.spec.ts b/tests/gene-comparison-tool.spec.ts index f203600a..571c8702 100644 --- a/tests/gene-comparison-tool.spec.ts +++ b/tests/gene-comparison-tool.spec.ts @@ -1,30 +1,75 @@ import { test, expect } from '@playwright/test'; +import { GCT_CATEGORIES, URL_GCT_PROTEIN } from './helpers/constants'; +import { waitForSpinnerNotVisible } from './helpers/utils'; +import { baseURL } from '../playwright.config'; +import { changeGctCategory, closeGctHelpDialog } from './helpers/gct'; + +const URL_GCT = '/genes/comparison'; +const URL_RNA = '/genes/comparison?'; +const URL_PROTEIN = '/genes/comparison?category=Protein+-+Differential+Expression'; test.describe('specific viewport block', () => { test.slow(); test.use({ viewport: { width: 1600, height: 1200 } }); test('has title', async ({ page }) => { - await page.goto('/genes/comparison?category=Protein+-+Differential+Expression'); + await page.goto(`${ URL_GCT }`); // wait for page to load (i.e. spinner to disappear) - await expect(page.locator('div:nth-child(4) > div > .spinner')) - .not.toBeVisible({ timeout: 250000}); + await waitForSpinnerNotVisible(page, 250_000); // Expect a title "to contain" a substring. await expect(page).toHaveTitle('Gene Comparison | Visual comparison tool for AD genes'); }); - test('sub-category is SRM by default', async ({ page }) => { + test('protein has sub-category of SRM by default', async ({ page }) => { // set category for Protein - Differential Expression - await page.goto('/genes/comparison?category=Protein+-+Differential+Expression'); + await page.goto(URL_GCT_PROTEIN); // wait for page to load (i.e. spinner to disappear) - await expect(page.locator('div:nth-child(4) > div > .spinner')) - .not.toBeVisible({ timeout: 150000}); + await waitForSpinnerNotVisible(page, 150_000); // expect sub-category dropdown to be SRM const dropdown = page.locator('#subCategory'); await expect(dropdown).toHaveText('Targeted Selected Reaction Monitoring (SRM)'); }); + + test('switching from RNA to Protein with RNA-specific column ordering reverts back to Risk Score descending', async ({ page }) => { + // set category to RNA + await page.goto(`${ URL_RNA }`); + + // wait for page to load (i.e. spinner to disappear) + await waitForSpinnerNotVisible(page, 150_000); + + // Gene Comparison Overview tutorial modal + const tutorialModal = page.getByText('Gene Comparison Overview'); + await expect(tutorialModal).toBeVisible({ timeout: 10_000}); + + // close the Gene Comparison Overview tutorial modal + await closeGctHelpDialog(page); + + // Gene Comparison Overview tutorial modal + await expect(tutorialModal).not.toBeVisible({ timeout: 10_000}); + + // sort by FP ascending + const FPColumn = page.getByText('FP'); + // first click sorts descending + await FPColumn.click(); + // second click sorts ascending + await FPColumn.click(); + + // expect url to be correct + expect(page.url()).toBe(`${ baseURL }${ URL_RNA }sortField=FP&sortOrder=1`); + + // change category to Protein + await changeGctCategory(page, GCT_CATEGORIES.RNA, GCT_CATEGORIES.PROTEIN); + + // expect url to be correct + expect(page.url()).toBe(`${ baseURL }${ URL_PROTEIN }`); + + // expect sort arrow to be descending + await expect( + page.getByRole('columnheader', { name: 'RISK SCORE' }).locator('i') + ).toHaveClass(/pi-sort-amount-down/); + }); }); diff --git a/tests/gene-details.spec.ts b/tests/gene-details.spec.ts new file mode 100644 index 00000000..9d22c37d --- /dev/null +++ b/tests/gene-details.spec.ts @@ -0,0 +1,30 @@ +import { test, expect } from '@playwright/test'; +import { waitForSpinnerNotVisible } from './helpers/utils'; + +test.describe('specific viewport block', () => { + test.slow(); + test.use({ viewport: { width: 1600, height: 1200 } }); + + test('invalid gene results in a 404 redirect', async ({ page }) => { + // go to invalid ENSG page + await page.goto('/genes/ENSG00000000000'); + await waitForSpinnerNotVisible(page); + + // expect a title "to contain" a substring. + await expect(page).toHaveTitle('Agora'); + + // expect div for page not found content to be visible + await expect(page.locator('.page-not-found')).toBeVisible(); + }); + + test('consistency of change section heading is visible when using anchor link', async ({ page}) => { + await page.goto( + '/genes/ENSG00000178209/evidence/rna?model=AD Diagnosis males and females#consistency-of-change' + ); + + await waitForSpinnerNotVisible(page); + + const header = page.getByRole('heading', { name: 'Consistency of Change in Expression'}); + await expect(header).toBeInViewport(); + }); +}); diff --git a/tests/gene-resources.spec.ts b/tests/gene-resources.spec.ts index f109b1d8..ff9b63bb 100644 --- a/tests/gene-resources.spec.ts +++ b/tests/gene-resources.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { waitForSpinnerNotVisible } from './helpers/utils'; test.describe('specific viewport block', () => { test.slow(); @@ -8,8 +9,7 @@ test.describe('specific viewport block', () => { await page.goto('/genes/ENSG00000178209/resources'); // wait for page to load (i.e. spinner to disappear) - await expect(page.locator('div:nth-child(4) > div > .spinner')) - .not.toBeVisible({ timeout: 250000}); + await waitForSpinnerNotVisible(page); // Expect a title "to contain" a substring. await expect(page).toHaveTitle('Agora'); @@ -20,8 +20,7 @@ test.describe('specific viewport block', () => { await page.goto('/genes/ENSG00000178209/resources'); // wait for page to load (i.e. spinner to disappear) - await expect(page.locator('div:nth-child(4) > div > .spinner')) - .not.toBeVisible({ timeout: 150000}); + await waitForSpinnerNotVisible(page, 150000); // expect link named 'Visit AMP-PD' const link = page.getByRole('link', { name: 'Visit AMP-PD' }); diff --git a/tests/gene-similar.spec.ts b/tests/gene-similar.spec.ts new file mode 100644 index 00000000..586efee8 --- /dev/null +++ b/tests/gene-similar.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from '@playwright/test'; +import { waitForSpinnerNotVisible } from './helpers/utils'; + +test.describe('specific viewport block', () => { + test.slow(); + test.use({ viewport: { width: 1600, height: 1200 } }); + + test('invalid gene results in a 404 redirect', async ({ page }) => { + // go to invalid ENSG page + await page.goto('/genes/ENSG00000000000/similar'); + await waitForSpinnerNotVisible(page); + + // expect a title "to contain" a substring. + await expect(page).toHaveTitle('Agora'); + + // expect div for page not found content to be visible + await expect(page.locator('.page-not-found')).toBeVisible(); + }); +}); diff --git a/tests/helpers/constants.ts b/tests/helpers/constants.ts new file mode 100644 index 00000000..1b4ba4d1 --- /dev/null +++ b/tests/helpers/constants.ts @@ -0,0 +1,21 @@ +export const GCT_CATEGORIES = { + RNA: 'RNA - Differential Expression', + PROTEIN: 'Protein - Differential Expression', +}; + +export const GCT_RNA_SUBCATEGORIES = { + AD: 'AD Diagnosis (males and females)', + AD_AOD: 'AD Diagnosis x AOD (males and females)', + AD_SEX_F: 'AD Diagnosis x Sex (females only)', + AD_SEX_M: 'AD Diagnosis x Sex (males only)', +}; + +export const GCT_PROTEIN_SUBCATEGORIES = { + SRM: 'Targeted Selected Reaction Monitoring (SRM)', + TMT: 'Genome-wide Tandem Mass Tag (TMT)', + LFQ: 'Genome-wide Label-free Quantification (LFQ)', +}; + +export const URL_GCT = '/genes/comparison'; +export const URL_GCT_PROTEIN = `${URL_GCT}?category=${GCT_CATEGORIES.PROTEIN}`; +export const URL_GCT_PROTEIN_TMT = `${URL_GCT_PROTEIN}&subCategory=TMT`; diff --git a/tests/helpers/data.ts b/tests/helpers/data.ts new file mode 100644 index 00000000..53d6b43e --- /dev/null +++ b/tests/helpers/data.ts @@ -0,0 +1,119 @@ +export const fiftyGenes = [ + 'ENSG00000008283', + 'ENSG00000089041', + 'ENSG00000089250', + 'ENSG00000095970', + 'ENSG00000103197', + 'ENSG00000106066', + 'ENSG00000107099', + 'ENSG00000107902', + 'ENSG00000110514', + 'ENSG00000112294', + 'ENSG00000124299', + 'ENSG00000125877', + 'ENSG00000126602', + 'ENSG00000130203', + 'ENSG00000130414', + 'ENSG00000130702', + 'ENSG00000132561', + 'ENSG00000134324', + 'ENSG00000138095', + 'ENSG00000140945', + 'ENSG00000141338', + 'ENSG00000142192', + 'ENSG00000143252', + 'ENSG00000144455', + 'ENSG00000146648', + 'ENSG00000147852', + 'ENSG00000153113', + 'ENSG00000158864', + 'ENSG00000158887', + 'ENSG00000160285', + 'ENSG00000161011', + 'ENSG00000161904', + 'ENSG00000162688', + 'ENSG00000164347', + 'ENSG00000171608', + 'ENSG00000178209', + 'ENSG00000182512', + 'ENSG00000185532', + 'ENSG00000186153', + 'ENSG00000186868', + 'ENSG00000187323', + 'ENSG00000196126', + 'ENSG00000197535', + 'ENSG00000197912', + 'ENSG00000197943', + 'ENSG00000204287', + 'ENSG00000204525', + 'ENSG00000214960', + 'ENSG00000234745', + 'ENSG00000273079', +]; + +export const fiftyOneGenes = [...fiftyGenes, 'ENSG00000100504']; + +export const geneWithMultipleProteinsTMT = { + name: 'GFAP', + ensemblId: 'ENSG00000131095', + uniProtIds: ['K7EKD1', 'K7ELP4', 'K7EPI4', 'P14136', 'P14136-3'], +}; +export const gene2WithMultipleProteinsTMT = { + name: 'PLEC', + ensemblId: 'ENSG00000178209', + uniProtIds: ['Q15149', 'Q15149-3', 'Q15149-8'], +}; + +// Includes 50 unique genes for a specific protein in TMT dataset +export const fiftyProteinsToFiftyUniqueGenesTMT = [ + 'ENSG00000003147Q05084', + 'ENSG00000023228P28331', + 'ENSG00000038427P13611', + 'ENSG00000069966O14775', + 'ENSG00000078674Q15154', + 'ENSG00000091140P09622', + 'ENSG00000100504P06737', + 'ENSG00000103485Q15274', + 'ENSG00000103723Q13367-2', + 'ENSG00000104763Q13510', + 'ENSG00000105607Q92947', + 'ENSG00000106976Q05193', + 'ENSG00000108515P13929', + 'ENSG00000118473Q9BQI5-5', + 'ENSG00000126267P14854', + 'ENSG00000130203P02649', + 'ENSG00000130414O95299', + 'ENSG00000132470P16144-3', + 'ENSG00000134324Q14693', + 'ENSG00000136153Q8WWI1-2', + 'ENSG00000138095P42704', + 'ENSG00000139180F5GY40', + 'ENSG00000141295J3QL71', + 'ENSG00000141338O94911-3', + 'ENSG00000143603A0A087WYJ0', + 'ENSG00000150995Q14643-2', + 'ENSG00000151150A0A087WVC2', + 'ENSG00000151655P19823', + 'ENSG00000151834E9PBQ7', + 'ENSG00000158864O75306', + 'ENSG00000159082C9JFZ1', + 'ENSG00000160789P02545', + 'ENSG00000162946Q9NRI5', + 'ENSG00000163596F8WBQ1', + 'ENSG00000172803Q86XE0', + 'ENSG00000174469Q9UHC6', + 'ENSG00000174886Q86Y39', + 'ENSG00000175161Q8N3J6', + 'ENSG00000175287Q5SRE7', + 'ENSG00000178209Q15149', + 'ENSG00000183117E5RIG2', + 'ENSG00000183454Q12879', + 'ENSG00000196126P20039', + 'ENSG00000197912Q9UQ90', + 'ENSG00000197943P16885', + 'ENSG00000198087Q9Y5K6', + 'ENSG00000204287P01903', + 'ENSG00000213722O95865', + 'ENSG00000261701P00739', + 'ENSG00000273079Q13224', +]; diff --git a/tests/helpers/gct-pinning.ts b/tests/helpers/gct-pinning.ts new file mode 100644 index 00000000..87023f49 --- /dev/null +++ b/tests/helpers/gct-pinning.ts @@ -0,0 +1,136 @@ +import test, { expect, Page } from '@playwright/test'; +import { convertToQueryParam } from './utils'; +import { getGeneRowButtons } from './gct'; + +export const closePinnedGeneWarningModal = async (page: Page) => { + await test.step('click through warning modal', async () => { + const dialog = page.getByRole('dialog').filter({ + hasText: /you will lose some of your pins if you proceed/i, + }); + const proceedBtn = dialog.getByRole('button', { name: 'Proceed' }); + await proceedBtn.click(); + await expect(dialog).not.toBeVisible(); + }); +}; + +export const formatPinnedGenesQueryParam = (pinnedGenes: string[]) => { + return convertToQueryParam(pinnedGenes, 'pinned'); +}; + +export const expectPinnedGenesCountText = async ( + page: Page, + nGenes: number +) => { + await expect(page.getByText(`Pinned Genes (${nGenes}/50)`)).toBeVisible(); +}; + +export const expectPinnedProteinsCountText = async ( + page: Page, + nProteins: number +) => { + await expect( + page.getByText(`${nProteins} Protein${nProteins > 1 ? 's' : ''}`) + ).toBeVisible(); +}; + +export const expectTooManyPinnedGenesToast = async (page: Page) => { + const alert = page.getByRole('alert'); + await expect(alert).toHaveText( + 'Only 50 rows were added, because you reached the maximum of 50 pinned Ensembl identifiers.' + ); +}; + +export const getPinnedItemsFromUrl = (url: string) => { + const queryString = url.split('?'); + if (queryString.length !== 2) return null; + + const urlParams = new URLSearchParams(queryString[1]); + const genesString = urlParams.get('pinned'); + return genesString?.split(/%2C|,/); +}; + +export const confirmPinnedItemsCount = async (page: Page, nPinned: number) => { + await test.step(`confirm pinned items count is ${nPinned}`, async () => { + const rows = page.locator('tr.pinned'); + const rowsCount = await rows.count(); + expect(rowsCount).toBe(nPinned); + }); +}; + +export const confirmPinnedItemsByGeneName = async ( + page: Page, + geneName: string, + nItems: number +) => { + await test.step(`confirm ${nItems} pinned items for ${geneName}`, async () => { + const rows = page.getByRole('row').filter({ hasText: geneName }); + const nRows = await rows.count(); + expect(nRows).toEqual(nItems); + + for (let i = 0; i < nRows; ++i) { + await expect(rows.nth(i)).toHaveClass(/pinned/i); + } + }); +}; + +export const confirmPinnedItemsByItemName = async ( + page: Page, + itemNames: string[] +) => { + for (const itemName of itemNames) { + await confirmPinnedItemsByGeneName(page, itemName, 1); + } +}; + +export const confirmPinnedProteins = async ( + page: Page, + geneName: string, + proteins: string[] +) => { + const geneProteins = proteins.map((protein) => `${geneName} (${protein})`); + await confirmPinnedItemsByItemName(page, geneProteins); +}; + +// assumes that geneName will resolve to a single search result +export const pinGeneViaSearch = async (page: Page, geneName: string) => { + await test.step(`search for a gene named ${geneName}`, async () => { + const search = page.getByPlaceholder('Search', { exact: true }); + await search.fill(geneName); + }); + + await test.step('pin the gene', async () => { + const btns = await getGeneRowButtons(page, geneName); + await btns.pin.click(); + }); +}; + +// assumes that each geneName will resolve to a single search result +export const pinMultipleGenesViaSearch = async ( + page: Page, + geneNames: string[] +) => { + await test.step('pin genes', async () => { + let pinnedCount = 0; + for (const gene of geneNames) { + await pinGeneViaSearch(page, gene); + pinnedCount++; + await expectPinnedGenesCountText(page, pinnedCount); + await confirmPinnedItemsByGeneName(page, gene, 1); + } + }); +}; + +export const pinAllItemsViaSearchByGene = async ( + page: Page, + geneName: string +) => { + await test.step(`search for items named ${geneName}`, async () => { + const search = page.getByPlaceholder('Search', { exact: true }); + await search.fill(geneName); + }); + + await test.step('pin the gene', async () => { + const pinAllBtn = page.getByRole('button', { name: 'Pin All' }); + await pinAllBtn.click(); + }); +}; diff --git a/tests/helpers/gct.ts b/tests/helpers/gct.ts new file mode 100644 index 00000000..03927b54 --- /dev/null +++ b/tests/helpers/gct.ts @@ -0,0 +1,126 @@ +import { Page, expect, test } from '@playwright/test'; +import { waitForSpinnerNotVisible } from './utils'; +import { + GCT_CATEGORIES, + GCT_PROTEIN_SUBCATEGORIES, + GCT_RNA_SUBCATEGORIES, +} from './constants'; + +export const expectGctPageLoaded = async ( + page: Page, + category: string, + subcategory: string, + closeHelpDialog = true +) => { + await test.step('wait for GCT page to load', async () => { + if (closeHelpDialog) await closeGctHelpDialog(page); + + await waitForSpinnerNotVisible(page); + + await expect( + page.getByRole('heading', { name: 'Gene Comparison Tool' }) + ).toBeVisible(); + + await expect( + page.getByRole('button', { name: 'Filter Genes' }) + ).toBeVisible(); + await expect(page.getByRole('button', { name: 'Share URL' })).toBeVisible(); + + await expect(page.getByText(category)).toBeVisible(); + await expect(page.getByText(subcategory)).toBeVisible(); + }); +}; + +export const closeGctHelpDialog = async (page: Page) => { + await test.step('close GCT help dialog', async () => { + const dialog = page.getByRole('dialog'); + await expect(dialog).toHaveText(/Gene Comparison Overview/i); + + const closeBtn = dialog.getByRole('button').first(); + await closeBtn.click(); + + await expect(dialog).not.toBeVisible(); + }); +}; + +export const getGeneRowButtons = async (page: Page, name: string) => { + const geneName = page.locator('.gene-controls').filter({ hasText: name }); + await geneName.hover(); + + const buttons = page.getByRole('cell', { name: name }).getByRole('button'); + const count = await buttons.count(); + expect(count).toBe(2); + + return { + open: buttons.first(), + pin: buttons.nth(1), + }; +}; + +const changeGctDropdown = async ( + page: Page, + current: string, + desired: string +) => { + await test.step(`change dropdown to ${desired}`, async () => { + const url = page.url(); + + await test.step('open dropdown', async () => { + const menu = page.getByText(current); + await menu.click(); + }); + + await test.step('wait for dropdown to stabilize', async () => { + // Must wait for the listbox displaying the options to be stable + // ...before attempting to click on an option + // ...otherwise, clicking an option will be flaky and intermittently fail + const listbox = await page.$('ul[role=listbox]'); + await listbox?.waitForElementState('stable'); + }); + + const option = await test.step('select new option', async () => { + const option = page.getByRole('option', { name: desired }); + await option.click({ timeout: 30_000 }); + return option; + }); + + await test.step('wait for page to redirect', async () => { + await expect(() => { + expect(page.url()).not.toEqual(url); + }).toPass({ timeout: 60_000 }); + }); + + // Handle case where listbox doesn't close after page redirects + await test.step('ensure dropdown closes', async () => { + await page.keyboard.press('Escape'); + await expect(option).not.toBeVisible(); + }); + }); +}; + +export const changeGctSubcategory = async ( + page: Page, + category: string, + currentSubCategory: string, + desiredSubCategory: string +) => { + await test.step(`change to ${desiredSubCategory} subcategory`, async () => { + await changeGctDropdown(page, currentSubCategory, desiredSubCategory); + await expectGctPageLoaded(page, category, desiredSubCategory, false); + }); +}; + +export const changeGctCategory = async ( + page: Page, + currentCategory: string, + desiredCategory: string +) => { + await test.step(`change to ${desiredCategory} category`, async () => { + const defaultSubcategory = + desiredCategory === GCT_CATEGORIES.RNA + ? GCT_RNA_SUBCATEGORIES.AD + : GCT_PROTEIN_SUBCATEGORIES.SRM; + await changeGctDropdown(page, currentCategory, desiredCategory); + await expectGctPageLoaded(page, desiredCategory, defaultSubcategory, false); + }); +}; diff --git a/tests/helpers/utils.ts b/tests/helpers/utils.ts new file mode 100644 index 00000000..184326b9 --- /dev/null +++ b/tests/helpers/utils.ts @@ -0,0 +1,14 @@ +import { Page, expect } from '@playwright/test'; + +export const waitForSpinnerNotVisible = async ( + page: Page, + timeout = 60 * 2 * 1000 +) => { + await expect( + page.locator('div:nth-child(4) > div > .spinner') + ).not.toBeVisible({ timeout: timeout }); +}; + +export const convertToQueryParam = (list: string[], queryKey: string) => { + return `${queryKey}=${list.join(',')}`; +}; diff --git a/tests/homepage.spec.ts b/tests/homepage.spec.ts index 83cae356..fbc9c5cb 100644 --- a/tests/homepage.spec.ts +++ b/tests/homepage.spec.ts @@ -35,15 +35,15 @@ test.describe('specific viewport block', () => { await page.goto('/'); // Hamburger menu should be hidden for the desktop viewport - expect(page.locator('button.header-nav-toggle')).toBeHidden(); - + await expect(page.locator('button.header-nav-toggle')).toBeHidden(); + // look for news link and click it - const newsLink = await page.getByRole('link', { name: 'News'}); + const newsLink = page.getByRole('link', { name: 'News' }); // news link should be visible on the home page - expect(newsLink).toBeVisible(); + await expect(newsLink).toBeVisible(); await newsLink.click(); await expect(page).toHaveTitle('News | Agora Releases'); }); -}); \ No newline at end of file +}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 00000000..e0e252fd --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,37 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "baseUrl": "./src", + "outDir": "./dist/out-tsc", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "sourceMap": true, + "declaration": false, + "downlevelIteration": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "importHelpers": true, + "target": "es2017", + "module": "es2020", + "lib": ["es2020", "dom"], + "allowSyntheticDefaultImports": true + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.html", + "./src/**/*.scss", + "./tests/**.ts", + "./tests/**/**.ts", + "playwright.config.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json index bd8923ae..b6f22b29 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,31 +1,5 @@ { - "compileOnSave": false, - "compilerOptions": { - "baseUrl": "./src", - "outDir": "./dist/out-tsc", - "forceConsistentCasingInFileNames": true, - "strict": true, - "noImplicitOverride": true, - "noPropertyAccessFromIndexSignature": false, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "sourceMap": true, - "declaration": false, - "downlevelIteration": true, - "experimentalDecorators": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "es2017", - "module": "es2020", - "lib": ["es2020", "dom"], - "allowSyntheticDefaultImports": true - }, - "angularCompilerOptions": { - "enableI18nLegacyMessageIdFormat": false, - "strictInjectionParameters": true, - "strictInputAccessModifiers": true, - "strictTemplates": true - }, + "extends": "./tsconfig.base.json", "exclude": [ "./node_modules", "./dist",