diff --git a/package-lock.json b/package-lock.json index 8bf920052..7529805a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -114,12 +114,12 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.1602.14", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1602.14.tgz", - "integrity": "sha512-eSdONEV5dbtLNiOMBy9Ue9DdJ1ct6dH9RdZfYiedq6VZn0lejePAjY36MYVXgq2jTE+v/uIiaNy7caea5pt55A==", + "version": "0.1602.15", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1602.15.tgz", + "integrity": "sha512-+yPlUG5c8l7Z/A6dyeV7NQjj4WDWnWWQt+8eW/KInwVwoYiM32ntTJ0M4uU/aDdHuwKQnMLly28AcSWPWKYf2Q==", "dev": true, "dependencies": { - "@angular-devkit/core": "16.2.14", + "@angular-devkit/core": "16.2.15", "rxjs": "7.8.1" }, "engines": { @@ -129,15 +129,15 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "16.2.14", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-16.2.14.tgz", - "integrity": "sha512-bXQ6i7QPhwmYHuh+DSNkBhjTIHQF0C6fqZEg2ApJA3NmnzE98oQnmJ9AnGnAkdf1Mjn3xi2gxoZWPDDxGEINMw==", + "version": "16.2.15", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-16.2.15.tgz", + "integrity": "sha512-gw9wQENYVNUCB2bnzk0yKd6YGlemDwuwKnrPnSm4myyMuScZpW+e+zliGW+JXRuVWZqiTJNcdd58e4CrrreILg==", "dev": true, "dependencies": { "@ampproject/remapping": "2.2.1", - "@angular-devkit/architect": "0.1602.14", - "@angular-devkit/build-webpack": "0.1602.14", - "@angular-devkit/core": "16.2.14", + "@angular-devkit/architect": "0.1602.15", + "@angular-devkit/build-webpack": "0.1602.15", + "@angular-devkit/core": "16.2.15", "@babel/core": "7.22.9", "@babel/generator": "7.22.9", "@babel/helper-annotate-as-pure": "7.22.5", @@ -149,7 +149,7 @@ "@babel/runtime": "7.22.6", "@babel/template": "7.22.5", "@discoveryjs/json-ext": "0.5.7", - "@ngtools/webpack": "16.2.14", + "@ngtools/webpack": "16.2.15", "@vitejs/plugin-basic-ssl": "1.0.1", "ansi-colors": "4.1.3", "autoprefixer": "10.4.14", @@ -193,7 +193,7 @@ "tree-kill": "1.2.2", "tslib": "2.6.1", "vite": "4.5.3", - "webpack": "5.88.2", + "webpack": "5.94.0", "webpack-dev-middleware": "6.1.2", "webpack-dev-server": "4.15.1", "webpack-merge": "5.9.0", @@ -257,12 +257,12 @@ "dev": true }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.1602.14", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1602.14.tgz", - "integrity": "sha512-f+ZTCjOoA1SCQEaX3L/63ubqr/vlHkwDXAtKjBsQgyz6srnETcjy96Us5k/LoK7/hPc85zFneqLinfqOMVWHJQ==", + "version": "0.1602.15", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1602.15.tgz", + "integrity": "sha512-ms1+vCDdV0KX8BplJ7JoKH3wKjWHxxZTOX+mSPIjt4wS1uAk5DnezXHIjpBiJ3HY9XVHFI9C0HT4n7o6kFIOEQ==", "dev": true, "dependencies": { - "@angular-devkit/architect": "0.1602.14", + "@angular-devkit/architect": "0.1602.15", "rxjs": "7.8.1" }, "engines": { @@ -276,9 +276,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "16.2.14", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-16.2.14.tgz", - "integrity": "sha512-Ui14/d2+p7lnmXlK/AX2ieQEGInBV75lonNtPQgwrYgskF8ufCuN0DyVZQUy9fJDkC+xQxbJyYrby/BS0R0e7w==", + "version": "16.2.15", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-16.2.15.tgz", + "integrity": "sha512-68BgPWpcjNKz++uvLFG8IZaOH3ti2BWQVqaE3yTIYaMoNt0y0A0X2MUVd7EGbAGUk2JdloWJv5LTPVZMzCuK4w==", "dev": true, "dependencies": { "ajv": "8.12.0", @@ -303,12 +303,12 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "16.2.14", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-16.2.14.tgz", - "integrity": "sha512-B6LQKInCT8w5zx5Pbroext5eFFRTCJdTwHN8GhcVS8IeKCnkeqVTQLjB4lBUg7LEm8Y7UHXwzrVxmk+f+MBXhw==", + "version": "16.2.15", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-16.2.15.tgz", + "integrity": "sha512-C/j2EwapdBMf1HWDuH89bA9B2e511iEYImkyZ+vCSXRwGiWUaZCrhl18bvztpErTrdOLM3mCwNXWEAMXI4zUXA==", "dev": true, "dependencies": { - "@angular-devkit/core": "16.2.14", + "@angular-devkit/core": "16.2.15", "jsonc-parser": "3.2.0", "magic-string": "0.30.1", "ora": "5.4.1", @@ -465,15 +465,15 @@ } }, "node_modules/@angular/cli": { - "version": "16.2.14", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-16.2.14.tgz", - "integrity": "sha512-0y71jtitigVolm4Rim1b8xPQ+B22cGp4Spef2Wunpqj67UowN6tsZaVuWBEQh4u5xauX8LAHKqsvy37ZPWCc4A==", + "version": "16.2.15", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-16.2.15.tgz", + "integrity": "sha512-nNUmt0ZRj2xHH8tGXSJUiusP5rmakAz0f6cc6T4p03OyeShOKdvs9+/F4hzzsM79/ylZofBlFfwYVCBTbOtMqw==", "dev": true, "dependencies": { - "@angular-devkit/architect": "0.1602.14", - "@angular-devkit/core": "16.2.14", - "@angular-devkit/schematics": "16.2.14", - "@schematics/angular": "16.2.14", + "@angular-devkit/architect": "0.1602.15", + "@angular-devkit/core": "16.2.15", + "@angular-devkit/schematics": "16.2.15", + "@schematics/angular": "16.2.15", "@yarnpkg/lockfile": "1.1.0", "ansi-colors": "4.1.3", "ini": "4.1.1", @@ -4422,9 +4422,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "16.2.14", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-16.2.14.tgz", - "integrity": "sha512-3+zPP3Wir46qrZ3FEiTz5/emSoVHYUCH+WgBmJ57mZCx1qBOYh2VgllnPr/Yusl1sc/jUZjdwq/es/9ZNw+zDQ==", + "version": "16.2.15", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-16.2.15.tgz", + "integrity": "sha512-rD4IHt3nS6PdIKvmoqwIadMIGKsemBSz412kD8Deetl0TiCVhD/Tn1M00dxXzMSHSFCQcOKxdZAeD53yRwTOOA==", "dev": true, "engines": { "node": "^16.14.0 || >=18.10.0", @@ -4707,6 +4707,102 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/@nx/nx-darwin-arm64": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-16.5.1.tgz", + "integrity": "sha512-q98TFI4B/9N9PmKUr1jcbtD4yAFs1HfYd9jUXXTQOlfO9SbDjnrYJgZ4Fp9rMNfrBhgIQ4x1qx0AukZccKmH9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-darwin-x64": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-16.5.1.tgz", + "integrity": "sha512-j9HmL1l8k7EVJ3eOM5y8COF93gqrydpxCDoz23ZEtsY+JHY77VAiRQsmqBgEx9GGA2dXi9VEdS67B0+1vKariw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-freebsd-x64": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-16.5.1.tgz", + "integrity": "sha512-CXSPT01aVS869tvCCF2tZ7LnCa8l41wJ3mTVtWBkjmRde68E5Up093hklRMyXb3kfiDYlfIKWGwrV4r0eH6x1A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-linux-arm-gnueabihf": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-16.5.1.tgz", + "integrity": "sha512-BhrumqJSZCWFfLFUKl4CAUwR0Y0G2H5EfFVGKivVecEQbb+INAek1aa6c89evg2/OvetQYsJ+51QknskwqvLsA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-linux-arm64-gnu": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-16.5.1.tgz", + "integrity": "sha512-x7MsSG0W+X43WVv7JhiSq2eKvH2suNKdlUHEG09Yt0vm3z0bhtym1UCMUg3IUAK7jy9hhLeDaFVFkC6zo+H/XQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-linux-arm64-musl": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-16.5.1.tgz", + "integrity": "sha512-J+/v/mFjOm74I0PNtH5Ka+fDd+/dWbKhpcZ2R1/6b9agzZk+Ff/SrwJcSYFXXWKbPX+uQ4RcJoytT06Zs3s0ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nx/nx-linux-x64-gnu": { "version": "16.5.1", "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-16.5.1.tgz", @@ -4739,6 +4835,38 @@ "node": ">= 10" } }, + "node_modules/@nx/nx-win32-arm64-msvc": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-16.5.1.tgz", + "integrity": "sha512-qtqiLS9Y9TYyAbbpq58kRoOroko4ZXg5oWVqIWFHoxc5bGPweQSJCROEqd1AOl2ZDC6BxfuVHfhDDop1kK05WA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-win32-x64-msvc": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-16.5.1.tgz", + "integrity": "sha512-kUJBLakK7iyA9WfsGGQBVennA4jwf5XIgm0lu35oMOphtZIluvzItMt0EYBmylEROpmpEIhHq0P6J9FA+WH0Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@parcel/watcher": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.0.4.tgz", @@ -4815,13 +4943,13 @@ } }, "node_modules/@schematics/angular": { - "version": "16.2.14", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-16.2.14.tgz", - "integrity": "sha512-YqIv727l9Qze8/OL6H9mBHc2jVXzAGRNBYnxYWqWhLbfvuVbbldo6NNIIjgv6lrl2LJSdPAAMNOD5m/f6210ug==", + "version": "16.2.15", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-16.2.15.tgz", + "integrity": "sha512-T7wEGYxidpLAkis+hO5nsVfnWsy6sXf1T9GS8uztC8IYYsnqB9jTVfjVyfhASugZasdmx7+jWv3oCGy6Z5ZehA==", "dev": true, "dependencies": { - "@angular-devkit/core": "16.2.14", - "@angular-devkit/schematics": "16.2.14", + "@angular-devkit/core": "16.2.15", + "@angular-devkit/schematics": "16.2.15", "jsonc-parser": "3.2.0" }, "engines": { @@ -5101,21 +5229,13 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.5.tgz", "integrity": "sha512-u5/YPJHo1tvkSF2CE0USEkxon82Z5DBy2xR+qfyYNszpX9qcs4sT6uq2kBbj4BXY1+DBGDPnrhMZV3pKWGNukw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -5135,9 +5255,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "4.19.0", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz", - "integrity": "sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==", + "version": "4.19.5", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.5.tgz", + "integrity": "sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg==", "dev": true, "dependencies": { "@types/node": "*", @@ -5159,9 +5279,9 @@ "dev": true }, "node_modules/@types/http-proxy": { - "version": "1.17.14", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.14.tgz", - "integrity": "sha512-SSrD0c1OQzlFX7pGu1eXxSEjemej64aaNPRhhVYUGqXh0BtldAAx37MG8btcumvpgKyZp1F5Gn3JkktdxiFv6w==", + "version": "1.17.15", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.15.tgz", + "integrity": "sha512-25g5atgiVNTIv0LBDTg1H74Hvayx0ajtJPLLcYE3whFv75J0pWNtOBzaXJQgDTmrX1bx5U9YC2w/n65BN1HwRQ==", "dev": true, "dependencies": { "@types/node": "*" @@ -5198,12 +5318,12 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.0.0.tgz", - "integrity": "sha512-VT7KSYudcPOzP5Q0wfbowyNLaVR8QWUdw+088uFWwfvpY6uCWaXpqV6ieLAu9WBcnTa7H4Z5RLK8I5t2FuOcqw==", + "version": "22.5.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz", + "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", "dev": true, "dependencies": { - "undici-types": "~6.11.1" + "undici-types": "~6.19.2" } }, "node_modules/@types/node-forge": { @@ -5216,9 +5336,9 @@ } }, "node_modules/@types/qs": { - "version": "6.9.14", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.14.tgz", - "integrity": "sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==", + "version": "6.9.15", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", + "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==", "dev": true }, "node_modules/@types/range-parser": { @@ -5315,9 +5435,9 @@ } }, "node_modules/@types/ws": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", - "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", + "integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==", "dev": true, "dependencies": { "@types/node": "*" @@ -6160,10 +6280,10 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "dev": true, "peerDependencies": { "acorn": "^8" @@ -6646,9 +6766,9 @@ "dev": true }, "node_modules/axios": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", - "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", + "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", "dev": true, "dependencies": { "follow-redirects": "^1.15.6", @@ -8130,13 +8250,13 @@ "dev": true }, "node_modules/cypress": { - "version": "13.13.1", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.13.1.tgz", - "integrity": "sha512-8F9UjL5MDUdgC/S5hr8CGLHbS5gGht5UOV184qc2pFny43fnkoaKxlzH/U6//zmGu/xRTaKimNfjknLT8+UDFg==", + "version": "13.14.2", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.14.2.tgz", + "integrity": "sha512-lsiQrN17vHMB2fnvxIrKLAjOr9bPwsNbPZNrWf99s4u+DVmCY6U+w7O3GGG9FvP4EUVYaDu+guWeNLiUzBrqvA==", "dev": true, "hasInstallScript": true, "dependencies": { - "@cypress/request": "^3.0.0", + "@cypress/request": "^3.0.1", "@cypress/xvfb": "^1.2.4", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", @@ -8945,9 +9065,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.16.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.16.0.tgz", - "integrity": "sha512-O+QWCviPNSSLAD9Ucn8Awv+poAkqn3T1XY5/N7kR7rQO9yfSGWkYZDwpJ+iKF7B8rxaQKWngSqACpgzeapSyoA==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", "dev": true, "dependencies": { "graceful-fs": "^4.2.4", @@ -10088,9 +10208,9 @@ } }, "node_modules/filesize": { - "version": "10.1.4", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.4.tgz", - "integrity": "sha512-ryBwPIIeErmxgPnm6cbESAzXjuEFubs+yKYLBZvg3CaiNcmkJChoOGcBSrZ6IwkMwPABwPpVXE6IlNdGJJrvEg==", + "version": "10.1.6", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz", + "integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==", "engines": { "node": ">= 10.4.0" } @@ -11385,9 +11505,9 @@ "dev": true }, "node_modules/ipaddr.js": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", - "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", "dev": true, "engines": { "node": ">= 10" @@ -12079,22 +12199,22 @@ } }, "node_modules/jasmine": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-5.2.0.tgz", - "integrity": "sha512-il+noV96N1BGU9/FMmc8QtAMxC8lPnXUiAvgb0o9MDZATRdxglTQe9wo6UdL049ropQL6MopDYwDlludKR6wJQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-5.3.0.tgz", + "integrity": "sha512-Vrv5VWTXVZ/5xcNawlYCmE24pOaZu3KduLr9iAaENoMJ8W8Ryvhfpw2cf3rI4Unc2ajvu2t4tCKjS72TnraBGQ==", "dev": true, "dependencies": { "glob": "^10.2.2", - "jasmine-core": "~5.2.0" + "jasmine-core": "~5.3.0" }, "bin": { "jasmine": "bin/jasmine.js" } }, "node_modules/jasmine-core": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.2.0.tgz", - "integrity": "sha512-tSAtdrvWybZkQmmaIoDgnvHG8ORUNw5kEVlO5CvrXj02Jjr9TZrmjFq7FUiOUzJiOP2wLGYT6PgrQgQF4R1xiw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.3.0.tgz", + "integrity": "sha512-zsOmeBKESky4toybvWEikRiZ0jHoBEu79wNArLfMdSnlLMZx3Xcp6CSm2sUcYyoJC+Uyj8LBJap/MUbVSfJ27g==", "dev": true }, "node_modules/jasmine-marbles": { @@ -12790,9 +12910,9 @@ } }, "node_modules/launch-editor": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.6.1.tgz", - "integrity": "sha512-eB/uXmFVpY4zezmGp5XtU21kwo7GBbKB+EQ+UZeWtGb9yAM5xt/Evk+lYH3eRNAtId+ej4u7TYPFZ07w4s7rRw==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.9.1.tgz", + "integrity": "sha512-Gcnl4Bd+hRO9P9icCP/RVVT2o8SFlPXofuCxvA2SaZuH45whSvf5p8x5oih5ftLiVhEI4sp5xDY+R+b3zJBh5w==", "dev": true, "dependencies": { "picocolors": "^1.0.0", @@ -13372,9 +13492,9 @@ } }, "node_modules/luxon": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", - "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", "engines": { "node": ">=12" } @@ -13611,11 +13731,11 @@ "dev": true }, "node_modules/mathjs": { - "version": "13.0.3", - "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-13.0.3.tgz", - "integrity": "sha512-GpP9OW6swA5POZXvgpc/1FYkAr8lKgV04QHS1tIU60klFfplVCYaNzn6qy0vSp0hAQQN7shcx9CeB507dlLujA==", + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-13.1.1.tgz", + "integrity": "sha512-duaSAy7m4F+QtP1Dyv8MX2XuxcqpNDDlGly0SdVTCqpAmwdOFWilDdQKbLdo9RfD6IDNMOdo9tIsEaTXkconlQ==", "dependencies": { - "@babel/runtime": "^7.24.8", + "@babel/runtime": "^7.25.4", "complex.js": "^2.1.1", "decimal.js": "^10.4.3", "escape-latex": "^1.2.0", @@ -13633,9 +13753,9 @@ } }, "node_modules/mathjs/node_modules/@babel/runtime": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.8.tgz", - "integrity": "sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA==", + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.4.tgz", + "integrity": "sha512-DSgLeL/FNcpXuzav5wfYvHCGvynXkJbn3Zvc3823AEe9nPwW9IK4UoCSS5yGymmQzN0pCPvivtgS6/8U2kkm1w==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -18036,9 +18156,9 @@ } }, "node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -18283,9 +18403,9 @@ } }, "node_modules/undici-types": { - "version": "6.11.1", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.11.1.tgz", - "integrity": "sha512-mIDEX2ek50x0OlRgxryxsenE5XaQD4on5U2inY7RApK3SOJpofyw7uW2AyfMKkhAxXIceo2DeWGVGwyvng1GNQ==", + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "dev": true }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -18688,34 +18808,33 @@ } }, "node_modules/webpack": { - "version": "5.88.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.88.2.tgz", - "integrity": "sha512-JmcgNZ1iKj+aiR0OvTYtWQqJwq37Pf683dY9bVORwVbUrDhLhdn/PlO2sHsFHPkj7sHNQF3JwaAkp49V+Sq1tQ==", + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, "dependencies": { - "@types/eslint-scope": "^3.7.3", - "@types/estree": "^1.0.0", - "@webassemblyjs/ast": "^1.11.5", - "@webassemblyjs/wasm-edit": "^1.11.5", - "@webassemblyjs/wasm-parser": "^1.11.5", + "@types/estree": "^1.0.5", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", "acorn": "^8.7.1", - "acorn-import-assertions": "^1.9.0", - "browserslist": "^4.14.5", + "acorn-import-attributes": "^1.9.5", + "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.15.0", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", + "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^3.2.0", "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.7", - "watchpack": "^2.4.0", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", "webpack-sources": "^3.2.3" }, "bin": { @@ -18845,9 +18964,9 @@ } }, "node_modules/webpack-dev-server/node_modules/ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "dev": true, "engines": { "node": ">=10.0.0" diff --git a/src/app/datasets/dashboard/full-text-search/full-text-search-bar.component.html b/src/app/datasets/dashboard/full-text-search/full-text-search-bar.component.html index 71be7f287..f9895119c 100644 --- a/src/app/datasets/dashboard/full-text-search/full-text-search-bar.component.html +++ b/src/app/datasets/dashboard/full-text-search/full-text-search-bar.component.html @@ -21,15 +21,15 @@ search Search - diff --git a/src/app/datasets/dashboard/full-text-search/full-text-search-bar.component.ts b/src/app/datasets/dashboard/full-text-search/full-text-search-bar.component.ts index 4fe17d6d8..f30011865 100644 --- a/src/app/datasets/dashboard/full-text-search/full-text-search-bar.component.ts +++ b/src/app/datasets/dashboard/full-text-search/full-text-search-bar.component.ts @@ -87,7 +87,7 @@ export class FullTextSearchBarComponent implements OnInit, OnDestroy { onClear(): void { this.searchTerm = ""; this.searchTermSubject.next(undefined); - //this.searchClickSubject.next(); + this.searchClickSubject.next(); } ngOnDestroy(): void { diff --git a/src/app/datasets/datafiles-actions/datafiles-action.component.spec.ts b/src/app/datasets/datafiles-actions/datafiles-action.component.spec.ts index 90a0aa67b..bfb2e2ce0 100644 --- a/src/app/datasets/datafiles-actions/datafiles-action.component.spec.ts +++ b/src/app/datasets/datafiles-actions/datafiles-action.component.spec.ts @@ -21,6 +21,8 @@ import { ActionDataset } from "./datafiles-action.interfaces"; describe("1000: DatafilesActionComponent", () => { let component: DatafilesActionComponent; let fixture: ComponentFixture; + let htmlForm: HTMLFormElement; + let htmlInput: HTMLInputElement; const actionsConfig = [ { @@ -129,14 +131,20 @@ describe("1000: DatafilesActionComponent", () => { id: "4ac45f3e-4d79-11ef-856c-6339dab93bee", }); - const browserWindowMock = { - document: { - write() {}, - body: { - setAttribute() {}, - }, - }, - } as unknown as Window; + // const browserWindowMock = { + // document: { + // write() {}, + // body: { + // setAttribute() {}, + // }, + // }, + // } as unknown as Window; + + beforeAll(() => { + htmlForm = document.createElement("form"); + (htmlForm as HTMLFormElement).submit = () => {}; + htmlInput = document.createElement("input"); + }); beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -494,9 +502,22 @@ describe("1000: DatafilesActionComponent", () => { }); }); - function getFakeElement(elementType: string): HTMLElement { - const element = new MockHtmlElement(elementType); - return element as unknown as HTMLElement; + function createFakeElement(elementType: string): HTMLElement { + //const element = new MockHtmlElement(elementType); + //return element as unknown as HTMLElement; + let element: HTMLElement = null; + + switch (elementType) { + case "form": + element = htmlForm.cloneNode(true) as HTMLElement; + break; + case "input": + element = htmlInput.cloneNode(true) as HTMLElement; + break; + default: + element = null; + } + return element; } it("0400: Form submission should have all files when Download All is clicked", async () => { @@ -505,8 +526,9 @@ describe("1000: DatafilesActionComponent", () => { maxSizeType.higher, selectedFilesType.none, ); - spyOn(document, "createElement").and.callFake(getFakeElement); - spyOn(window, "open").and.returnValue(browserWindowMock); + + spyOn(document, "createElement").and.callFake(createFakeElement); + //spyOn(window, "open").and.returnValue(browserWindowMock); component.perform_action(); @@ -526,13 +548,13 @@ describe("1000: DatafilesActionComponent", () => { maxSizeType.higher, selectedFilesType.none, ); - spyOn(document, "createElement").and.callFake(getFakeElement); - spyOn(window, "open").and.returnValue(browserWindowMock); + spyOn(document, "createElement").and.callFake(createFakeElement); + //spyOn(window, "open").and.returnValue(browserWindowMock); component.perform_action(); - expect(component.form.action).toEqual( - actionsConfig[actionSelectorType.download_all].url, + expect(component.form.action.replace(/\/$/, "")).toEqual( + actionsConfig[actionSelectorType.download_all].url.replace(/\/$/, ""), ); }); @@ -542,8 +564,8 @@ describe("1000: DatafilesActionComponent", () => { maxSizeType.higher, selectedFilesType.none, ); - spyOn(document, "createElement").and.callFake(getFakeElement); - spyOn(window, "open").and.returnValue(browserWindowMock); + spyOn(document, "createElement").and.callFake(createFakeElement); + //spyOn(window, "open").and.returnValue(browserWindowMock); component.perform_action(); @@ -566,8 +588,8 @@ describe("1000: DatafilesActionComponent", () => { maxSizeType.higher, selectedFile, ); - spyOn(document, "createElement").and.callFake(getFakeElement); - spyOn(window, "open").and.returnValue(browserWindowMock); + spyOn(document, "createElement").and.callFake(createFakeElement); + //spyOn(window, "open").and.returnValue(browserWindowMock); component.perform_action(); @@ -592,8 +614,8 @@ describe("1000: DatafilesActionComponent", () => { maxSizeType.higher, selectedFilesType.none, ); - spyOn(document, "createElement").and.callFake(getFakeElement); - spyOn(window, "open").and.returnValue(browserWindowMock); + spyOn(document, "createElement").and.callFake(createFakeElement); + //spyOn(window, "open").and.returnValue(browserWindowMock); component.perform_action(); @@ -613,13 +635,13 @@ describe("1000: DatafilesActionComponent", () => { maxSizeType.higher, selectedFilesType.none, ); - spyOn(document, "createElement").and.callFake(getFakeElement); - spyOn(window, "open").and.returnValue(browserWindowMock); + spyOn(document, "createElement").and.callFake(createFakeElement); + //spyOn(window, "open").and.returnValue(browserWindowMock); component.perform_action(); - expect(component.form.action).toEqual( - actionsConfig[actionSelectorType.notebook_all].url, + expect(component.form.action.replace(/\/$/, "")).toEqual( + actionsConfig[actionSelectorType.notebook_all].url.replace(/\/$/, ""), ); }); @@ -630,8 +652,8 @@ describe("1000: DatafilesActionComponent", () => { maxSizeType.higher, selectedFile, ); - spyOn(document, "createElement").and.callFake(getFakeElement); - spyOn(window, "open").and.returnValue(browserWindowMock); + spyOn(document, "createElement").and.callFake(createFakeElement); + //spyOn(window, "open").and.returnValue(browserWindowMock); component.perform_action(); diff --git a/src/app/datasets/datafiles-actions/datafiles-action.component.ts b/src/app/datasets/datafiles-actions/datafiles-action.component.ts index ff0675f58..9ede93d88 100644 --- a/src/app/datasets/datafiles-actions/datafiles-action.component.ts +++ b/src/app/datasets/datafiles-actions/datafiles-action.component.ts @@ -27,12 +27,11 @@ export class DatafilesActionComponent implements OnInit, OnChanges { visible = true; use_mat_icon = false; use_icon = false; - //disabled = false; disabled_condition = "false"; selectedTotalFileSize = 0; numberOfFileSelected = 0; - form: HTMLFormElement; + form: HTMLFormElement = null; constructor(private userApi: UserApi) { this.userApi.jwt().subscribe((jwt) => { @@ -92,10 +91,6 @@ export class DatafilesActionComponent implements OnInit, OnChanges { ).length; } - // compute_disabled() { - // this.disabled = eval(this.disabled_condition); - // } - get disabled() { this.update_status(); this.prepare_disabled_condition(); @@ -111,10 +106,24 @@ export class DatafilesActionComponent implements OnInit, OnChanges { } perform_action() { + const action_type = this.actionConfig.type || "form"; + switch (action_type) { + case "form": + default: + return this.type_form(); + } + } + + type_form() { + if (this.form !== null) { + document.body.removeChild(this.form); + } + this.form = document.createElement("form"); - this.form.target = this.actionConfig.target; - this.form.method = this.actionConfig.method; + this.form.target = this.actionConfig.target || "_self"; + this.form.method = this.actionConfig.method || "POST"; this.form.action = this.actionConfig.url; + this.form.style.display = "none"; this.form.appendChild( this.add_input("auth_token", this.userApi.getCurrentToken().id), @@ -128,7 +137,8 @@ export class DatafilesActionComponent implements OnInit, OnChanges { this.add_input("directory", this.actionDataset.sourceFolder), ); - for (const [index, item] of this.files.entries()) { + let index = 0; + for (const item of this.files) { if ( this.actionConfig.files === "all" || (this.actionConfig.files === "selected" && item.selected) @@ -136,11 +146,31 @@ export class DatafilesActionComponent implements OnInit, OnChanges { this.form.appendChild( this.add_input("files[" + index + "]", item.path), ); + index = index + 1; } } - //document.body.appendChild(form); + document.body.appendChild(this.form); this.form.submit(); - window.open("", "view"); + + return true; + } + + /* + * future development + * + type_fetch() { + const data = new URLSearchParams(); + for (const pair of new FormData(formElement)) { + data.append(pair[0], pair[1]); + } + + fetch(url, { + method: 'post', + body: data, + }) + .then(…); + } } + */ } diff --git a/src/app/datasets/datafiles-actions/datafiles-action.interfaces.ts b/src/app/datasets/datafiles-actions/datafiles-action.interfaces.ts index 14410d950..f2f02fcbf 100644 --- a/src/app/datasets/datafiles-actions/datafiles-action.interfaces.ts +++ b/src/app/datasets/datafiles-actions/datafiles-action.interfaces.ts @@ -5,6 +5,7 @@ export interface ActionConfig { files: string; mat_icon?: string; icon?: string; + type?: string; url: string; target: string; authorization: string[]; diff --git a/src/app/datasets/datafiles-actions/datafiles-actions.component.ts b/src/app/datasets/datafiles-actions/datafiles-actions.component.ts index f449b4c35..32d8eb1a2 100644 --- a/src/app/datasets/datafiles-actions/datafiles-actions.component.ts +++ b/src/app/datasets/datafiles-actions/datafiles-actions.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from "@angular/core"; +import { Component, Input } from "@angular/core"; import { ActionConfig, ActionDataset } from "./datafiles-action.interfaces"; import { DataFiles_File } from "datasets/datafiles/datafiles.interfaces"; import { AppConfigService } from "app-config.service"; diff --git a/src/app/datasets/datasets-filter/datasets-filter.component.html b/src/app/datasets/datasets-filter/datasets-filter.component.html index 119eb9814..350228347 100644 --- a/src/app/datasets/datasets-filter/datasets-filter.component.html +++ b/src/app/datasets/datasets-filter/datasets-filter.component.html @@ -1,200 +1,35 @@ - Filter by... + Filters and Conditions - - + + + + + + + - - Location - - {{ location || "No Location" }} - cancel - - - - - - - {{ getFacetId(fc, "No Location") }} | - {{ getFacetCount(fc) }} - - - - - - Group - - {{ group }}cancel - - - - - - - {{ getFacetId(fc, "No Group") }} | - {{ getFacetCount(fc) }} - - - - - - Type - - {{ type }}cancel - - - - - - - {{ getFacetId(fc, "No Type") }} | - {{ getFacetCount(fc) }} - - - - - - Keywords - - {{ keyword }}cancel - - - - - - {{ getFacetId(fc, "No Keywords") }} - : {{ getFacetCount(fc) }} - - - - - - Start Date - End Date - - - - - - -
- - - - - - {{ condition.lhs }} - - -  =  - - -  =  - - -  <  - - -  >  - - - {{ - condition.relation === "EQUAL_TO_STRING" - ? '"' + condition.rhs + '"' - : condition.rhs - }} - {{ condition.unit | prettyUnit }} - - cancel - - + + +
+ +
+ +
diff --git a/src/app/datasets/datasets-filter/datasets-filter.component.scss b/src/app/datasets/datasets-filter/datasets-filter.component.scss index d3c8fb819..92dbd784e 100644 --- a/src/app/datasets/datasets-filter/datasets-filter.component.scss +++ b/src/app/datasets/datasets-filter/datasets-filter.component.scss @@ -5,6 +5,15 @@ mat-card { width: 100%; } + .filter-container { + display: flex; + align-items: center; + + :first-child { + flex-grow: 1; + } + } + .section-container { font-size: 1.25rem; font-weight: 425; @@ -20,6 +29,18 @@ mat-card { margin-left: auto; } + .full-width-button { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 0; + + mat-icon { + margin-right: 8px; + } + } + .scientific-chips { ::ng-deep .mat-mdc-chip-list-wrapper { margin: 0; diff --git a/src/app/datasets/datasets-filter/datasets-filter.component.spec.ts b/src/app/datasets/datasets-filter/datasets-filter.component.spec.ts index 5c4d78247..cfc4f6053 100644 --- a/src/app/datasets/datasets-filter/datasets-filter.component.spec.ts +++ b/src/app/datasets/datasets-filter/datasets-filter.component.spec.ts @@ -11,30 +11,13 @@ import { MockStore } from "shared/MockStubs"; import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; -import { FacetCount } from "state-management/state/datasets.store"; import { - setSearchTermsAction, - addLocationFilterAction, - removeLocationFilterAction, - addGroupFilterAction, - removeGroupFilterAction, - addKeywordFilterAction, - removeKeywordFilterAction, - addTypeFilterAction, - removeTypeFilterAction, clearFacetsAction, - removeScientificConditionAction, - setDateRangeFilterAction, - addScientificConditionAction, - setPidTermsAction, + fetchDatasetsAction, + fetchFacetCountsAction, } from "state-management/actions/datasets.actions"; import { of } from "rxjs"; -import { - selectColumnAction, - deselectColumnAction, - deselectAllCustomColumnsAction, -} from "state-management/actions/user.actions"; -import { ScientificCondition } from "state-management/models"; +import { deselectAllCustomColumnsAction } from "state-management/actions/user.actions"; import { SharedScicatFrontendModule } from "shared/shared.module"; import { MatAutocompleteModule } from "@angular/material/autocomplete"; import { MatDialogModule, MatDialog } from "@angular/material/dialog"; @@ -43,25 +26,51 @@ import { MatInputModule } from "@angular/material/input"; import { MatSelectModule } from "@angular/material/select"; import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; import { AsyncPipe } from "@angular/common"; -import { DateTime } from "luxon"; -import { - MatDatepickerInputEvent, - MatDatepickerModule, -} from "@angular/material/datepicker"; +import { MatDatepickerModule } from "@angular/material/datepicker"; import { MatChipsModule } from "@angular/material/chips"; import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; import { MatCardModule } from "@angular/material/card"; import { MatButtonModule } from "@angular/material/button"; import { MatIconModule } from "@angular/material/icon"; import { AppConfigService } from "app-config.service"; +import { DatasetsFilterSettingsComponent } from "./settings/datasets-filter-settings.component"; +import { LocationFilterComponent } from "../../shared/modules/filters/location-filter.component"; +import { PidFilterComponent } from "../../shared/modules/filters/pid-filter.component"; +import { PidFilterContainsComponent } from "../../shared/modules/filters/pid-filter-contains.component"; +import { PidFilterStartsWithComponent } from "../../shared/modules/filters/pid-filter-startsWith.component"; +import { GroupFilterComponent } from "../../shared/modules/filters/group-filter.component"; +import { TypeFilterComponent } from "../../shared/modules/filters/type-filter.component"; +import { KeywordFilterComponent } from "../../shared/modules/filters/keyword-filter.component"; +import { DateRangeFilterComponent } from "../../shared/modules/filters/date-range-filter.component"; +import { TextFilterComponent } from "../../shared/modules/filters/text-filter.component"; +import { FilterConfig } from "../../shared/modules/filters/filters.module"; +import { selectFilters } from "../../state-management/selectors/user.selectors"; + +const filterConfigs: FilterConfig[] = [ + { type: "LocationFilterComponent", visible: true }, + { type: "PidFilterComponent", visible: true }, + { type: "PidFilterContainsComponent", visible: false }, + { type: "PidFilterStartsWithComponent", visible: false }, + { type: "GroupFilterComponent", visible: true }, + { type: "TypeFilterComponent", visible: true }, + { type: "KeywordFilterComponent", visible: true }, + { type: "DateRangeFilterComponent", visible: true }, + { type: "TextFilterComponent", visible: true }, +]; + +export class MockStoreWithFilters extends MockStore { + public select(selector: any) { + if (selector === selectFilters) { + return of(filterConfigs); + } + return of(null); + } +} export class MockMatDialog { open() { return { - afterClosed: () => - of({ - data: { lhs: "", rhs: "", relation: "EQUAL_TO_STRING", unit: "" }, - }), + afterClosed: () => of(filterConfigs), }; } } @@ -74,7 +83,7 @@ describe("DatasetsFilterComponent", () => { let component: DatasetsFilterComponent; let fixture: ComponentFixture; - let store: MockStore; + let store: MockStoreWithFilters; let dispatchSpy; beforeEach(waitForAsync(() => { @@ -100,7 +109,10 @@ describe("DatasetsFilterComponent", () => { StoreModule.forRoot({}), ], declarations: [DatasetsFilterComponent, SearchParametersDialogComponent], - providers: [AsyncPipe], + providers: [ + AsyncPipe, + { provide: Store, useClass: MockStoreWithFilters }, + ], }); TestBed.overrideComponent(DatasetsFilterComponent, { set: { @@ -124,7 +136,7 @@ describe("DatasetsFilterComponent", () => { fixture.detectChanges(); }); - beforeEach(inject([Store], (mockStore: MockStore) => { + beforeEach(inject([Store], (mockStore: MockStoreWithFilters) => { store = mockStore; })); @@ -163,418 +175,50 @@ describe("DatasetsFilterComponent", () => { it("should contain a clear all button", () => { const compiled = fixture.debugElement.nativeElement; const btn = compiled.querySelector(".datasets-filters-clear-all-button"); - expect(btn.textContent).toContain("Clear All Filters"); + expect(btn.textContent).toContain("undo Reset"); }); it("should contain a search button", () => { const compiled = fixture.debugElement.nativeElement; const btn = compiled.querySelector(".datasets-filters-search-button"); - expect(btn.textContent).toContain("Search"); - }); - - describe("#getFacetId()", () => { - it("should return the FacetCount id if present", () => { - const facetCount: FacetCount = { - _id: "test1", - count: 0, - }; - const fallback = "test2"; - - const id = component.getFacetId(facetCount, fallback); - - expect(id).toEqual("test1"); - }); - - it("should return the FacetCount id if present", () => { - const facetCount: FacetCount = { - count: 0, - }; - const fallback = "test"; - - const id = component.getFacetId(facetCount, fallback); - - expect(id).toEqual(fallback); - }); - }); - - describe("#getFacetCount()", () => { - it("should return the FacetCount", () => { - const facetCount: FacetCount = { - count: 0, - }; - - const count = component.getFacetCount(facetCount); - - expect(count).toEqual(facetCount.count); - }); - }); - - describe("#textSearchChanged()", () => { - it("should dispatch a SetSearchTermsAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const terms = "test"; - component.textSearchChanged(terms); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith(setSearchTermsAction({ terms })); - }); - }); - - describe("#onLocationInput()", () => { - it("should call next on locationInput$", () => { - const nextSpy = spyOn(component.locationInput$, "next"); - - const event = { - target: { - value: "location", - }, - }; - - component.onLocationInput(event); - - expect(nextSpy).toHaveBeenCalledOnceWith(event.target.value); - }); - }); - - describe("#onGroupInput()", () => { - it("should call next on groupInput$", () => { - const nextSpy = spyOn(component.groupInput$, "next"); - - const event = { - target: { - value: "group", - }, - }; - - component.onGroupInput(event); - - expect(nextSpy).toHaveBeenCalledOnceWith(event.target.value); - }); - }); - - describe("#onKeywordInput()", () => { - it("should call next on keywordsInput$", () => { - const nextSpy = spyOn(component.keywordsInput$, "next"); - - const event = { - target: { - value: "keyword", - }, - }; - - component.onKeywordInput(event); - - expect(nextSpy).toHaveBeenCalledOnceWith(event.target.value); - }); - }); - - describe("#onTypeInput()", () => { - it("should call next on typeInput$", () => { - const nextSpy = spyOn(component.typeInput$, "next"); - - const event = { - target: { - value: "type", - }, - }; - - component.onTypeInput(event); - - expect(nextSpy).toHaveBeenCalledOnceWith(event.target.value); - }); - }); - - describe("#locationSelected()", () => { - it("should dispatch an AddLocationFilterAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const location = "test"; - component.locationSelected(location); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith( - addLocationFilterAction({ location }), - ); - }); + expect(btn.textContent).toContain("search Apply"); }); - describe("#locationRemoved()", () => { - it("should dispatch a RemoveLocationFilterAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const location = "test"; - component.locationRemoved(location); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith( - removeLocationFilterAction({ location }), - ); - }); - }); - - describe("#groupSelected()", () => { - it("should dispatch an AddGroupFilterAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const group = "test"; - component.groupSelected(group); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith(addGroupFilterAction({ group })); - }); - }); - - describe("#groupRemoved()", () => { - it("should dispatch a RemoveGroupFilterAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const group = "test"; - component.groupRemoved(group); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith( - removeGroupFilterAction({ group }), - ); - }); - }); - - describe("#keywordSelected()", () => { - it("should dispatch an AddKeywordFilterAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const keyword = "test"; - component.keywordSelected(keyword); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith( - addKeywordFilterAction({ keyword }), - ); - }); - }); - - describe("#keywordRemoved()", () => { - it("should dispatch a RemoveKeywordFilterAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const keyword = "test"; - component.keywordRemoved(keyword); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith( - removeKeywordFilterAction({ keyword }), - ); - }); - }); - - describe("#typeSelected()", () => { - it("should dispatch an AddTypeFilterAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const datasetType = "string"; - component.typeSelected(datasetType); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith( - addTypeFilterAction({ datasetType }), - ); - }); - }); - - describe("#typeRemoved()", () => { - it("should dispatch a RemoveTypeFilterAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const datasetType = "string"; - component.typeRemoved(datasetType); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith( - removeTypeFilterAction({ datasetType }), - ); - }); - }); - - describe("#dateChanged()", () => { - it("should dispatch setDateRangeFilterAction with empty string values if event.value is null", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const event = { - targetElement: { - getAttribute: (name: string) => "begin", - }, - value: null, - } as MatDatepickerInputEvent; - - component.dateChanged(event); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith( - setDateRangeFilterAction({ begin: "", end: "" }), - ); - }); - - it("should set dateRange.begin if event has value and event.targetElement name is begin", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const beginDate = DateTime.fromJSDate(new Date("2021-01-01")); - const event = { - targetElement: { - getAttribute: (name: string) => "begin", - }, - value: beginDate, - } as MatDatepickerInputEvent; - - component.dateChanged(event); - - const expected = beginDate.toUTC().toISO(); - expect(component.dateRange.begin).toEqual(expected); - expect(dispatchSpy).not.toHaveBeenCalled(); - }); - - it("should set dateRange.end if event has value and event.targetElement name is end", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const endDate = DateTime.fromJSDate(new Date("2021-07-08")); - const event = { - targetElement: { - getAttribute: (name: string) => "end", - }, - value: endDate, - } as MatDatepickerInputEvent; - - component.dateChanged(event); - - const expected = endDate.toUTC().plus({ days: 1 }).toISO(); - expect(component.dateRange.end).toEqual(expected); - expect(dispatchSpy).not.toHaveBeenCalled(); - }); - - it("should dispatch a setDateRangeFilterAction if dateRange.begin and dateRange.end have values", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const beginDate = DateTime.fromJSDate(new Date("2021-01-01")); - const endDate = DateTime.fromJSDate(new Date("2021-07-08")); - component.dateRange.begin = beginDate.toUTC().toISO(); - const event = { - targetElement: { - getAttribute: (name: string) => "end", - }, - value: endDate, - } as MatDatepickerInputEvent; - - component.dateChanged(event); - - const expected = { - begin: beginDate.toUTC().toISO(), - end: endDate.toUTC().plus({ days: 1 }).toISO(), - }; - expect(dispatchSpy).toHaveBeenCalledOnceWith( - setDateRangeFilterAction(expected), - ); - }); - }); - - describe("#clearFacets()", () => { + describe("#reset()", () => { it("should dispatch a ClearFacetsAction and a deselectAllCustomColumnsAction", () => { dispatchSpy = spyOn(store, "dispatch"); - component.clearFacets(); + component.reset(); - expect(dispatchSpy).toHaveBeenCalledTimes(3); + expect(dispatchSpy).toHaveBeenCalledTimes(4); expect(dispatchSpy).toHaveBeenCalledWith(clearFacetsAction()); - expect(dispatchSpy).toHaveBeenCalledWith(setPidTermsAction({ pid: "" })); expect(dispatchSpy).toHaveBeenCalledWith( deselectAllCustomColumnsAction(), ); + expect(dispatchSpy).toHaveBeenCalledWith(fetchDatasetsAction()); + expect(dispatchSpy).toHaveBeenCalledWith(fetchFacetCountsAction()); }); }); - describe("#showAddConditionDialog()", () => { - it("should open SearchParametersDialog, dispatch addScientificConditionAction and selectColumnAction if dialog returns data", () => { + describe("#showDatasetsFilterSettingsDialog()", () => { + it("should open DatasetsFilterSettingsComponent", () => { spyOn(component.dialog, "open").and.callThrough(); dispatchSpy = spyOn(store, "dispatch"); - component.metadataKeys$ = of(["test", "keys"]); - component.showAddConditionDialog(); + // component.metadataKeys$ = of(["test", "keys"]); + component.showDatasetsFilterSettingsDialog(); expect(component.dialog.open).toHaveBeenCalledTimes(1); expect(component.dialog.open).toHaveBeenCalledWith( - SearchParametersDialogComponent, + DatasetsFilterSettingsComponent, { + width: "60%", data: { - parameterKeys: component["asyncPipe"].transform( - component.metadataKeys$, - ), + filterConfigs: filterConfigs, + conditionConfigs: null, }, }, ); - expect(dispatchSpy).toHaveBeenCalledTimes(2); - expect(dispatchSpy).toHaveBeenCalledWith( - addScientificConditionAction({ - condition: { - lhs: "", - rhs: "", - relation: "EQUAL_TO_STRING", - unit: "", - }, - }), - ); - expect(dispatchSpy).toHaveBeenCalledWith( - selectColumnAction({ name: "", columnType: "custom" }), - ); - }); - }); - - describe("#removeCondition()", () => { - it("should dispatch a removeScientificConditionAction and a deselectColumnAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const condition: ScientificCondition = { - lhs: "test", - relation: "EQUAL_TO_NUMERIC", - rhs: 5, - unit: "s", - }; - const index = 0; - component.removeCondition(condition, index); - - expect(dispatchSpy).toHaveBeenCalledTimes(2); - expect(dispatchSpy).toHaveBeenCalledWith( - removeScientificConditionAction({ index }), - ); - expect(dispatchSpy).toHaveBeenCalledWith( - deselectColumnAction({ name: condition.lhs, columnType: "custom" }), - ); - }); - }); - - describe("#pidSearchChanged()", () => { - it("should dispatch a SetSearchTermsAction", () => { - dispatchSpy = spyOn(store, "dispatch"); - - const pid = "1"; - component.pidSearchChanged(pid); - - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(dispatchSpy).toHaveBeenCalledWith(setPidTermsAction({ pid })); - }); - }); - - describe("#buildPidTermsCondition()", () => { - const tests = [ - ["", "", ""], - ["1", "startsWith", { $regex: "^1" }], - ["1", "contains", { $regex: "1" }], - ["1", "equals", "1"], - ["1", "", "1"], - ]; - tests.forEach((t, i) => { - it(`should return buildPidTermsCondition ${i}`, () => { - component.appConfig.pidSearchMethod = t[1] as string; - const condition = component["buildPidTermsCondition"](t[0] as string); - expect(condition).toEqual(t[2]); - }); }); }); }); diff --git a/src/app/datasets/datasets-filter/datasets-filter.component.ts b/src/app/datasets/datasets-filter/datasets-filter.component.ts index 3dcb54baa..4d1f8aee7 100644 --- a/src/app/datasets/datasets-filter/datasets-filter.component.ts +++ b/src/app/datasets/datasets-filter/datasets-filter.component.ts @@ -1,358 +1,137 @@ -import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ChangeDetectorRef, Component, OnDestroy } from "@angular/core"; import { MatDialog } from "@angular/material/dialog"; import { Store } from "@ngrx/store"; -import { - debounceTime, - distinctUntilChanged, - skipWhile, - map, -} from "rxjs/operators"; -import { FacetCount } from "state-management/state/datasets.store"; import { - selectCreationTimeFilter, - selectGroupFacetCounts, - selectGroupFilter, selectHasAppliedFilters, - selectKeywordFacetCounts, - selectKeywordsFilter, - selectLocationFacetCounts, - selectLocationFilter, selectScientificConditions, - selectSearchTerms, - selectTypeFacetCounts, - selectTypeFilter, - selectKeywordsTerms, - selectMetadataKeys, - selectPidTerms, } from "state-management/selectors/datasets.selectors"; import { - setTextFilterAction, - addKeywordFilterAction, - setSearchTermsAction, - addLocationFilterAction, - removeLocationFilterAction, - addGroupFilterAction, - removeGroupFilterAction, - removeKeywordFilterAction, - addTypeFilterAction, - removeTypeFilterAction, - setDateRangeFilterAction, clearFacetsAction, - addScientificConditionAction, - removeScientificConditionAction, - setPidTermsAction, - setPidTermsFilterAction, fetchDatasetsAction, fetchFacetCountsAction, } from "state-management/actions/datasets.actions"; -import { combineLatest, BehaviorSubject, Observable, Subscription } from "rxjs"; +import { Subscription } from "rxjs"; import { - selectColumnAction, - deselectColumnAction, deselectAllCustomColumnsAction, + updateConditionsConfigs, + updateFilterConfigs, } from "state-management/actions/user.actions"; -import { ScientificCondition } from "state-management/models"; -import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; -import { AsyncPipe } from "@angular/common"; -import { MatDatepickerInputEvent } from "@angular/material/datepicker"; -import { DateTime } from "luxon"; import { AppConfigService } from "app-config.service"; - -interface DateRange { - begin: string; - end: string; -} -enum PidTermsSearchCondition { - startsWith = "startsWith", - contains = "contains", - equals = "equals", -} +import { DatasetsFilterSettingsComponent } from "./settings/datasets-filter-settings.component"; +import { + selectConditions, + selectFilters, +} from "state-management/selectors/user.selectors"; +import { AsyncPipe } from "@angular/common"; +import { ConditionFilterComponent } from "../../shared/modules/filters/condition-filter.component"; +import { PidFilterComponent } from "../../shared/modules/filters/pid-filter.component"; +import { PidFilterContainsComponent } from "../../shared/modules/filters/pid-filter-contains.component"; +import { PidFilterStartsWithComponent } from "../../shared/modules/filters/pid-filter-startsWith.component"; +import { LocationFilterComponent } from "../../shared/modules/filters/location-filter.component"; +import { GroupFilterComponent } from "../../shared/modules/filters/group-filter.component"; +import { TypeFilterComponent } from "../../shared/modules/filters/type-filter.component"; +import { KeywordFilterComponent } from "../../shared/modules/filters/keyword-filter.component"; +import { DateRangeFilterComponent } from "../../shared/modules/filters/date-range-filter.component"; +import { TextFilterComponent } from "../../shared/modules/filters/text-filter.component"; + +const COMPONENT_MAP: { [key: string]: any } = { + PidFilterComponent: PidFilterComponent, + PidFilterContainsComponent: PidFilterContainsComponent, + PidFilterStartsWithComponent: PidFilterStartsWithComponent, + LocationFilterComponent: LocationFilterComponent, + GroupFilterComponent: GroupFilterComponent, + TypeFilterComponent: TypeFilterComponent, + KeywordFilterComponent: KeywordFilterComponent, + DateRangeFilterComponent: DateRangeFilterComponent, + TextFilterComponent: TextFilterComponent, + ConditionFilterComponent: ConditionFilterComponent, +}; @Component({ selector: "datasets-filter", templateUrl: "datasets-filter.component.html", styleUrls: ["datasets-filter.component.scss"], }) -export class DatasetsFilterComponent implements OnInit, OnDestroy { +export class DatasetsFilterComponent implements OnDestroy { private subscriptions: Subscription[] = []; - locationFacetCounts$ = this.store.select(selectLocationFacetCounts); - groupFacetCounts$ = this.store.select(selectGroupFacetCounts); - typeFacetCounts$ = this.store.select(selectTypeFacetCounts); - keywordFacetCounts$ = this.store.select(selectKeywordFacetCounts); + protected readonly ConditionFilterComponent = ConditionFilterComponent; - searchTerms$ = this.store.select(selectSearchTerms); - pidTerms$ = this.store.select(selectPidTerms); - keywordsTerms$ = this.store.select(selectKeywordsTerms); - locationFilter$ = this.store.select(selectLocationFilter); - groupFilter$ = this.store.select(selectGroupFilter); - typeFilter$ = this.store.select(selectTypeFilter); - keywordsFilter$ = this.store.select(selectKeywordsFilter); - creationTimeFilter$ = this.store.select(selectCreationTimeFilter); - scientificConditions$ = this.store.select(selectScientificConditions); - metadataKeys$ = this.store.select(selectMetadataKeys); + filterConfigs$ = this.store.select(selectFilters); - locationInput$ = new BehaviorSubject(""); - groupInput$ = new BehaviorSubject(""); - typeInput$ = new BehaviorSubject(""); - keywordsInput$ = new BehaviorSubject(""); + conditionConfigs$ = this.store.select(selectConditions); + + scientificConditions$ = this.store.select(selectScientificConditions); appConfig = this.appConfigService.getConfig(); clearSearchBar = false; - groupSuggestions$ = this.createSuggestionObserver( - this.groupFacetCounts$, - this.groupInput$, - this.groupFilter$, - ); - - locationSuggestions$ = this.createSuggestionObserver( - this.locationFacetCounts$, - this.locationInput$, - this.locationFilter$, - ); - - typeSuggestions$ = this.createSuggestionObserver( - this.typeFacetCounts$, - this.typeInput$, - this.typeFilter$, - ); - - keywordsSuggestions$ = this.createSuggestionObserver( - this.keywordFacetCounts$, - this.keywordsInput$, - this.keywordsFilter$, - ); hasAppliedFilters$ = this.store.select(selectHasAppliedFilters); - dateRange: DateRange = { - begin: "", - end: "", - }; + isInEditMode = false; constructor( public appConfigService: AppConfigService, - private asyncPipe: AsyncPipe, public dialog: MatDialog, private store: Store, + private asyncPipe: AsyncPipe, + private cdr: ChangeDetectorRef, ) {} - private buildPidTermsCondition(terms: string) { - if (!terms) return ""; - switch (this.appConfig.pidSearchMethod) { - case PidTermsSearchCondition.startsWith: { - return { $regex: `^${terms}` }; - } - case PidTermsSearchCondition.contains: { - return { $regex: terms }; - } - default: { - return terms; - } - } - } - - createSuggestionObserver( - facetCounts$: Observable, - input$: BehaviorSubject, - currentFilters$: Observable, - ): Observable { - return combineLatest([facetCounts$, input$, currentFilters$]).pipe( - map(([counts, filterString, currentFilters]) => { - if (!counts) { - return []; - } - return counts.filter( - (count) => - typeof count._id === "string" && - count._id.toLowerCase().includes(filterString.toLowerCase()) && - currentFilters.indexOf(count._id) < 0, - ); - }), - ); - } - - getFacetId(facetCount: FacetCount, fallback = ""): string { - const id = facetCount._id; - return id ? String(id) : fallback; - } - - getFacetCount(facetCount: FacetCount): number { - return facetCount.count; - } - - textSearchChanged(terms: string) { - if ("string" != typeof terms) return; - this.clearSearchBar = false; - this.store.dispatch(setSearchTermsAction({ terms })); - } - - pidSearchChanged(pid: string) { - if ("string" != typeof pid) return; - this.clearSearchBar = false; - this.store.dispatch(setPidTermsAction({ pid })); - } - - onLocationInput(event: any) { - const value = (event.target).value; - this.locationInput$.next(value); - } - - onGroupInput(event: any) { - const value = (event.target).value; - this.groupInput$.next(value); - } - - onKeywordInput(event: any) { - const value = (event.target).value; - this.keywordsInput$.next(value); - } - - onTypeInput(event: any) { - const value = (event.target).value; - this.typeInput$.next(value); - } - - locationSelected(location: string | null) { - const loc = location || ""; - this.store.dispatch(addLocationFilterAction({ location: loc })); - this.locationInput$.next(""); - } - - locationRemoved(location: string) { - this.store.dispatch(removeLocationFilterAction({ location })); - } - - groupSelected(group: string) { - this.store.dispatch(addGroupFilterAction({ group })); - this.groupInput$.next(""); - } - - groupRemoved(group: string) { - this.store.dispatch(removeGroupFilterAction({ group })); - } - - keywordSelected(keyword: string) { - this.store.dispatch(addKeywordFilterAction({ keyword })); - this.keywordsInput$.next(""); - } - - keywordRemoved(keyword: string) { - this.store.dispatch(removeKeywordFilterAction({ keyword })); - } - - typeSelected(type: string) { - this.store.dispatch(addTypeFilterAction({ datasetType: type })); - this.typeInput$.next(""); - } - - typeRemoved(type: string) { - this.store.dispatch(removeTypeFilterAction({ datasetType: type })); - } - - dateChanged(event: MatDatepickerInputEvent) { - if (event.value) { - const name = event.targetElement.getAttribute("name"); - if (name === "begin") { - this.dateRange.begin = event.value.toUTC().toISO(); - this.dateRange.end = ""; - } - if (name === "end") { - this.dateRange.end = event.value.toUTC().plus({ days: 1 }).toISO(); - } - if (this.dateRange.begin.length > 0 && this.dateRange.end.length > 0) { - this.store.dispatch(setDateRangeFilterAction(this.dateRange)); - } - } else { - this.store.dispatch(setDateRangeFilterAction({ begin: "", end: "" })); - } - } - - clearFacets() { + reset() { this.clearSearchBar = true; - this.dateRange = { - begin: "", - end: "", - }; + this.store.dispatch(clearFacetsAction()); - this.store.dispatch(setPidTermsAction({ pid: "" })); this.store.dispatch(deselectAllCustomColumnsAction()); - } + this.applyFilters(); + // we need to treat JS event loop here, otherwise this.clearSearchBar is false for the components + setTimeout(() => { + this.clearSearchBar = false; // reset value so it will be triggered again + }, 0); + } + + showDatasetsFilterSettingsDialog() { + const dialogRef = this.dialog.open(DatasetsFilterSettingsComponent, { + width: "60%", + data: { + filterConfigs: this.asyncPipe.transform(this.filterConfigs$), + conditionConfigs: this.asyncPipe.transform(this.conditionConfigs$), + }, + }); + + dialogRef.afterClosed().subscribe((result) => { + console.log("The dialog was closed"); + if (result) { + // Handle the selected filter + console.log(`Selected filter: ${result}`); + this.store.dispatch( + updateFilterConfigs({ filterConfigs: result.filterConfigs }), + ); + this.store.dispatch( + updateConditionsConfigs({ + conditionConfigs: result.conditionConfigs, + }), + ); - showAddConditionDialog() { - this.dialog - .open(SearchParametersDialogComponent, { - data: { parameterKeys: this.asyncPipe.transform(this.metadataKeys$) }, - }) - .afterClosed() - .subscribe((res) => { - if (res) { - const { data } = res; - this.store.dispatch( - addScientificConditionAction({ condition: data }), - ); - this.store.dispatch( - selectColumnAction({ name: data.lhs, columnType: "custom" }), - ); - } - }); + // this.cdr.detectChanges(); + } + }); } applyFilters() { + this.isInEditMode = false; this.store.dispatch(fetchDatasetsAction()); this.store.dispatch(fetchFacetCountsAction()); } - removeCondition(condition: ScientificCondition, index: number) { - this.store.dispatch(removeScientificConditionAction({ index })); - this.store.dispatch( - deselectColumnAction({ name: condition.lhs, columnType: "custom" }), - ); - } - - ngOnInit() { - this.subscriptions.push( - this.searchTerms$ - .pipe( - skipWhile((terms) => terms === ""), - debounceTime(500), - distinctUntilChanged(), - ) - .subscribe((terms) => { - this.store.dispatch(setTextFilterAction({ text: terms })); - }), - ); - - this.subscriptions.push( - this.keywordsTerms$ - .pipe( - skipWhile((terms) => terms === ""), - debounceTime(500), - distinctUntilChanged(), - ) - .subscribe((terms) => { - this.store.dispatch(addKeywordFilterAction({ keyword: terms })); - }), - ); - - this.subscriptions.push( - this.pidTerms$ - .pipe( - skipWhile((terms) => terms.length < 5), - debounceTime(500), - distinctUntilChanged(), - ) - .subscribe((terms) => { - const condition = this.buildPidTermsCondition(terms); - this.store.dispatch(setPidTermsFilterAction({ pid: condition })); - }), - ); - } - ngOnDestroy() { this.subscriptions.forEach((subscription) => subscription.unsubscribe()); } + + resolveComponentType(typeAsString: string): any { + return COMPONENT_MAP[typeAsString]; + } } diff --git a/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.html b/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.html new file mode 100644 index 000000000..535a9f052 --- /dev/null +++ b/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.html @@ -0,0 +1,74 @@ +

Configure Filters

+ +
Filters
+ + + + sort + + + + {{ getFilterLabel(filter.type) }} + + + +
Conditions
+ + + + + + {{ condition.condition.lhs }} + + +  =  + + +  =  + + +  <  + + +  >  + + + {{ + condition.condition.relation === "EQUAL_TO_STRING" + ? '"' + condition.condition.rhs + '"' + : condition.condition.rhs + }} + {{ condition.condition.unit | prettyUnit }} + + + edit + delete + + +
+ +
+ +
+
+ + + + diff --git a/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.scss b/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.scss new file mode 100644 index 000000000..49e9d5afe --- /dev/null +++ b/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.scss @@ -0,0 +1,58 @@ +mat-dialog-title { + background-color: #1976d2; /* Example: Material Indigo 500 */ + color: white; + padding: 12px 24px; + width: 60%; +} + +.filter-dialog-content { + max-height: 400px; + overflow: auto; + padding: 16px; + background-color: #f0f0f0; /* Light grey background for contrast */ +} + +mat-nav-list { + width: 100%; + max-height: 400px; + overflow: auto; +} + +mat-list-item { + display: flex; + align-items: center; /* Centers items vertically */ + justify-content: start; /* Aligns items to the start */ + padding: 10px; /* Provides padding within each list item */ + border-bottom: 1px solid #ccc; /* Adds a subtle line between items for better visual separation */ +} + + +.filter-item { + display: flex; + align-items: center; /* Align items vertically in the center */ + justify-content: space-between; /* Spread out the items to fill the horizontal space */ + padding: 8px 16px; /* Add some padding around the items */ + border-bottom: 1px solid #e0e0e0; /* Optional: adds a separator line between items */ +} + +.filter-toggle { + flex-shrink: 0; /* Prevents the toggle from shrinking */ +} + +.filter-name { + margin-left: 16px; /* Space between the toggle and the name */ + flex-grow: 1; /* Allows the name to take up any available space */ + white-space: nowrap; /* Prevents the text from wrapping */ + overflow: hidden; /* Keeps the text within the container */ + text-overflow: ellipsis; /* Adds an ellipsis if the text is too long */ +} + +.spacer { + flex-grow: 2; /* Forces any extra space to be added here, pushing the drag handle to the right */ +} + +.drag-handle { + cursor: grab; /* Changes the cursor to indicate draggable */ + margin-left: auto; + margin-right: 20px; +} diff --git a/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.spec.ts b/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.spec.ts new file mode 100644 index 000000000..3171707b3 --- /dev/null +++ b/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.spec.ts @@ -0,0 +1,183 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockMatDialogRef, MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { removeScientificConditionAction } from "state-management/actions/datasets.actions"; +import { of } from "rxjs"; +import { + deselectColumnAction, + deselectAllCustomColumnsAction, +} from "state-management/actions/user.actions"; +import { ScientificCondition } from "state-management/models"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { + MatDialogModule, + MatDialog, + MAT_DIALOG_DATA, + MatDialogRef, +} from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { AppConfigService } from "app-config.service"; +import { DatasetsFilterSettingsComponent } from "./datasets-filter-settings.component"; +import { ConditionConfig } from "../../../shared/modules/filters/filters.module"; + +export class MockMatDialog { + open() { + return { + afterClosed: () => of([]), + }; + } +} + +const getConfig = () => ({ + scienceSearchEnabled: false, +}); + +const condition: ScientificCondition = { + lhs: "test", + relation: "EQUAL_TO_NUMERIC", + rhs: 5, + unit: "s", +}; + +describe("DatasetsFilterSettingsComponent", () => { + let component: DatasetsFilterSettingsComponent; + let fixture: ComponentFixture; + + let store: MockStore; + let dispatchSpy; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot({}), + ], + declarations: [ + DatasetsFilterSettingsComponent, + SearchParametersDialogComponent, + ], + providers: [AsyncPipe], + }); + TestBed.overrideComponent(DatasetsFilterSettingsComponent, { + set: { + providers: [ + { provide: AppConfigService, useValue: { getConfig } }, + { provide: MatDialog, useClass: MockMatDialog }, + { provide: MatDialogRef, useClass: MockMatDialogRef }, + { + provide: MAT_DIALOG_DATA, + useValue: { + conditionConfigs: [ + { + condition, + enabled: true, + }, + ], + }, + }, + ], + }, + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DatasetsFilterSettingsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + beforeEach(inject([Store], (mockStore: MockStore) => { + store = mockStore; + })); + + afterEach(() => { + fixture.destroy(); + }); + + it("should be created", () => { + expect(component).toBeTruthy(); + }); + + describe("#showDatasetsFilterSettingsDialog()", () => { + it("should open DatasetsFilterSettingsComponent", () => { + spyOn(component.dialog, "open").and.callThrough(); + dispatchSpy = spyOn(store, "dispatch"); + + // Spy or stub other side effects in addCondition as needed + spyOn(component, "toggleCondition").and.callFake( + (ignored: ConditionConfig) => ignored, + ); + + component.metadataKeys$ = of(["test", "keys"]); + component.addCondition(); + + expect(component.dialog.open).toHaveBeenCalledTimes(1); + expect(component.dialog.open).toHaveBeenCalledWith( + SearchParametersDialogComponent, + { + data: { + parameterKeys: ["test", "keys"], + }, + }, + ); + }); + }); + + describe("#removeCondition()", () => { + it("should dispatch a removeScientificConditionAction and a deselectColumnAction", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const conditionConfig: ConditionConfig = { + condition, + enabled: true, + }; + + component.removeCondition(conditionConfig, 0); + + expect(dispatchSpy).toHaveBeenCalledTimes(2); + expect(dispatchSpy).toHaveBeenCalledWith( + removeScientificConditionAction({ condition }), + ); + expect(dispatchSpy).toHaveBeenCalledWith( + deselectColumnAction({ name: condition.lhs, columnType: "custom" }), + ); + }); + }); +}); diff --git a/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.ts b/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.ts new file mode 100644 index 000000000..f55af1307 --- /dev/null +++ b/src/app/datasets/datasets-filter/settings/datasets-filter-settings.component.ts @@ -0,0 +1,155 @@ +import { ChangeDetectorRef, Component, Inject } from "@angular/core"; +import { + MAT_DIALOG_DATA, + MatDialog, + MatDialogRef, +} from "@angular/material/dialog"; +import { SearchParametersDialogComponent } from "../../../shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AppConfigService } from "app-config.service"; +import { AsyncPipe } from "@angular/common"; +import { + addScientificConditionAction, + removeScientificConditionAction, +} from "../../../state-management/actions/datasets.actions"; +import { + deselectColumnAction, + selectColumnAction, +} from "../../../state-management/actions/user.actions"; +import { Store } from "@ngrx/store"; +import { selectMetadataKeys } from "../../../state-management/selectors/datasets.selectors"; +import { CdkDragDrop, moveItemInArray } from "@angular/cdk/drag-drop"; +import { + ConditionConfig, + FilterConfig, +} from "../../../shared/modules/filters/filters.module"; +import { getFilterLabel } from "../../../shared/modules/filters/utils"; +import { ScientificCondition } from "../../../state-management/models"; + +@Component({ + selector: "app-type-datasets-filter-settings", + templateUrl: `./datasets-filter-settings.component.html`, + styleUrls: [`./datasets-filter-settings.component.scss`], +}) +export class DatasetsFilterSettingsComponent { + protected readonly getFilterLabel = getFilterLabel; + + metadataKeys$ = this.store.select(selectMetadataKeys); + + appConfig = this.appConfigService.getConfig(); + + constructor( + public dialogRef: MatDialogRef, + public dialog: MatDialog, + private store: Store, + private asyncPipe: AsyncPipe, + private appConfigService: AppConfigService, + @Inject(MAT_DIALOG_DATA) public data: any, + ) {} + + addCondition() { + this.dialog + .open(SearchParametersDialogComponent, { + data: { + parameterKeys: this.asyncPipe.transform(this.metadataKeys$), + }, + }) + .afterClosed() + .subscribe((res) => { + if (res) { + const { data } = res; + const condition = this.toggleCondition({ + condition: data, + enabled: false, + }); + this.data.conditionConfigs.push(condition); + } + }); + } + + editCondition(condition: ConditionConfig, i: number) { + this.store.dispatch( + removeScientificConditionAction({ condition: condition.condition }), + ); + this.store.dispatch( + deselectColumnAction({ + name: condition.condition.lhs, + columnType: "custom", + }), + ); + this.dialog + .open(SearchParametersDialogComponent, { + data: { + parameterKeys: this.asyncPipe.transform(this.metadataKeys$), + condition: condition.condition, + }, + }) + .afterClosed() + .subscribe((res) => { + if (res) { + const { data } = res; + this.data.conditionConfigs[i] = { + ...condition, + condition: data, + }; + this.store.dispatch( + addScientificConditionAction({ condition: data }), + ); + this.store.dispatch( + selectColumnAction({ name: data.lhs, columnType: "custom" }), + ); + } + }); + } + + removeCondition(condition: ConditionConfig, index: number) { + this.data.conditionConfigs.splice(index, 1); + if (condition.enabled) { + this.store.dispatch( + removeScientificConditionAction({ condition: condition.condition }), + ); + this.store.dispatch( + deselectColumnAction({ + name: condition.condition.lhs, + columnType: "custom", + }), + ); + } + } + + toggleCondition(condition: ConditionConfig) { + condition.enabled = !condition.enabled; + const data = condition.condition; + if (condition.enabled) { + this.store.dispatch(addScientificConditionAction({ condition: data })); + this.store.dispatch( + selectColumnAction({ name: data.lhs, columnType: "custom" }), + ); + } else { + this.store.dispatch(removeScientificConditionAction({ condition: data })); + this.store.dispatch( + deselectColumnAction({ name: data.lhs, columnType: "custom" }), + ); + } + return condition; + } + + toggleVisibility(filter: FilterConfig) { + filter.visible = !filter.visible; + } + + drop(event: CdkDragDrop): void { + moveItemInArray( + this.data.filterConfigs, + event.previousIndex, + event.currentIndex, + ); + } + + onApply() { + this.dialogRef.close(this.data); + } + + onCancel() { + this.dialogRef.close(); + } +} diff --git a/src/app/datasets/datasets.module.ts b/src/app/datasets/datasets.module.ts index 72c17a0bf..d20615983 100644 --- a/src/app/datasets/datasets.module.ts +++ b/src/app/datasets/datasets.module.ts @@ -84,6 +84,11 @@ import { RelatedDatasetsComponent } from "./related-datasets/related-datasets.co import { FullTextSearchBarComponent } from "./dashboard/full-text-search/full-text-search-bar.component"; import { DatafilesActionsComponent } from "./datafiles-actions/datafiles-actions.component"; import { DatafilesActionComponent } from "./datafiles-actions/datafiles-action.component"; +import { MatMenuModule } from "@angular/material/menu"; +import { DatasetsFilterSettingsComponent } from "./datasets-filter/settings/datasets-filter-settings.component"; +import { CdkDrag, CdkDragHandle, CdkDropList } from "@angular/cdk/drag-drop"; +import { FiltersModule } from "shared/modules/filters/filters.module"; +import { userReducer } from "state-management/reducers/user.reducer"; @NgModule({ imports: [ @@ -138,8 +143,14 @@ import { DatafilesActionComponent } from "./datafiles-actions/datafiles-action.c StoreModule.forFeature("samples", samplesReducer), StoreModule.forFeature("publishedData", publishedDataReducer), StoreModule.forFeature("logbooks", logbooksReducer), + StoreModule.forFeature("users", userReducer), LogbooksModule, FullTextSearchBarComponent, + MatMenuModule, + CdkDropList, + CdkDrag, + CdkDragHandle, + FiltersModule, ], declarations: [ BatchViewComponent, @@ -165,6 +176,7 @@ import { DatafilesActionComponent } from "./datafiles-actions/datafiles-action.c RelatedDatasetsComponent, DatafilesActionsComponent, DatafilesActionComponent, + DatasetsFilterSettingsComponent, ], providers: [ ArchivingService, diff --git a/src/app/shared/MockStubs.ts b/src/app/shared/MockStubs.ts index 3e1dc2776..cfd964f0a 100644 --- a/src/app/shared/MockStubs.ts +++ b/src/app/shared/MockStubs.ts @@ -125,7 +125,7 @@ export class MockAppConfigService { export class MockStore { public dispatch() {} - public select() { + public select(selector) { return of([]); } diff --git a/src/app/shared/modules/filters/clearable-input.component.ts b/src/app/shared/modules/filters/clearable-input.component.ts new file mode 100644 index 000000000..f600e9ed1 --- /dev/null +++ b/src/app/shared/modules/filters/clearable-input.component.ts @@ -0,0 +1,14 @@ +import { Component, ElementRef, Input, ViewChild } from "@angular/core"; + +//TODO move to common +@Component({ template: "" }) +export class ClearableInputComponent { + @ViewChild("input", { static: true }) input!: ElementRef; + + @Input() + set clear(value: boolean) { + if (value) { + this.input.nativeElement.value = ""; + } + } +} diff --git a/src/app/shared/modules/filters/condition-filter.component.html b/src/app/shared/modules/filters/condition-filter.component.html new file mode 100644 index 000000000..4b82a5f6a --- /dev/null +++ b/src/app/shared/modules/filters/condition-filter.component.html @@ -0,0 +1,4 @@ + + Condition + + diff --git a/src/app/shared/modules/filters/condition-filter.component.scss b/src/app/shared/modules/filters/condition-filter.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/condition-filter.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/condition-filter.component.ts b/src/app/shared/modules/filters/condition-filter.component.ts new file mode 100644 index 000000000..c8f15a0c8 --- /dev/null +++ b/src/app/shared/modules/filters/condition-filter.component.ts @@ -0,0 +1,40 @@ +import { Component, Input } from "@angular/core"; +import { Store } from "@ngrx/store"; +import { ScientificCondition } from "state-management/models"; + +@Component({ + selector: "app-condition-filter", + templateUrl: "condition-filter.component.html", + styleUrls: ["condition-filter.component.scss"], +}) +export class ConditionFilterComponent { + @Input() condition: ScientificCondition; + + constructor(private store: Store) {} + + formatCondition() { + const condition = this.condition; + let relationSymbol = ""; + switch (condition.relation) { + case "EQUAL_TO_NUMERIC": + case "EQUAL_TO_STRING": + relationSymbol = "="; + break; + case "LESS_THAN": + relationSymbol = "<"; + break; + case "GREATER_THAN": + relationSymbol = ">"; + break; + default: + relationSymbol = ""; + } + + const rhsValue = + condition.relation === "EQUAL_TO_STRING" + ? `"${condition.rhs}"` + : condition.rhs; + + return `${condition.lhs} ${relationSymbol} ${rhsValue} ${condition.unit}`; + } +} diff --git a/src/app/shared/modules/filters/date-range-filter.component.html b/src/app/shared/modules/filters/date-range-filter.component.html new file mode 100644 index 000000000..61a2b1679 --- /dev/null +++ b/src/app/shared/modules/filters/date-range-filter.component.html @@ -0,0 +1,19 @@ + + {{ label }} + + + + + + + diff --git a/src/app/shared/modules/filters/date-range-filter.component.scss b/src/app/shared/modules/filters/date-range-filter.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/date-range-filter.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/date-range-filter.component.spec.ts b/src/app/shared/modules/filters/date-range-filter.component.spec.ts new file mode 100644 index 000000000..6534d2308 --- /dev/null +++ b/src/app/shared/modules/filters/date-range-filter.component.spec.ts @@ -0,0 +1,174 @@ +import { isDevMode, NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { setDateRangeFilterAction } from "state-management/actions/datasets.actions"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { MatDialogModule, MatDialog } from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { DateTime } from "luxon"; +import { + MatDatepickerInputEvent, + MatDatepickerModule, +} from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { DateRangeFilterComponent } from "./date-range-filter.component"; + +describe("DateRangeFilterComponent", () => { + let component: DateRangeFilterComponent; + let fixture: ComponentFixture; + + let store: MockStore; + let dispatchSpy; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot( + {}, + { + runtimeChecks: { + strictActionImmutability: false, + strictActionSerializability: false, + strictActionTypeUniqueness: false, + strictActionWithinNgZone: false, + strictStateImmutability: false, + strictStateSerializability: false, + }, + }, + ), + ], + declarations: [DateRangeFilterComponent, SearchParametersDialogComponent], + providers: [AsyncPipe], + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DateRangeFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + beforeEach(inject([Store], (mockStore: MockStore) => { + store = mockStore; + })); + + afterEach(() => { + fixture.destroy(); + }); + + describe("#dateChanged()", () => { + it("should dispatch setDateRangeFilterAction with empty string values if event.value is null", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const event = { + targetElement: { + getAttribute: (name: string) => "begin", + }, + value: null, + } as MatDatepickerInputEvent; + + component.dateChanged(event); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + setDateRangeFilterAction({ begin: "", end: "" }), + ); + }); + + it("should set dateRange.begin if event has value and event.targetElement name is begin", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const beginDate = DateTime.fromJSDate(new Date("2021-01-01")); + const event = { + targetElement: { + getAttribute: (name: string) => "begin", + }, + value: beginDate, + } as MatDatepickerInputEvent; + + component.dateChanged(event); + + const expected = beginDate.toUTC().toISO(); + expect(component.dateRange.begin).toEqual(expected); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it("should set dateRange.end if event has value and event.targetElement name is end", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const endDate = DateTime.fromJSDate(new Date("2021-07-08")); + const event = { + targetElement: { + getAttribute: (name: string) => "end", + }, + value: endDate, + } as MatDatepickerInputEvent; + + component.dateChanged(event); + + const expected = endDate.toUTC().plus({ days: 1 }).toISO(); + expect(component.dateRange.end).toEqual(expected); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it("should dispatch a setDateRangeFilterAction if dateRange.begin and dateRange.end have values", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const beginDate = DateTime.fromJSDate(new Date("2021-01-01")); + const endDate = DateTime.fromJSDate(new Date("2021-07-08")); + component.dateRange.begin = beginDate.toUTC().toISO(); + const event = { + targetElement: { + getAttribute: (name: string) => "end", + }, + value: endDate, + } as MatDatepickerInputEvent; + + component.dateChanged(event); + + const expected = { + begin: beginDate.toUTC().toISO(), + end: endDate.toUTC().plus({ days: 1 }).toISO(), + }; + expect(dispatchSpy).toHaveBeenCalledOnceWith( + setDateRangeFilterAction(expected), + ); + }); + }); +}); diff --git a/src/app/shared/modules/filters/date-range-filter.component.ts b/src/app/shared/modules/filters/date-range-filter.component.ts new file mode 100644 index 000000000..dddd4ae46 --- /dev/null +++ b/src/app/shared/modules/filters/date-range-filter.component.ts @@ -0,0 +1,62 @@ +import { Component, Input } from "@angular/core"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { MatDatepickerInputEvent } from "@angular/material/datepicker"; +import { DateTime } from "luxon"; +import { setDateRangeFilterAction } from "state-management/actions/datasets.actions"; +import { selectCreationTimeFilter } from "state-management/selectors/datasets.selectors"; +import { Store } from "@ngrx/store"; +import { getFilterLabel } from "./utils"; + +interface DateRange { + begin: string; + end: string; +} + +@Component({ + selector: "app-date-range-filter", + templateUrl: "date-range-filter.component.html", + styleUrls: ["date-range-filter.component.scss"], +}) +export class DateRangeFilterComponent extends ClearableInputComponent { + creationTimeFilter$ = this.store.select(selectCreationTimeFilter); + + dateRange: DateRange = { + begin: "", + end: "", + }; + + constructor(private store: Store) { + super(); + } + + get label() { + return getFilterLabel(this.constructor.name); + } + + dateChanged(event: MatDatepickerInputEvent) { + if (event.value) { + const name = event.targetElement.getAttribute("name"); + if (name === "begin") { + this.dateRange.begin = event.value.toUTC().toISO(); + this.dateRange.end = ""; + } + if (name === "end") { + this.dateRange.end = event.value.toUTC().plus({ days: 1 }).toISO(); + } + if (this.dateRange.begin.length > 0 && this.dateRange.end.length > 0) { + this.store.dispatch(setDateRangeFilterAction(this.dateRange)); + } + } else { + this.store.dispatch(setDateRangeFilterAction({ begin: "", end: "" })); + } + } + + @Input() + set clear(value: boolean) { + if (value) + this.dateRange = { + begin: "", + end: "", + }; + } +} diff --git a/src/app/shared/modules/filters/filters.module.ts b/src/app/shared/modules/filters/filters.module.ts new file mode 100644 index 000000000..783e0a507 --- /dev/null +++ b/src/app/shared/modules/filters/filters.module.ts @@ -0,0 +1,80 @@ +import { NgModule } from "@angular/core"; +import { PidFilterContainsComponent } from "./pid-filter-contains.component"; +import { PidFilterComponent } from "./pid-filter.component"; +import { PidFilterStartsWithComponent } from "./pid-filter-startsWith.component"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { LocationFilterComponent } from "./location-filter.component"; +import { GroupFilterComponent } from "./group-filter.component"; +import { ConditionFilterComponent } from "./condition-filter.component"; +import { TypeFilterComponent } from "./type-filter.component"; +import { TextFilterComponent } from "./text-filter.component"; +import { KeywordFilterComponent } from "./keyword-filter.component"; +import { DateRangeFilterComponent } from "./date-range-filter.component"; +import { ScientificCondition } from "state-management/models"; +import { MatInputModule } from "@angular/material/input"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { AsyncPipe, NgForOf } from "@angular/common"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatIconModule } from "@angular/material/icon"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; + +@NgModule({ + declarations: [ + ClearableInputComponent, + PidFilterComponent, + PidFilterContainsComponent, + PidFilterStartsWithComponent, + LocationFilterComponent, + GroupFilterComponent, + TypeFilterComponent, + KeywordFilterComponent, + DateRangeFilterComponent, + TextFilterComponent, + ConditionFilterComponent, + ], + imports: [ + MatInputModule, + MatDatepickerModule, + AsyncPipe, + MatChipsModule, + MatIconModule, + MatAutocompleteModule, + NgForOf, + ], + exports: [ + ClearableInputComponent, + PidFilterComponent, + PidFilterContainsComponent, + PidFilterStartsWithComponent, + LocationFilterComponent, + GroupFilterComponent, + TypeFilterComponent, + KeywordFilterComponent, + DateRangeFilterComponent, + TextFilterComponent, + ConditionFilterComponent, + ], +}) +export class FiltersModule {} + +type Filter = + | "PidFilterComponent" + | "PidFilterContainsComponent" + | "PidFilterStartsWithComponent" + | "LocationFilterComponent" + | "GroupFilterComponent" + | "TypeFilterComponent" + | "KeywordFilterComponent" + | "DateRangeFilterComponent" + | "TextFilterComponent" + | "ConditionFilterComponent"; + +export interface FilterConfig { + type: Filter; + visible: boolean; +} + +export interface ConditionConfig { + condition: ScientificCondition; + enabled: boolean; +} diff --git a/src/app/shared/modules/filters/group-filter.component.html b/src/app/shared/modules/filters/group-filter.component.html new file mode 100644 index 000000000..0f202875b --- /dev/null +++ b/src/app/shared/modules/filters/group-filter.component.html @@ -0,0 +1,29 @@ + + {{ label }} + + {{ group }}cancel + + + + + + {{ getFacetId(fc, "No Group") }} | + {{ getFacetCount(fc) }} + + + diff --git a/src/app/shared/modules/filters/group-filter.component.scss b/src/app/shared/modules/filters/group-filter.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/group-filter.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/group-filter.component.spec.ts b/src/app/shared/modules/filters/group-filter.component.spec.ts new file mode 100644 index 000000000..7e9604d8c --- /dev/null +++ b/src/app/shared/modules/filters/group-filter.component.spec.ts @@ -0,0 +1,135 @@ +import { isDevMode, NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { + addGroupFilterAction, + removeGroupFilterAction, +} from "state-management/actions/datasets.actions"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { MatDialogModule, MatDialog } from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { GroupFilterComponent } from "./group-filter.component"; + +describe("GroupFilterComponent", () => { + let component: GroupFilterComponent; + let fixture: ComponentFixture; + + let store: MockStore; + let dispatchSpy; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot( + {}, + { + runtimeChecks: { + strictActionImmutability: false, + strictActionSerializability: false, + strictActionTypeUniqueness: false, + strictActionWithinNgZone: false, + strictStateImmutability: false, + strictStateSerializability: false, + }, + }, + ), + ], + declarations: [GroupFilterComponent, SearchParametersDialogComponent], + providers: [AsyncPipe], + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GroupFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + beforeEach(inject([Store], (mockStore: MockStore) => { + store = mockStore; + })); + + afterEach(() => { + fixture.destroy(); + }); + + describe("#onGroupInput()", () => { + it("should call next on groupInput$", () => { + const nextSpy = spyOn(component.groupInput$, "next"); + + const event = { + target: { + value: "group", + }, + }; + + component.onGroupInput(event); + + expect(nextSpy).toHaveBeenCalledOnceWith(event.target.value); + }); + }); + + describe("#groupSelected()", () => { + it("should dispatch an AddGroupFilterAction", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const group = "test"; + component.groupSelected(group); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith(addGroupFilterAction({ group })); + }); + }); + + describe("#groupRemoved()", () => { + it("should dispatch a RemoveGroupFilterAction", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const group = "test"; + component.groupRemoved(group); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + removeGroupFilterAction({ group }), + ); + }); + }); +}); diff --git a/src/app/shared/modules/filters/group-filter.component.ts b/src/app/shared/modules/filters/group-filter.component.ts new file mode 100644 index 000000000..101e968f5 --- /dev/null +++ b/src/app/shared/modules/filters/group-filter.component.ts @@ -0,0 +1,60 @@ +import { Component } from "@angular/core"; +import { + selectGroupFacetCounts, + selectGroupFilter, +} from "state-management/selectors/datasets.selectors"; +import { Store } from "@ngrx/store"; +import { + addGroupFilterAction, + removeGroupFilterAction, +} from "state-management/actions/datasets.actions"; +import { + createSuggestionObserver, + getFacetCount, + getFacetId, + getFilterLabel, +} from "./utils"; +import { BehaviorSubject } from "rxjs"; +import { ClearableInputComponent } from "./clearable-input.component"; + +@Component({ + selector: "app-group-filter", + templateUrl: "group-filter.component.html", + styleUrls: ["group-filter.component.scss"], +}) +export class GroupFilterComponent extends ClearableInputComponent { + protected readonly getFacetId = getFacetId; + protected readonly getFacetCount = getFacetCount; + + groupFilter$ = this.store.select(selectGroupFilter); + + groupFacetCounts$ = this.store.select(selectGroupFacetCounts); + groupInput$ = new BehaviorSubject(""); + + groupSuggestions$ = createSuggestionObserver( + this.groupFacetCounts$, + this.groupInput$, + this.groupFilter$, + ); + + constructor(private store: Store) { + super(); + } + + get label() { + return getFilterLabel(this.constructor.name); + } + + onGroupInput(event: any) { + const value = (event.target).value; + this.groupInput$.next(value); + } + groupSelected(group: string) { + this.store.dispatch(addGroupFilterAction({ group })); + this.groupInput$.next(""); + } + + groupRemoved(group: string) { + this.store.dispatch(removeGroupFilterAction({ group })); + } +} diff --git a/src/app/shared/modules/filters/keyword-filter.component.html b/src/app/shared/modules/filters/keyword-filter.component.html new file mode 100644 index 000000000..0b8ab5580 --- /dev/null +++ b/src/app/shared/modules/filters/keyword-filter.component.html @@ -0,0 +1,29 @@ + + {{ label }} + + {{ keyword }}cancel + + + + + + {{ getFacetId(fc, "No Keywords") }} + : {{ getFacetCount(fc) }} + + + diff --git a/src/app/shared/modules/filters/keyword-filter.component.scss b/src/app/shared/modules/filters/keyword-filter.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/keyword-filter.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/keyword-filter.component.spec.ts b/src/app/shared/modules/filters/keyword-filter.component.spec.ts new file mode 100644 index 000000000..7dbd13b6a --- /dev/null +++ b/src/app/shared/modules/filters/keyword-filter.component.spec.ts @@ -0,0 +1,137 @@ +import { isDevMode, NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { + addKeywordFilterAction, + removeKeywordFilterAction, +} from "state-management/actions/datasets.actions"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { MatDialogModule, MatDialog } from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { KeywordFilterComponent } from "./keyword-filter.component"; + +describe("KeywordFilterComponent", () => { + let component: KeywordFilterComponent; + let fixture: ComponentFixture; + + let store: MockStore; + let dispatchSpy; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot( + {}, + { + runtimeChecks: { + strictActionImmutability: false, + strictActionSerializability: false, + strictActionTypeUniqueness: false, + strictActionWithinNgZone: false, + strictStateImmutability: false, + strictStateSerializability: false, + }, + }, + ), + ], + declarations: [KeywordFilterComponent, SearchParametersDialogComponent], + providers: [AsyncPipe], + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KeywordFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + beforeEach(inject([Store], (mockStore: MockStore) => { + store = mockStore; + })); + + afterEach(() => { + fixture.destroy(); + }); + + describe("#onKeywordInput()", () => { + it("should call next on keywordsInput$", () => { + const nextSpy = spyOn(component.keywordsInput$, "next"); + + const event = { + target: { + value: "keyword", + }, + }; + + component.onKeywordInput(event); + + expect(nextSpy).toHaveBeenCalledOnceWith(event.target.value); + }); + }); + + describe("#keywordSelected()", () => { + it("should dispatch an AddKeywordFilterAction", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const keyword = "test"; + component.keywordSelected(keyword); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + addKeywordFilterAction({ keyword }), + ); + }); + }); + + describe("#keywordRemoved()", () => { + it("should dispatch a RemoveKeywordFilterAction", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const keyword = "test"; + component.keywordRemoved(keyword); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + removeKeywordFilterAction({ keyword }), + ); + }); + }); +}); diff --git a/src/app/shared/modules/filters/keyword-filter.component.ts b/src/app/shared/modules/filters/keyword-filter.component.ts new file mode 100644 index 000000000..b6faf5802 --- /dev/null +++ b/src/app/shared/modules/filters/keyword-filter.component.ts @@ -0,0 +1,84 @@ +import { Component, OnDestroy } from "@angular/core"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { + createSuggestionObserver, + getFacetCount, + getFacetId, + getFilterLabel, +} from "./utils"; +import { + selectKeywordFacetCounts, + selectKeywordsFilter, + selectKeywordsTerms, +} from "state-management/selectors/datasets.selectors"; +import { Store } from "@ngrx/store"; +import { BehaviorSubject } from "rxjs"; +import { + addKeywordFilterAction, + removeKeywordFilterAction, +} from "state-management/actions/datasets.actions"; +import { debounceTime, distinctUntilChanged, skipWhile } from "rxjs/operators"; + +@Component({ + selector: "app-keyword-filter", + templateUrl: "keyword-filter.component.html", + styleUrls: ["keyword-filter.component.scss"], +}) +export class KeywordFilterComponent + extends ClearableInputComponent + implements OnDestroy +{ + protected readonly getFacetCount = getFacetCount; + protected readonly getFacetId = getFacetId; + + keywordsTerms$ = this.store.select(selectKeywordsTerms); + + keywordsFilter$ = this.store.select(selectKeywordsFilter); + + keywordsInput$ = new BehaviorSubject(""); + keywordFacetCounts$ = this.store.select(selectKeywordFacetCounts); + + subscription = undefined; + + keywordsSuggestions$ = createSuggestionObserver( + this.keywordFacetCounts$, + this.keywordsInput$, + this.keywordsFilter$, + ); + + constructor(private store: Store) { + super(); + + this.subscription = this.keywordsTerms$ + .pipe( + skipWhile((terms) => terms === ""), + debounceTime(500), + distinctUntilChanged(), + ) + .subscribe((terms) => { + this.store.dispatch(addKeywordFilterAction({ keyword: terms })); + }); + } + + get label() { + return getFilterLabel(this.constructor.name); + } + + onKeywordInput(event: any) { + const value = (event.target).value; + this.keywordsInput$.next(value); + } + + keywordSelected(keyword: string) { + this.store.dispatch(addKeywordFilterAction({ keyword })); + this.keywordsInput$.next(""); + } + + keywordRemoved(keyword: string) { + this.store.dispatch(removeKeywordFilterAction({ keyword })); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } +} diff --git a/src/app/shared/modules/filters/location-filter.component.html b/src/app/shared/modules/filters/location-filter.component.html new file mode 100644 index 000000000..2e9b02434 --- /dev/null +++ b/src/app/shared/modules/filters/location-filter.component.html @@ -0,0 +1,31 @@ + + {{ label }} + + {{ location || "No Location" }} + cancel + + + + + + + {{ getFacetId(fc, "No Location") }} | + {{ getFacetCount(fc) }} + + + diff --git a/src/app/shared/modules/filters/location-filter.component.scss b/src/app/shared/modules/filters/location-filter.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/location-filter.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/location-filter.component.spec.ts b/src/app/shared/modules/filters/location-filter.component.spec.ts new file mode 100644 index 000000000..b5a0e5a52 --- /dev/null +++ b/src/app/shared/modules/filters/location-filter.component.spec.ts @@ -0,0 +1,137 @@ +import { isDevMode, NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { + addLocationFilterAction, + removeLocationFilterAction, +} from "state-management/actions/datasets.actions"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { MatDialogModule, MatDialog } from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { LocationFilterComponent } from "./location-filter.component"; + +describe("LocationFilterComponent", () => { + let component: LocationFilterComponent; + let fixture: ComponentFixture; + + let store: MockStore; + let dispatchSpy; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot( + {}, + { + runtimeChecks: { + strictActionImmutability: false, + strictActionSerializability: false, + strictActionTypeUniqueness: false, + strictActionWithinNgZone: false, + strictStateImmutability: false, + strictStateSerializability: false, + }, + }, + ), + ], + declarations: [LocationFilterComponent, SearchParametersDialogComponent], + providers: [AsyncPipe], + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LocationFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + beforeEach(inject([Store], (mockStore: MockStore) => { + store = mockStore; + })); + + afterEach(() => { + fixture.destroy(); + }); + + describe("#onLocationInput()", () => { + it("should call next on locationInput$", () => { + const nextSpy = spyOn(component.locationInput$, "next"); + + const event = { + target: { + value: "location", + }, + }; + + component.onLocationInput(event); + + expect(nextSpy).toHaveBeenCalledOnceWith(event.target.value); + }); + }); + + describe("#locationSelected()", () => { + it("should dispatch an AddLocationFilterAction", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const location = "test"; + component.locationSelected(location); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + addLocationFilterAction({ location }), + ); + }); + }); + + describe("#locationRemoved()", () => { + it("should dispatch a RemoveLocationFilterAction", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const location = "test"; + component.locationRemoved(location); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + removeLocationFilterAction({ location }), + ); + }); + }); +}); diff --git a/src/app/shared/modules/filters/location-filter.component.ts b/src/app/shared/modules/filters/location-filter.component.ts new file mode 100644 index 000000000..0d6b44b8e --- /dev/null +++ b/src/app/shared/modules/filters/location-filter.component.ts @@ -0,0 +1,62 @@ +import { Component } from "@angular/core"; +import { + selectLocationFacetCounts, + selectLocationFilter, +} from "state-management/selectors/datasets.selectors"; +import { + createSuggestionObserver, + getFacetCount, + getFacetId, + getFilterLabel, +} from "./utils"; +import { BehaviorSubject } from "rxjs"; +import { + addLocationFilterAction, + removeLocationFilterAction, +} from "state-management/actions/datasets.actions"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { Store } from "@ngrx/store"; + +@Component({ + selector: "app-location-filter", + templateUrl: "location-filter.component.html", + styleUrls: ["location-filter.component.scss"], +}) +export class LocationFilterComponent extends ClearableInputComponent { + protected readonly getFacetId = getFacetId; + protected readonly getFacetCount = getFacetCount; + + locationFacetCounts$ = this.store.select(selectLocationFacetCounts); + locationFilter$ = this.store.select(selectLocationFilter); + + locationInput$ = new BehaviorSubject(""); + + locationSuggestions$ = createSuggestionObserver( + this.locationFacetCounts$, + this.locationInput$, + this.locationFilter$, + ); + + constructor(private store: Store) { + super(); + } + + get label() { + return getFilterLabel(this.constructor.name); + } + + locationSelected(location: string | null) { + const loc = location || ""; + this.store.dispatch(addLocationFilterAction({ location: loc })); + this.locationInput$.next(""); + } + + locationRemoved(location: string) { + this.store.dispatch(removeLocationFilterAction({ location })); + } + + onLocationInput(event: any) { + const value = (event.target).value; + this.locationInput$.next(value); + } +} diff --git a/src/app/shared/modules/filters/pid-filter-contains.component.html b/src/app/shared/modules/filters/pid-filter-contains.component.html new file mode 100644 index 000000000..3cd8cdf13 --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter-contains.component.html @@ -0,0 +1,9 @@ + + {{ label }} + + diff --git a/src/app/shared/modules/filters/pid-filter-contains.component.scss b/src/app/shared/modules/filters/pid-filter-contains.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter-contains.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/pid-filter-contains.component.spec.ts b/src/app/shared/modules/filters/pid-filter-contains.component.spec.ts new file mode 100644 index 000000000..7e478c22c --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter-contains.component.spec.ts @@ -0,0 +1,106 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { MatDialogModule, MatDialog } from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { AppConfigService } from "app-config.service"; +import { PidFilterContainsComponent } from "./pid-filter-contains.component"; +import { PidFilterComponent } from "./pid-filter.component"; + +const getConfig = () => ({ + scienceSearchEnabled: false, +}); + +describe("PidFilterContainsComponent", () => { + let component: PidFilterContainsComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot({}), + ], + declarations: [ + PidFilterContainsComponent, + PidFilterComponent, + SearchParametersDialogComponent, + ], + providers: [AsyncPipe], + }); + TestBed.overrideComponent(PidFilterContainsComponent, { + set: { + providers: [ + { + provide: AppConfigService, + useValue: { + getConfig, + }, + }, + ], + }, + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PidFilterContainsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + }); + + describe("#buildPidTermsCondition()", () => { + const tests = [ + { input: "1", method: "contains", expected: { $regex: "1" } }, + ]; + + tests.forEach((test, index) => { + it(`should return correct condition for test case #${index + 1}`, () => { + component.appConfig.pidSearchMethod = test.method; + const condition = component.buildPidTermsCondition(test.input); + expect(condition).toEqual(test.expected); + }); + }); + }); +}); diff --git a/src/app/shared/modules/filters/pid-filter-contains.component.ts b/src/app/shared/modules/filters/pid-filter-contains.component.ts new file mode 100644 index 000000000..bdd4e1ef8 --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter-contains.component.ts @@ -0,0 +1,13 @@ +import { PidFilterComponent } from "./pid-filter.component"; +import { Component } from "@angular/core"; + +@Component({ + selector: "app-pid-contains-filter", + templateUrl: "./pid-filter-contains.component.html", + styleUrls: ["./pid-filter-contains.component.scss"], +}) +export class PidFilterContainsComponent extends PidFilterComponent { + buildPidTermsCondition(terms: string): { $regex: string } { + return { $regex: terms }; + } +} diff --git a/src/app/shared/modules/filters/pid-filter-startsWith.component.html b/src/app/shared/modules/filters/pid-filter-startsWith.component.html new file mode 100644 index 000000000..3cd8cdf13 --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter-startsWith.component.html @@ -0,0 +1,9 @@ + + {{ label }} + + diff --git a/src/app/shared/modules/filters/pid-filter-startsWith.component.scss b/src/app/shared/modules/filters/pid-filter-startsWith.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter-startsWith.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/pid-filter-startsWith.component.spec.ts b/src/app/shared/modules/filters/pid-filter-startsWith.component.spec.ts new file mode 100644 index 000000000..6c80cab7f --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter-startsWith.component.spec.ts @@ -0,0 +1,109 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, + fakeAsync, + tick, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { setPidTermsFilterAction } from "state-management/actions/datasets.actions"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { MatDialogModule, MatDialog } from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { AppConfigService } from "app-config.service"; +import { PidFilterComponent } from "./pid-filter.component"; +import { PidFilterStartsWithComponent } from "./pid-filter-startsWith.component"; + +const getConfig = () => ({ + scienceSearchEnabled: false, +}); + +describe("PidFilterStartsWithComponent", () => { + let component: PidFilterStartsWithComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot({}), + ], + declarations: [ + PidFilterStartsWithComponent, + PidFilterComponent, + SearchParametersDialogComponent, + ], + providers: [AsyncPipe], + }); + TestBed.overrideComponent(PidFilterStartsWithComponent, { + set: { + providers: [ + { + provide: AppConfigService, + useValue: { + getConfig, + }, + }, + ], + }, + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PidFilterStartsWithComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + }); + + describe("#buildPidTermsCondition()", () => { + const tests = [ + { input: "1", method: "startsWith", expected: { $regex: "^1" } }, + ]; + + tests.forEach((test, index) => { + it(`should return correct condition for test case #${index + 1}`, () => { + component.appConfig.pidSearchMethod = test.method; + const condition = component["buildPidTermsCondition"](test.input); + expect(condition).toEqual(test.expected); + }); + }); + }); +}); diff --git a/src/app/shared/modules/filters/pid-filter-startsWith.component.ts b/src/app/shared/modules/filters/pid-filter-startsWith.component.ts new file mode 100644 index 000000000..5fbfe7321 --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter-startsWith.component.ts @@ -0,0 +1,15 @@ +import { PidFilterComponent } from "./pid-filter.component"; +import { Component } from "@angular/core"; + +@Component({ + selector: "app-pid-startsWith-filter", + templateUrl: "./pid-filter-startsWith.component.html", + styleUrls: ["./pid-filter-startsWith.component.scss"], +}) +export class PidFilterStartsWithComponent extends PidFilterComponent { + static kLabel = "PID filter (Starts With)"; + + buildPidTermsCondition(terms: string): { $regex: string } { + return { $regex: `^${terms}` }; + } +} diff --git a/src/app/shared/modules/filters/pid-filter.component.html b/src/app/shared/modules/filters/pid-filter.component.html new file mode 100644 index 000000000..3cd8cdf13 --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter.component.html @@ -0,0 +1,9 @@ + + {{ label }} + + diff --git a/src/app/shared/modules/filters/pid-filter.component.scss b/src/app/shared/modules/filters/pid-filter.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/pid-filter.component.spec.ts b/src/app/shared/modules/filters/pid-filter.component.spec.ts new file mode 100644 index 000000000..9a847223d --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter.component.spec.ts @@ -0,0 +1,130 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, + fakeAsync, + tick, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { setPidTermsFilterAction } from "state-management/actions/datasets.actions"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { MatDialogModule, MatDialog } from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { AppConfigService } from "app-config.service"; +import { PidFilterComponent } from "./pid-filter.component"; + +const getConfig = () => ({ + scienceSearchEnabled: false, +}); + +describe("PidFilterComponent", () => { + let component: PidFilterComponent; + let fixture: ComponentFixture; + + let store: MockStore; + let dispatchSpy; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot({}), + ], + declarations: [PidFilterComponent, SearchParametersDialogComponent], + providers: [AsyncPipe], + }); + TestBed.overrideComponent(PidFilterComponent, { + set: { + providers: [ + { + provide: AppConfigService, + useValue: { + getConfig, + }, + }, + ], + }, + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PidFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + beforeEach(inject([Store], (mockStore: MockStore) => { + store = mockStore; + })); + + afterEach(() => { + fixture.destroy(); + }); + + describe("#onPidInput()", () => { + it("should dispatch a SetSearchTermsAction", fakeAsync(() => { + dispatchSpy = spyOn(store, "dispatch"); + + const pid = "xxxxxx"; + const event = { target: { value: pid } }; + component.onPidInput(event); + + tick(500); //wait for it + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + setPidTermsFilterAction({ pid }), + ); + })); + }); + + describe("#buildPidTermsCondition()", () => { + const tests = [ + { input: "", method: "", expected: "" }, + { input: "1", method: "equals", expected: "1" }, + { input: "1", method: "", expected: "1" }, + ]; + + tests.forEach((test, index) => { + it(`should return correct condition for test case #${index + 1}`, () => { + component.appConfig.pidSearchMethod = test.method; + const condition = component.buildPidTermsCondition(test.input); + expect(condition).toEqual(test.expected); + }); + }); + }); +}); diff --git a/src/app/shared/modules/filters/pid-filter.component.ts b/src/app/shared/modules/filters/pid-filter.component.ts new file mode 100644 index 000000000..0d5e066b7 --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter.component.ts @@ -0,0 +1,65 @@ +import { Component, Input, OnDestroy } from "@angular/core"; +import { Store } from "@ngrx/store"; +import { Subject, Subscription } from "rxjs"; +import { + setPidTermsAction, + setPidTermsFilterAction, +} from "state-management/actions/datasets.actions"; +import { debounceTime, distinctUntilChanged, skipWhile } from "rxjs/operators"; +import { AppConfigService } from "app-config.service"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { getFilterLabel } from "./utils"; + +@Component({ + selector: "app-pid-filter", + templateUrl: `./pid-filter.component.html`, + styleUrls: [`./pid-filter.component.scss`], +}) +export class PidFilterComponent + extends ClearableInputComponent + implements OnDestroy +{ + private pidSubject = new Subject(); + private subscription: Subscription; + + appConfig = this.appConfigService.getConfig(); + + constructor( + public appConfigService: AppConfigService, + private store: Store, + ) { + super(); + this.subscription = this.pidSubject + .pipe(debounceTime(500)) + .subscribe((pid) => { + const condition = !pid ? "" : this.buildPidTermsCondition(pid); + this.store.dispatch(setPidTermsFilterAction({ pid: condition })); + }); + } + + get label() { + return getFilterLabel((this.constructor as typeof PidFilterComponent).name); + } + + buildPidTermsCondition(terms: string): string | { $regex: string } { + return terms; + } + + ngOnDestroy() { + // Unsubscribe to avoid memory leaks + this.subscription.unsubscribe(); + this.pidSubject.complete(); + } + + onPidInput(event: any) { + const pid = (event.target as HTMLInputElement).value; + this.pidSubject.next(pid); + } + + @Input() + set clear(value: boolean) { + super.clear = value; + + if (value) this.store.dispatch(setPidTermsAction({ pid: "" })); + } +} diff --git a/src/app/shared/modules/filters/text-filter.component.html b/src/app/shared/modules/filters/text-filter.component.html new file mode 100644 index 000000000..db628b284 --- /dev/null +++ b/src/app/shared/modules/filters/text-filter.component.html @@ -0,0 +1,10 @@ + + {{ label }} + + diff --git a/src/app/shared/modules/filters/text-filter.component.scss b/src/app/shared/modules/filters/text-filter.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/text-filter.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/text-filter.component.spec.ts b/src/app/shared/modules/filters/text-filter.component.spec.ts new file mode 100644 index 000000000..12416345b --- /dev/null +++ b/src/app/shared/modules/filters/text-filter.component.spec.ts @@ -0,0 +1,112 @@ +import { isDevMode, NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, + fakeAsync, + tick, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { + setSearchTermsAction, + setTextFilterAction, +} from "state-management/actions/datasets.actions"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { MatDialogModule } from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { TextFilterComponent } from "./text-filter.component"; + +describe("TextFilterComponent", () => { + let component: TextFilterComponent; + let fixture: ComponentFixture; + + let store: MockStore; + let dispatchSpy; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot( + {}, + { + runtimeChecks: { + strictActionImmutability: false, + strictActionSerializability: false, + strictActionTypeUniqueness: false, + strictActionWithinNgZone: false, + strictStateImmutability: false, + strictStateSerializability: false, + }, + }, + ), + ], + declarations: [TextFilterComponent, SearchParametersDialogComponent], + providers: [AsyncPipe], + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TextFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + beforeEach(inject([Store], (mockStore: MockStore) => { + store = mockStore; + })); + + afterEach(() => { + fixture.destroy(); + }); + + describe("#textSearchChanged()", () => { + it("should dispatch a SetSearchTermsAction", fakeAsync(() => { + dispatchSpy = spyOn(store, "dispatch"); + + const terms = "test"; + const event = { target: { value: terms } }; + component.textSearchChanged(event); + + tick(500); //wait for it + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + setTextFilterAction({ text: terms }), + ); + })); + }); +}); diff --git a/src/app/shared/modules/filters/text-filter.component.ts b/src/app/shared/modules/filters/text-filter.component.ts new file mode 100644 index 000000000..fd77eb764 --- /dev/null +++ b/src/app/shared/modules/filters/text-filter.component.ts @@ -0,0 +1,48 @@ +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { Store } from "@ngrx/store"; +import { setTextFilterAction } from "state-management/actions/datasets.actions"; +import { debounceTime, distinctUntilChanged, skipWhile } from "rxjs/operators"; +import { Subject, Subscription } from "rxjs"; +import { getFilterLabel } from "./utils"; + +@Component({ + selector: "app-text-filter", + templateUrl: "text-filter.component.html", + styleUrls: ["text-filter.component.scss"], +}) +export class TextFilterComponent + extends ClearableInputComponent + implements OnDestroy +{ + private textSubject = new Subject(); + + subscription: Subscription; + + constructor(private store: Store) { + super(); + this.subscription = this.textSubject + .pipe( + skipWhile((terms) => terms === ""), + debounceTime(500), + distinctUntilChanged(), + ) + .subscribe((terms) => { + this.store.dispatch(setTextFilterAction({ text: terms })); + }); + } + + get label() { + return getFilterLabel(this.constructor.name); + } + + textSearchChanged(event: any) { + const pid = (event.target as HTMLInputElement).value; + this.textSubject.next(pid); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + this.textSubject.complete(); + } +} diff --git a/src/app/shared/modules/filters/type-filter.component.html b/src/app/shared/modules/filters/type-filter.component.html new file mode 100644 index 000000000..c9b6a1640 --- /dev/null +++ b/src/app/shared/modules/filters/type-filter.component.html @@ -0,0 +1,30 @@ + + {{ label }} + + {{ type }}cancel + + + + + + + {{ getFacetId(fc, "No Type") }} | + {{ getFacetCount(fc) }} + + + diff --git a/src/app/shared/modules/filters/type-filter.component.scss b/src/app/shared/modules/filters/type-filter.component.scss new file mode 100644 index 000000000..9c04bc08b --- /dev/null +++ b/src/app/shared/modules/filters/type-filter.component.scss @@ -0,0 +1,3 @@ +.mat-mdc-form-field { + width: 100%; +} diff --git a/src/app/shared/modules/filters/type-filter.component.spec.ts b/src/app/shared/modules/filters/type-filter.component.spec.ts new file mode 100644 index 000000000..a157ef39c --- /dev/null +++ b/src/app/shared/modules/filters/type-filter.component.spec.ts @@ -0,0 +1,137 @@ +import { isDevMode, NO_ERRORS_SCHEMA } from "@angular/core"; +import { + ComponentFixture, + TestBed, + inject, + waitForAsync, +} from "@angular/core/testing"; +import { Store, StoreModule } from "@ngrx/store"; +import { MockStore } from "shared/MockStubs"; + +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { + addTypeFilterAction, + removeTypeFilterAction, +} from "state-management/actions/datasets.actions"; +import { SharedScicatFrontendModule } from "shared/shared.module"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { MatDialogModule, MatDialog } from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatSelectModule } from "@angular/material/select"; +import { SearchParametersDialogComponent } from "shared/modules/search-parameters-dialog/search-parameters-dialog.component"; +import { AsyncPipe } from "@angular/common"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule, MatOptionModule } from "@angular/material/core"; +import { MatCardModule } from "@angular/material/card"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { TypeFilterComponent } from "./type-filter.component"; + +describe("TypeFilterComponent", () => { + let component: TypeFilterComponent; + let fixture: ComponentFixture; + + let store: MockStore; + let dispatchSpy; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + BrowserAnimationsModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatNativeDateModule, + ReactiveFormsModule, + SharedScicatFrontendModule, + StoreModule.forRoot( + {}, + { + runtimeChecks: { + strictActionImmutability: false, + strictActionSerializability: false, + strictActionTypeUniqueness: false, + strictActionWithinNgZone: false, + strictStateImmutability: false, + strictStateSerializability: false, + }, + }, + ), + ], + declarations: [TypeFilterComponent, SearchParametersDialogComponent], + providers: [AsyncPipe], + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TypeFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + beforeEach(inject([Store], (mockStore: MockStore) => { + store = mockStore; + })); + + afterEach(() => { + fixture.destroy(); + }); + + describe("#onTypeInput()", () => { + it("should call next on typeInput$", () => { + const nextSpy = spyOn(component.typeInput$, "next"); + + const event = { + target: { + value: "type", + }, + }; + + component.onTypeInput(event); + + expect(nextSpy).toHaveBeenCalledOnceWith(event.target.value); + }); + }); + + describe("#typeSelected()", () => { + it("should dispatch an AddTypeFilterAction", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const datasetType = "string"; + component.typeSelected(datasetType); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + addTypeFilterAction({ datasetType }), + ); + }); + }); + + describe("#typeRemoved()", () => { + it("should dispatch a RemoveTypeFilterAction", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const datasetType = "string"; + component.typeRemoved(datasetType); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + removeTypeFilterAction({ datasetType }), + ); + }); + }); +}); diff --git a/src/app/shared/modules/filters/type-filter.component.ts b/src/app/shared/modules/filters/type-filter.component.ts new file mode 100644 index 000000000..cd9ee2199 --- /dev/null +++ b/src/app/shared/modules/filters/type-filter.component.ts @@ -0,0 +1,61 @@ +import { Component } from "@angular/core"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { + createSuggestionObserver, + getFacetCount, + getFacetId, + getFilterLabel, +} from "./utils"; +import { + selectTypeFacetCounts, + selectTypeFilter, +} from "state-management/selectors/datasets.selectors"; +import { Store } from "@ngrx/store"; +import { BehaviorSubject } from "rxjs"; +import { + addTypeFilterAction, + removeTypeFilterAction, +} from "state-management/actions/datasets.actions"; + +@Component({ + selector: "app-type-filter", + templateUrl: "type-filter.component.html", + styleUrls: ["type-filter.component.scss"], +}) +export class TypeFilterComponent extends ClearableInputComponent { + protected readonly getFacetCount = getFacetCount; + protected readonly getFacetId = getFacetId; + + typeFacetCounts$ = this.store.select(selectTypeFacetCounts); + + typeFilter$ = this.store.select(selectTypeFilter); + typeInput$ = new BehaviorSubject(""); + + typeSuggestions$ = createSuggestionObserver( + this.typeFacetCounts$, + this.typeInput$, + this.typeFilter$, + ); + + constructor(private store: Store) { + super(); + } + + get label() { + return getFilterLabel(this.constructor.name); + } + + onTypeInput(event: any) { + const value = (event.target).value; + this.typeInput$.next(value); + } + + typeSelected(type: string) { + this.store.dispatch(addTypeFilterAction({ datasetType: type })); + this.typeInput$.next(""); + } + + typeRemoved(type: string) { + this.store.dispatch(removeTypeFilterAction({ datasetType: type })); + } +} diff --git a/src/app/shared/modules/filters/utils.spec.ts b/src/app/shared/modules/filters/utils.spec.ts new file mode 100644 index 000000000..a241259b8 --- /dev/null +++ b/src/app/shared/modules/filters/utils.spec.ts @@ -0,0 +1,39 @@ +import { FacetCount } from "../../../state-management/state/datasets.store"; +import { getFacetCount, getFacetId } from "./utils"; + +describe("#getFacetId()", () => { + it("should return the FacetCount id if present", () => { + const facetCount: FacetCount = { + _id: "test1", + count: 0, + }; + const fallback = "test2"; + + const id = getFacetId(facetCount, fallback); + + expect(id).toEqual("test1"); + }); + + it("should return the FacetCount id if present", () => { + const facetCount: FacetCount = { + count: 0, + }; + const fallback = "test"; + + const id = getFacetId(facetCount, fallback); + + expect(id).toEqual(fallback); + }); +}); + +describe("#getFacetCount()", () => { + it("should return the FacetCount", () => { + const facetCount: FacetCount = { + count: 0, + }; + + const count = getFacetCount(facetCount); + + expect(count).toEqual(facetCount.count); + }); +}); diff --git a/src/app/shared/modules/filters/utils.ts b/src/app/shared/modules/filters/utils.ts new file mode 100644 index 000000000..bfbab0782 --- /dev/null +++ b/src/app/shared/modules/filters/utils.ts @@ -0,0 +1,48 @@ +import { BehaviorSubject, combineLatest, Observable } from "rxjs"; +import { FacetCount } from "../../../state-management/state/datasets.store"; +import { map } from "rxjs/operators"; + +export function createSuggestionObserver( + facetCounts$: Observable, + input$: BehaviorSubject, + currentFilters$: Observable, +): Observable { + return combineLatest([facetCounts$, input$, currentFilters$]).pipe( + map(([counts, filterString, currentFilters]) => { + if (!counts) { + return []; + } + return counts.filter( + (count) => + typeof count._id === "string" && + count._id.toLowerCase().includes(filterString.toLowerCase()) && + currentFilters.indexOf(count._id) < 0, + ); + }), + ); +} + +export function getFacetId(facetCount: FacetCount, fallback = ""): string { + const id = facetCount._id; + return id ? String(id) : fallback; +} + +export function getFacetCount(facetCount: FacetCount): number { + return facetCount.count; +} + +const labelMap: Map = new Map([ + ["DateRangeFilterComponent", "Start Date - End Date"], + ["GroupFilterComponent", "Group"], + ["KeywordFilterComponent", "Keyword"], + ["LocationFilterComponent", "Location"], + ["PidFilterStartsWithComponent", "PID filter (Starts With)"], + ["PidFilterComponent", "PID filter (Equals)"], + ["PidFilterContainsComponent", "PID filter (Contains)"], + ["TextFilterComponent", "Text filter"], + ["TypeFilterComponent", "Type filter"], +]); + +export function getFilterLabel(type: string): string { + return labelMap.get(type) || "Default Label"; +} diff --git a/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.html b/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.html index 9be96519c..61c2149a3 100644 --- a/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.html +++ b/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.html @@ -87,7 +87,7 @@

Add Characteristic

color="primary" [disabled]="isInvalid()" > - Add + Apply diff --git a/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.spec.ts b/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.spec.ts index 5865322f8..321d1102e 100644 --- a/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.spec.ts +++ b/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.spec.ts @@ -17,6 +17,7 @@ import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { AppConfigService } from "app-config.service"; import { SearchParametersDialogComponent } from "./search-parameters-dialog.component"; +import { ScientificCondition } from "../../../state-management/models"; const getConfig = () => ({ scienceSearchUnitsEnabled: true, @@ -75,7 +76,7 @@ describe("SearchParametersDialogComponent", () => { rhs: 5, unit: "gram", }; - component.parametersForm.setValue(formValues); + component.parametersForm.setValue(formValues as ScientificCondition); component.add(); @@ -114,7 +115,7 @@ describe("SearchParametersDialogComponent", () => { rhs: 5, unit: "", }; - component.parametersForm.setValue(formValues); + component.parametersForm.setValue(formValues as ScientificCondition); component.toggleUnitField(); @@ -127,7 +128,7 @@ describe("SearchParametersDialogComponent", () => { rhs: 5, unit: "", }; - component.parametersForm.setValue(formValues); + component.parametersForm.setValue(formValues as ScientificCondition); component.toggleUnitField(); @@ -140,7 +141,7 @@ describe("SearchParametersDialogComponent", () => { rhs: 5, unit: "", }; - component.parametersForm.setValue(formValues); + component.parametersForm.setValue(formValues as ScientificCondition); component.toggleUnitField(); @@ -156,7 +157,7 @@ describe("SearchParametersDialogComponent", () => { rhs: 5, unit: "", }; - component.parametersForm.setValue(formValues); + component.parametersForm.setValue(formValues as ScientificCondition); const isInvalid = component.isInvalid(); @@ -169,7 +170,7 @@ describe("SearchParametersDialogComponent", () => { rhs: "test", unit: "gram", }; - component.parametersForm.setValue(formValues); + component.parametersForm.setValue(formValues as ScientificCondition); const isInvalid = component.isInvalid(); @@ -182,7 +183,7 @@ describe("SearchParametersDialogComponent", () => { rhs: "", unit: "gram", }; - component.parametersForm.setValue(formValues); + component.parametersForm.setValue(formValues as ScientificCondition); const isInvalid = component.isInvalid(); @@ -195,7 +196,7 @@ describe("SearchParametersDialogComponent", () => { rhs: 5, unit: "gram", }; - component.parametersForm.setValue(formValues); + component.parametersForm.setValue(formValues as ScientificCondition); const isInvalid = component.isInvalid(); diff --git a/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.ts b/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.ts index e5af6d850..d2680dc1a 100644 --- a/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.ts +++ b/src/app/shared/modules/search-parameters-dialog/search-parameters-dialog.component.ts @@ -1,9 +1,10 @@ -import { Component, Inject } from "@angular/core"; +import { ChangeDetectorRef, Component, Inject } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; import { MatDialogRef, MAT_DIALOG_DATA } from "@angular/material/dialog"; import { AppConfigService } from "app-config.service"; import { map, startWith } from "rxjs/operators"; import { UnitsService } from "shared/services/units.service"; +import { ScientificCondition } from "../../../state-management/models"; @Component({ selector: "search-parameters-dialog", @@ -17,12 +18,15 @@ export class SearchParametersDialogComponent { units: string[] = []; parametersForm = new FormGroup({ - lhs: new FormControl("", [Validators.required, Validators.minLength(2)]), - relation: new FormControl("GREATER_THAN", [ + lhs: new FormControl(this.data.condition?.lhs || "", [ + Validators.required, + Validators.minLength(2), + ]), + relation: new FormControl(this.data.condition?.relation || "GREATER_THAN", [ Validators.required, Validators.minLength(9), ]), - rhs: new FormControl("", [ + rhs: new FormControl(this.data.condition?.rhs || "", [ Validators.required, Validators.minLength(1), ]), @@ -49,10 +53,18 @@ export class SearchParametersDialogComponent { constructor( public appConfigService: AppConfigService, - @Inject(MAT_DIALOG_DATA) public data: { parameterKeys: string[] }, + @Inject(MAT_DIALOG_DATA) + public data: { + parameterKeys: string[]; + condition?: ScientificCondition; + }, public dialogRef: MatDialogRef, private unitsService: UnitsService, - ) {} + ) { + if (this.data.condition?.lhs) { + this.getUnits(this.data.condition.lhs); + } + } add = (): void => { const { lhs, relation, unit } = this.parametersForm.value; diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 55d1ac1af..6ed28f27f 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -16,6 +16,7 @@ import { CommonModule } from "@angular/common"; import { SharedTableModule } from "./modules/shared-table/shared-table.module"; import { ScicatDataService } from "./services/scicat-data-service"; import { ScientificMetadataTreeModule } from "./modules/scientific-metadata-tree/scientific-metadata-tree.modules"; +import { FiltersModule } from "./modules/filters/filters.module"; @NgModule({ imports: [ BreadcrumbModule, @@ -48,6 +49,7 @@ import { ScientificMetadataTreeModule } from "./modules/scientific-metadata-tree FormsModule, SharedTableModule, ScientificMetadataTreeModule, + FiltersModule, ], }) export class SharedScicatFrontendModule {} diff --git a/src/app/state-management/actions/datasets.actions.spec.ts b/src/app/state-management/actions/datasets.actions.spec.ts index c09c38afa..9cb46268d 100644 --- a/src/app/state-management/actions/datasets.actions.spec.ts +++ b/src/app/state-management/actions/datasets.actions.spec.ts @@ -731,11 +731,16 @@ describe("Dataset Actions", () => { describe("removeScientificConditionAction", () => { it("should create an action", () => { - const index = 0; - const action = fromActions.removeScientificConditionAction({ index }); + const condition: ScientificCondition = { + lhs: "lhsTest", + relation: "LESS_THAN", + rhs: 5, + unit: "s", + }; + const action = fromActions.removeScientificConditionAction({ condition }); expect({ ...action }).toEqual({ type: "[Dataset] Remove Scientific Condition", - index, + condition, }); }); }); diff --git a/src/app/state-management/actions/datasets.actions.ts b/src/app/state-management/actions/datasets.actions.ts index 8d31223ee..5c6ce0d93 100644 --- a/src/app/state-management/actions/datasets.actions.ts +++ b/src/app/state-management/actions/datasets.actions.ts @@ -324,7 +324,7 @@ export const addScientificConditionAction = createAction( ); export const removeScientificConditionAction = createAction( "[Dataset] Remove Scientific Condition", - props<{ index: number }>(), + props<{ condition: ScientificCondition }>(), ); export const clearDatasetsStateAction = createAction("[Dataset] Clear State"); diff --git a/src/app/state-management/actions/user.actions.ts b/src/app/state-management/actions/user.actions.ts index c38083ddf..f04c262a0 100644 --- a/src/app/state-management/actions/user.actions.ts +++ b/src/app/state-management/actions/user.actions.ts @@ -2,6 +2,10 @@ import { HttpErrorResponse } from "@angular/common/http"; import { createAction, props } from "@ngrx/store"; import { User, AccessToken, UserIdentity, UserSetting } from "shared/sdk"; import { Message, Settings, TableColumn } from "state-management/models"; +import { + ConditionConfig, + FilterConfig, +} from "../../shared/modules/filters/filters.module"; export const setDatasetTableColumnsAction = createAction( "[User] Set Dataset Table Columns", @@ -160,3 +164,13 @@ export const saveSettingsAction = createAction( export const loadingAction = createAction("[User] Loading"); export const loadingCompleteAction = createAction("[User] Loading Complete"); + +export const updateFilterConfigs = createAction( + "[User] Update Filter Configs", + props<{ filterConfigs: FilterConfig[] }>(), +); + +export const updateConditionsConfigs = createAction( + "[User] Update Conditions Configs", + props<{ conditionConfigs: ConditionConfig[] }>(), +); diff --git a/src/app/state-management/effects/datasets.effects.spec.ts b/src/app/state-management/effects/datasets.effects.spec.ts index b2cef6a19..bf9563fbc 100644 --- a/src/app/state-management/effects/datasets.effects.spec.ts +++ b/src/app/state-management/effects/datasets.effects.spec.ts @@ -247,8 +247,14 @@ describe("DatasetEffects", () => { describe("ofType removeScientificConditionAction", () => { it("should result in a fetchMetadataKeysAction", () => { + const condition: ScientificCondition = { + lhs: "test", + relation: "EQUAL_TO_NUMERIC", + rhs: 1000, + unit: "s", + }; const action = fromActions.removeScientificConditionAction({ - index: 0, + condition, }); const outcome = fromActions.fetchMetadataKeysAction(); diff --git a/src/app/state-management/reducers/datasets.reducer.spec.ts b/src/app/state-management/reducers/datasets.reducer.spec.ts index 5b7fbe4ca..2185bf67d 100644 --- a/src/app/state-management/reducers/datasets.reducer.spec.ts +++ b/src/app/state-management/reducers/datasets.reducer.spec.ts @@ -526,9 +526,7 @@ describe("DatasetsReducer", () => { expect(sta.filters.scientific).toContain(condition); - const index = 0; - - const action = fromActions.removeScientificConditionAction({ index }); + const action = fromActions.removeScientificConditionAction({ condition }); const state = fromDatasets.datasetsReducer(initialDatasetState, action); expect(state.filters.scientific).not.toContain(condition); diff --git a/src/app/state-management/reducers/datasets.reducer.ts b/src/app/state-management/reducers/datasets.reducer.ts index cd09424ab..7c6783d88 100644 --- a/src/app/state-management/reducers/datasets.reducer.ts +++ b/src/app/state-management/reducers/datasets.reducer.ts @@ -462,9 +462,10 @@ const reducer = createReducer( ), on( fromActions.removeScientificConditionAction, - (state, { index }): DatasetState => { + (state, { condition }): DatasetState => { const currentFilters = state.filters; const scientific = [...currentFilters.scientific]; + const index = scientific.indexOf(condition); scientific.splice(index, 1); const filters = { ...currentFilters, scientific }; return { ...state, filters }; diff --git a/src/app/state-management/reducers/user.reducer.ts b/src/app/state-management/reducers/user.reducer.ts index 0f36625a8..13c16002a 100644 --- a/src/app/state-management/reducers/user.reducer.ts +++ b/src/app/state-management/reducers/user.reducer.ts @@ -222,6 +222,20 @@ const reducer = createReducer( isLoading: false, }), ), + on( + fromActions.updateFilterConfigs, + (state, { filterConfigs }): UserState => ({ + ...state, + filters: filterConfigs, + }), + ), + on( + fromActions.updateConditionsConfigs, + (state, { conditionConfigs }): UserState => ({ + ...state, + conditions: conditionConfigs, + }), + ), ); export const userReducer = (state: UserState | undefined, action: Action) => { diff --git a/src/app/state-management/selectors/user.selectors.spec.ts b/src/app/state-management/selectors/user.selectors.spec.ts index 254028e2b..d7f5cd503 100644 --- a/src/app/state-management/selectors/user.selectors.spec.ts +++ b/src/app/state-management/selectors/user.selectors.spec.ts @@ -3,6 +3,15 @@ import * as fromSelectors from "./user.selectors"; import { UserState } from "../state/user.store"; import { User, UserIdentity, Settings } from "../models"; import { AccessToken } from "shared/sdk"; +import { LocationFilterComponent } from "../../shared/modules/filters/location-filter.component"; +import { PidFilterComponent } from "../../shared/modules/filters/pid-filter.component"; +import { PidFilterContainsComponent } from "../../shared/modules/filters/pid-filter-contains.component"; +import { PidFilterStartsWithComponent } from "../../shared/modules/filters/pid-filter-startsWith.component"; +import { GroupFilterComponent } from "../../shared/modules/filters/group-filter.component"; +import { TypeFilterComponent } from "../../shared/modules/filters/type-filter.component"; +import { KeywordFilterComponent } from "../../shared/modules/filters/keyword-filter.component"; +import { DateRangeFilterComponent } from "../../shared/modules/filters/date-range-filter.component"; +import { TextFilterComponent } from "../../shared/modules/filters/text-filter.component"; const user = new User({ id: "testId", @@ -68,6 +77,20 @@ const initialUserState: UserState = { isLoading: false, columns: [{ name: "datasetName", order: 1, type: "standard", enabled: true }], + + filters: [ + { type: "LocationFilterComponent", visible: true }, + { type: "PidFilterComponent", visible: true }, + { type: "PidFilterContainsComponent", visible: false }, + { type: "PidFilterStartsWithComponent", visible: false }, + { type: "GroupFilterComponent", visible: true }, + { type: "TypeFilterComponent", visible: true }, + { type: "KeywordFilterComponent", visible: true }, + { type: "DateRangeFilterComponent", visible: true }, + { type: "TextFilterComponent", visible: true }, + ], + + conditions: [], }; describe("User Selectors", () => { diff --git a/src/app/state-management/selectors/user.selectors.ts b/src/app/state-management/selectors/user.selectors.ts index 643057e67..f03a901c1 100644 --- a/src/app/state-management/selectors/user.selectors.ts +++ b/src/app/state-management/selectors/user.selectors.ts @@ -94,6 +94,16 @@ export const selectColumns = createSelector( (state) => state.columns, ); +export const selectFilters = createSelector( + selectUserState, + (state) => state.filters, +); + +export const selectConditions = createSelector( + selectUserState, + (state) => state.conditions, +); + export const selectSampleDialogPageViewModel = createSelector( selectCurrentUser, selectProfile, diff --git a/src/app/state-management/state/user.store.ts b/src/app/state-management/state/user.store.ts index 2de8988ab..747a7b367 100644 --- a/src/app/state-management/state/user.store.ts +++ b/src/app/state-management/state/user.store.ts @@ -1,5 +1,18 @@ import { Settings, Message, User, TableColumn } from "../models"; import { AccessToken } from "shared/sdk"; +import { + ConditionConfig, + FilterConfig, +} from "../../shared/modules/filters/filters.module"; +import { LocationFilterComponent } from "../../shared/modules/filters/location-filter.component"; +import { PidFilterComponent } from "../../shared/modules/filters/pid-filter.component"; +import { PidFilterContainsComponent } from "../../shared/modules/filters/pid-filter-contains.component"; +import { PidFilterStartsWithComponent } from "../../shared/modules/filters/pid-filter-startsWith.component"; +import { GroupFilterComponent } from "../../shared/modules/filters/group-filter.component"; +import { TypeFilterComponent } from "../../shared/modules/filters/type-filter.component"; +import { KeywordFilterComponent } from "../../shared/modules/filters/keyword-filter.component"; +import { DateRangeFilterComponent } from "../../shared/modules/filters/date-range-filter.component"; +import { TextFilterComponent } from "../../shared/modules/filters/text-filter.component"; // NOTE It IS ok to make up a state of other sub states export interface UserState { @@ -19,6 +32,10 @@ export interface UserState { isLoading: boolean; columns: TableColumn[]; + + filters: FilterConfig[]; + + conditions: ConditionConfig[]; } export const initialUserState: UserState = { @@ -49,4 +66,17 @@ export const initialUserState: UserState = { isLoading: false, columns: [], + + filters: [ + { type: "LocationFilterComponent", visible: true }, + { type: "PidFilterComponent", visible: true }, + { type: "PidFilterContainsComponent", visible: false }, + { type: "PidFilterStartsWithComponent", visible: false }, + { type: "GroupFilterComponent", visible: true }, + { type: "TypeFilterComponent", visible: true }, + { type: "KeywordFilterComponent", visible: true }, + { type: "DateRangeFilterComponent", visible: true }, + ], + + conditions: [], }; diff --git a/src/assets/config.json b/src/assets/config.json index 228e405b8..759ea9507 100644 --- a/src/assets/config.json +++ b/src/assets/config.json @@ -140,7 +140,8 @@ "label": "Download All", "files": "all", "mat_icon": "download", - "url": "", + "type": "form", + "url": "https://www.scicat.info/download/all", "target": "_blank", "enabled": "#SizeLimit", "authorization": ["#datasetAccess", "#datasetPublic"] @@ -151,7 +152,8 @@ "label": "Download Selected", "files": "selected", "mat_icon": "download", - "url": "", + "type": "form", + "url": "https://www.scicat.info/download/selected", "target": "_blank", "enabled": "#Selected && #SizeLimit", "authorization": ["#datasetAccess", "#datasetPublic"] @@ -162,7 +164,8 @@ "label": "Notebook All", "files": "all", "icon": "/assets/icons/jupyter_logo.png", - "url": "", + "type": "form", + "url": "https://www.scicat.info/notebook/all", "target": "_blank", "authorization": ["#datasetAccess", "#datasetPublic"] }, @@ -172,7 +175,8 @@ "label": "Notebook Selected", "files": "selected", "icon": "/assets/icons/jupyter_logo.png", - "url": "", + "type": "form", + "url": "https://www.scicat.info/notebook/selected", "target": "_blank", "enabled": "#Selected", "authorization": ["#datasetAccess", "#datasetPublic"]