diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4507bd9ca..bb998bff3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Checkout Workbench Client - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: lfs: true fetch-depth: 50 @@ -48,4 +48,4 @@ jobs: shell: pwsh run: ./scripts/build_docker.ps1 -github_actor '${{ github.actor }}' - -release_tag '${{ inputs.release_tag }}${{ github.event.inputs.release_tag }}' + -release_tag '${{ inputs.release_tag }}' diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index c38b35142..c09747594 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -12,13 +12,13 @@ jobs: steps: - name: Checkout Baw Client - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: repository: QutEcoacoustics/baw-client ref: migration - name: Install Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: "12" cache: "npm" @@ -66,7 +66,7 @@ jobs: run: npm run build -- --production - name: Publish build files - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: path: bin/ name: baw-client @@ -91,16 +91,16 @@ jobs: - FirefoxHeadless steps: - name: Checkout Workbench Client - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Install Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Download Artifacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v4 with: path: baw-client @@ -115,9 +115,9 @@ jobs: - name: Upload Unit Test Results if: always() - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: - name: Unit Test Results (OS ${{ matrix.os }}) + name: Unit Test Results (OS ${{ matrix.os }} ${{ matrix.browsers }}) path: TESTS-*.xml - name: Lint project @@ -153,7 +153,7 @@ jobs: steps: - name: Checkout Workbench Client - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: lfs: true @@ -175,7 +175,7 @@ jobs: steps: - name: Checkout Workbench Client - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: lfs: true @@ -183,7 +183,7 @@ jobs: run: git lfs checkout - name: Install Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" @@ -195,7 +195,7 @@ jobs: run: npm run build - name: Publish build files - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: path: ./dist/workbench-client/ name: workbench-client @@ -211,7 +211,7 @@ jobs: steps: - name: Download Artifacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v4 with: path: artifacts - name: Publish Unit Test Results diff --git a/angular.json b/angular.json index d80786940..cd395b737 100644 --- a/angular.json +++ b/angular.json @@ -23,11 +23,17 @@ "main": "src/main.ts", "polyfills": "src/polyfills.ts", "tsConfig": "tsconfig.app.json", - "aot": true, + "aot": false, + "optimization": false, "assets": [ "src/favicon.ico", "src/assets", - "src/manifest.json" + "src/manifest.json", + { + "glob": "**/*", + "input": "node_modules/@ecoacoustics/web-components/dist", + "output": "@ecoacoustics/web-components" + } ], "styles": [ "src/styles.scss" @@ -38,7 +44,6 @@ "node_modules" ] }, - "scripts": [], "sourceMap": true, "allowedCommonJsDependencies": [ "zone.js/dist/zone-error", @@ -48,6 +53,7 @@ }, "configurations": { "production": { + "aot": true, "fileReplacements": [ { "replace": "src/environments/environment.ts", @@ -58,7 +64,6 @@ "outputHashing": "all", "sourceMap": false, "extractLicenses": true, - "buildOptimizer": true, "budgets": [ { "type": "initial", @@ -75,15 +80,20 @@ "optimization": false, "outputHashing": "all", "sourceMap": true, - "extractLicenses": true, - "buildOptimizer": false + "extractLicenses": true } } }, "serve": { "builder": "@angular-devkit/build-angular:dev-server", "options": { - "buildTarget": "workbench-client:build" + "buildTarget": "workbench-client:build", + "headers": { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + "Cross-Origin-Resource-Policy": "cross-origin", + "Access-Control-Allow-Origin": "*" + } }, "configurations": { "production": { @@ -92,13 +102,16 @@ } }, "test": { - "builder": "@angular-devkit/build-angular:karma", + "builder": "@angular-builders/custom-webpack:karma", "options": { "codeCoverage": true, "main": "src/test.ts", "polyfills": "src/polyfills.ts", "tsConfig": "tsconfig.spec.json", "karmaConfig": "karma.conf.js", + "customWebpackConfig": { + "path": "./webpack.config.js" + }, "assets": [ "src/assets", "src/manifest.json" diff --git a/karma.conf.js b/karma.conf.js index 0edd147b6..7e4e5fb85 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -1,6 +1,12 @@ // Karma configuration file, see link for more information // https://karma-runner.github.io/6.3/config/configuration-file.html +var maxSigned32BitInt = Math.pow(2, 31) - 1; + +// GitHub Actions sets the CI environment variable to true +// see: https://github.blog/changelog/2020-04-15-github-actions-sets-the-ci-environment-variable-to-true +var isCi = process.env.CI === "true"; + module.exports = function (config) { config.set({ basePath: "", @@ -24,7 +30,18 @@ module.exports = function (config) { reports: ["html", "lcovonly", "text-summary", "cobertura"], fixWebpackSourcePaths: true, }, - browserDisconnectTimeout: 30000, + // when running Karma locally, we do not want it to timeout + // if we added a timeout to locally run tests, we would only have the + // timeout duration of time to debug why the tests failed + // + // the timeout is a signed 32 bit integer and does not accept a JS Infinity + // therefore, we set the timeout to the maximum signed 32 bit integer value + // this gives us ~596 hours of time to debug the tests + // + // we reset the timeout to 30 seconds when running in CI so that CI tests + // do not hang indefinitely due to a test failure + browserDisconnectTimeout: isCi ? 30000 : maxSigned32BitInt, + browserNoActivityTimeout: isCi ? 30000 : maxSigned32BitInt, browserDisconnectTolerance: 3, browserConsoleLogOptions: { level: "debug", @@ -39,6 +56,20 @@ module.exports = function (config) { browsers: ["Chrome"], singleRun: false, restartOnFileChange: true, + // serve these files through the karma server + // by serving these files through the karma server we can fetch and test + // against real files during testing + files: [ + { pattern: "src/assets/test-assets/*", included: false, served: true }, + { + // TODO: this should expose all of node_modules through the karma server + // so that we can dynamically import anything from node_modules + // without adding it to this list + pattern: __dirname + "/node_modules/@ecoacoustics/web-components/**", + included: false, + served: true, + }, + ], viewport: { // Ensure you modify the viewports object (@test/helpers/general.ts) to match // the values declared here. diff --git a/package-lock.json b/package-lock.json index acfdc8f05..666999aa3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@angular/common": "17.1.2", "@angular/compiler": "17.1.2", "@angular/core": "17.1.2", + "@angular/elements": "^17.1.2", "@angular/forms": "17.1.2", "@angular/google-maps": "^17.0.5", "@angular/localize": "17.1.2", @@ -21,6 +22,7 @@ "@angular/platform-server": "17.1.2", "@angular/router": "17.1.2", "@angular/ssr": "^17.1.2", + "@ecoacoustics/web-components": "1.2.0", "@fortawesome/angular-fontawesome": "^0.14.1", "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1", @@ -60,6 +62,7 @@ "zone.js": "~0.14.3" }, "devDependencies": { + "@angular-builders/custom-webpack": "^17.0.2", "@angular-devkit/build-angular": "17.2.2", "@angular-eslint/builder": "17.2.0", "@angular-eslint/eslint-plugin": "17.2.0", @@ -131,6 +134,54 @@ "node": ">=6.0.0" } }, + "node_modules/@angular-builders/common": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@angular-builders/common/-/common-1.0.2.tgz", + "integrity": "sha512-lUusRq6jN1It5LcUTLS6Q+AYAYGTo/EEN8hV0M6Ek9qXzweAouJaSEnwv7p04/pD7yJTl0YOCbN79u+wGm3x4g==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "^17.1.0", + "ts-node": "^10.0.0", + "tsconfig-paths": "^4.1.0" + }, + "engines": { + "node": "^14.20.0 || ^16.13.0 || >=18.10.0" + } + }, + "node_modules/@angular-builders/common/node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@angular-builders/custom-webpack": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/@angular-builders/custom-webpack/-/custom-webpack-17.0.2.tgz", + "integrity": "sha512-K0jqdW5UdVIeKiZXO4nLiiiVt0g6PKJELdxgjsBGMtyRk+RLEY+pIp1061oy/Yf09nGYseZ7Mdx3XASYHQjNwA==", + "dev": true, + "dependencies": { + "@angular-builders/common": "1.0.2", + "@angular-devkit/architect": ">=0.1700.0 < 0.1800.0", + "@angular-devkit/build-angular": "^17.0.0", + "@angular-devkit/core": "^17.0.0", + "lodash": "^4.17.15", + "webpack-merge": "^5.7.3" + }, + "engines": { + "node": "^14.20.0 || ^16.13.0 || >=18.10.0" + }, + "peerDependencies": { + "@angular/compiler-cli": "^17.0.0" + } + }, "node_modules/@angular-devkit/architect": { "version": "0.1701.2", "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1701.2.tgz", @@ -856,6 +907,21 @@ "zone.js": "~0.14.0" } }, + "node_modules/@angular/elements": { + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/@angular/elements/-/elements-17.1.2.tgz", + "integrity": "sha512-+12VuI5sJtP2ylZe16Fhwl8r1qMD1njbl87U/1vqeS19CJvhMvsNDJmOpJl8oTfKHrO6O0k1QCszRrT3ZO4x6g==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0" + }, + "peerDependencies": { + "@angular/core": "17.1.2", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/forms": { "version": "17.1.2", "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-17.1.2.tgz", @@ -2753,6 +2819,14 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@ctrl/tinycolor": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.1.0.tgz", + "integrity": "sha512-WyOx8cJQ+FQus4Mm4uPIZA64gbk3Wxh0so5Lcii0aJifqwoVOlfFtorjLE0Hen4OYyHZMXDWqMmaQemBhgxFRQ==", + "engines": { + "node": ">=14" + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", @@ -2762,6 +2836,25 @@ "node": ">=10.0.0" } }, + "node_modules/@ecoacoustics/web-components": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ecoacoustics/web-components/-/web-components-1.2.0.tgz", + "integrity": "sha512-OWeJE8E4kd+bhcW6zuPKEv4yCKGnxlYrE6ATou4Pit0OR0HbnwcmnZ65OiyXdxEsZxlhbN9J/UqYAxAXEbwI6w==", + "dependencies": { + "@json2csv/plainjs": "^7.0.6", + "@lit-labs/preact-signals": "^1.0.2", + "@lit/context": "^1.1.1", + "@shoelace-style/shoelace": "^2.15.1", + "change-case": "^5.4.4", + "chroma-js": "^2.4.2", + "colorbrewer": "^1.5.6", + "csvtojson": "^2.0.10", + "fft-windowing-ts": "^0.2.7", + "lit": "^3.1.3", + "music-metadata-browser": "^2.5.10", + "prismjs": "^1.29.0" + } + }, "node_modules/@es-joy/jsdoccomment": { "version": "0.41.0", "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.41.0.tgz", @@ -3308,6 +3401,28 @@ "node": ">=14" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "dependencies": { + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.11.tgz", + "integrity": "sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" + }, "node_modules/@fortawesome/angular-fontawesome": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/@fortawesome/angular-fontawesome/-/angular-fontawesome-0.14.1.tgz", @@ -3594,12 +3709,64 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@json2csv/formatters": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@json2csv/formatters/-/formatters-7.0.6.tgz", + "integrity": "sha512-hjIk1H1TR4ydU5ntIENEPgoMGW+Q7mJ+537sDFDbsk+Y3EPl2i4NfFVjw0NJRgT+ihm8X30M67mA8AS6jPidSA==" + }, + "node_modules/@json2csv/plainjs": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@json2csv/plainjs/-/plainjs-7.0.6.tgz", + "integrity": "sha512-4Md7RPDCSYpmW1HWIpWBOqCd4vWfIqm53S3e/uzQ62iGi7L3r34fK/8nhOMEe+/eVfCx8+gdSCt1d74SlacQHw==", + "dependencies": { + "@json2csv/formatters": "^7.0.6", + "@streamparser/json": "^0.0.20" + } + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", "dev": true }, + "node_modules/@lit-labs/preact-signals": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lit-labs/preact-signals/-/preact-signals-1.0.2.tgz", + "integrity": "sha512-HFgIhqLB5IiNbvJxEN3+o6n9x/fNZo7pqfElG56NHrOFBsIFW7wswbp6hHeoGzATQDOB2ZmrH/VrRGYdcgl29g==", + "dependencies": { + "@preact/signals-core": "^1.3.0", + "lit": "^3.1.2" + } + }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.2.1.tgz", + "integrity": "sha512-wx4aBmgeGvFmOKucFKY+8VFJSYZxs9poN3SDNQFF6lT6NrQUnHiPB2PWz2sc4ieEcAaYYzN+1uWahEeTq2aRIQ==" + }, + "node_modules/@lit/context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.3.tgz", + "integrity": "sha512-Auh37F4S0PZM93HTDfZWs97mmzaQ7M3vnTc9YvxAGyP3UItSK/8Fs0vTOGT+njuvOwbKio/l8Cx/zWL4vkutpQ==", + "dependencies": { + "@lit/reactive-element": "^1.6.2 || ^2.0.0" + } + }, + "node_modules/@lit/react": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lit/react/-/react-1.0.6.tgz", + "integrity": "sha512-QIss8MPh6qUoFJmuaF4dSHts3qCsA36S3HcOLiNPShxhgYPr4XJRnCBKPipk85sR9xr6TQrOcDMfexwbNdJHYA==", + "peerDependencies": { + "@types/react": "17 || 18" + } + }, + "node_modules/@lit/reactive-element": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.0.4.tgz", + "integrity": "sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.2.0" + } + }, "node_modules/@ljharb/through": { "version": "2.3.12", "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.12.tgz", @@ -4239,6 +4406,15 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@preact/signals-core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.8.0.tgz", + "integrity": "sha512-OBvUsRZqNmjzCZXWLxkZfhcgT+Fk8DDcT/8vD6a1xhDemodyy87UJRJfASMuSD8FaAIeGgGm85ydXhm7lr4fyA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.12.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.12.0.tgz", @@ -4429,6 +4605,42 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@shoelace-style/animations": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@shoelace-style/animations/-/animations-1.2.0.tgz", + "integrity": "sha512-avvo1xxkLbv2dgtabdewBbqcJfV0e0zCwFqkPMnHFGbJbBHorRFfMAHh1NG9ymmXn0jW95ibUVH03E1NYXD6Gw==", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/claviska" + } + }, + "node_modules/@shoelace-style/localize": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.2.1.tgz", + "integrity": "sha512-r4C9C/5kSfMBIr0D9imvpRdCNXtUNgyYThc4YlS6K5Hchv1UyxNQ9mxwj+BTRH2i1Neits260sR3OjKMnplsFA==" + }, + "node_modules/@shoelace-style/shoelace": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/@shoelace-style/shoelace/-/shoelace-2.17.1.tgz", + "integrity": "sha512-fB9+bPHLg5zVwPbBKEqY3ghyttkJq9RuUzFMTZKweKrNKKDMUACtI8DlMYUqNwpdZMJhf7a0xeak6vFVBSxcbQ==", + "dependencies": { + "@ctrl/tinycolor": "^4.0.2", + "@floating-ui/dom": "^1.5.3", + "@lit/react": "^1.0.0", + "@shoelace-style/animations": "^1.1.0", + "@shoelace-style/localize": "^3.1.2", + "composed-offset-position": "^0.0.4", + "lit": "^3.0.0", + "qr-creator": "^1.0.0" + }, + "engines": { + "node": ">=14.17.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/claviska" + } + }, "node_modules/@sigstore/bundle": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.1.1.tgz", @@ -4513,6 +4725,11 @@ "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==", "dev": true }, + "node_modules/@streamparser/json": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.20.tgz", + "integrity": "sha512-VqAAkydywPpkw63WQhPVKCD3SdwXuihCUVZbbiY3SfSTGQyHmwRoq27y4dmJdZuJwd5JIlQoMPyGvMbUPY0RKQ==" + }, "node_modules/@swimlane/ngx-datatable": { "version": "20.1.0", "resolved": "https://registry.npmjs.org/@swimlane/ngx-datatable/-/ngx-datatable-20.1.0.tgz", @@ -4625,6 +4842,11 @@ "node": ">=8" } }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==" + }, "node_modules/@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", @@ -4938,6 +5160,12 @@ "@types/qunit": "^2.5.4" } }, + "node_modules/@types/prop-types": { + "version": "15.7.13", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", + "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", + "peer": true + }, "node_modules/@types/q": { "version": "0.0.32", "resolved": "https://registry.npmjs.org/@types/q/-/q-0.0.32.tgz", @@ -4961,6 +5189,16 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "dev": true }, + "node_modules/@types/react": { + "version": "18.3.11", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.11.tgz", + "integrity": "sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -5873,6 +6111,17 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -6634,6 +6883,11 @@ "node": ">=6.9.x" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + }, "node_modules/body-parser": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", @@ -7245,6 +7499,11 @@ "node": ">=4" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==" + }, "node_modules/chardet": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", @@ -7286,6 +7545,11 @@ "node": ">=10" } }, + "node_modules/chroma-js": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.6.0.tgz", + "integrity": "sha512-BLHvCB9s8Z1EV4ethr6xnkl/P2YRFOGqfgvuMG/MyCbZPrTA+NeiByY6XvgF0zP4/2deU2CXnWyMa3zu1LqQ3A==" + }, "node_modules/chrome-trace-event": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", @@ -7543,6 +7807,11 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, + "node_modules/colorbrewer": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/colorbrewer/-/colorbrewer-1.5.7.tgz", + "integrity": "sha512-G8wX/cby1fu05mkJ0+vF33fg3ksxqpPu2wfnl08ivV1/TnPzNxjyW6NH1XI+DpgwbFpWgh1x9dOPZWvpOrnBug==" + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -7599,6 +7868,11 @@ "node": ">=4.0.0" } }, + "node_modules/composed-offset-position": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/composed-offset-position/-/composed-offset-position-0.0.4.tgz", + "integrity": "sha512-vMlvu1RuNegVE0YsCDSV/X4X10j56mq7PCIyOKK74FxkXzGLwhOUmdkJLSdOBOMwWycobGUMgft2lp+YgTe8hw==" + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -8086,6 +8360,39 @@ "node": ">=4" } }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "peer": true + }, + "node_modules/csvtojson": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/csvtojson/-/csvtojson-2.0.10.tgz", + "integrity": "sha512-lUWFxGKyhraKCW8Qghz6Z0f2l/PqB1W3AO0HKJzGIQ5JRSlR651ekJDiGJbBT4sRNNv5ddnSGVEnsxP9XRCVpQ==", + "dependencies": { + "bluebird": "^3.5.1", + "lodash": "^4.17.3", + "strip-bom": "^2.0.0" + }, + "bin": { + "csvtojson": "bin/csvtojson" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/csvtojson/node_modules/strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==", + "dependencies": { + "is-utf8": "^0.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/custom-event": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", @@ -9956,6 +10263,14 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -9966,7 +10281,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "engines": { "node": ">=0.8.x" } @@ -10246,6 +10560,11 @@ "pend": "~1.2.0" } }, + "node_modules/fft-windowing-ts": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/fft-windowing-ts/-/fft-windowing-ts-0.2.7.tgz", + "integrity": "sha512-xJOVJPn6JhNBq5Tgd2NYghbzRQOh1ub/zUFldPCyNujScqia5jqX+GLVhzXVD6imAyK4KaMjG+iBeHycE2JV9w==" + }, "node_modules/figures": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/figures/-/figures-5.0.0.tgz", @@ -10286,6 +10605,22 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-type": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", + "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "dependencies": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -11904,6 +12239,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==" + }, "node_modules/is-weakmap": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", @@ -13326,6 +13666,34 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/lit": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.2.1.tgz", + "integrity": "sha512-1BBa1E/z0O9ye5fZprPtdqnc0BFzxIxTTOO/tQFmyC/hj1O3jL4TfmLBw0WEwjAokdLwpclkvGgDJwTIh0/22w==", + "dependencies": { + "@lit/reactive-element": "^2.0.4", + "lit-element": "^4.1.0", + "lit-html": "^3.2.0" + } + }, + "node_modules/lit-element": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.1.1.tgz", + "integrity": "sha512-HO9Tkkh34QkTeUmEdNYhMT8hzLid7YlMlATSi1q4q17HE5d9mrrEHJ/o8O2D0cMi182zK1F3v7x0PWFjrhXFew==", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.2.0", + "@lit/reactive-element": "^2.0.4", + "lit-html": "^3.2.0" + } + }, + "node_modules/lit-element/node_modules/lit-html": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.2.1.tgz", + "integrity": "sha512-qI/3lziaPMSKsrwlxH/xMgikhQ0EGOX2ICU73Bi/YHFvz2j/yMCIrw4+puF2IpQ4+upd3EWbvnHM9+PnJn48YA==", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, "node_modules/lit-html": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz", @@ -13334,6 +13702,14 @@ "@types/trusted-types": "^2.0.2" } }, + "node_modules/lit/node_modules/lit-html": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.2.1.tgz", + "integrity": "sha512-qI/3lziaPMSKsrwlxH/xMgikhQ0EGOX2ICU73Bi/YHFvz2j/yMCIrw4+puF2IpQ4+upd3EWbvnHM9+PnJn48YA==", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -13367,8 +13743,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.debounce": { "version": "4.0.8", @@ -13888,6 +14263,67 @@ "multicast-dns": "cli.js" } }, + "node_modules/music-metadata": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-7.14.0.tgz", + "integrity": "sha512-xrm3w7SV0Wk+OythZcSbaI8mcr/KHd0knJieu8bVpaPfMv/Agz5EooCAPz3OR5hbYMiUG6dgAPKZKnMzV+3amA==", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "content-type": "^1.0.5", + "debug": "^4.3.4", + "file-type": "^16.5.4", + "media-typer": "^1.1.0", + "strtok3": "^6.3.0", + "token-types": "^4.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/music-metadata-browser": { + "version": "2.5.11", + "resolved": "https://registry.npmjs.org/music-metadata-browser/-/music-metadata-browser-2.5.11.tgz", + "integrity": "sha512-Khq5nYapffIet0PUVb5J69pZPgqgn+/yoEr0jkO/OjH5xwfdz6rdwj0zsWPaqo3ylv+OthXoGjT6EegVHbMkJQ==", + "deprecated": "No longer support, superseded by music-metadata", + "dependencies": { + "buffer": "^6.0.3", + "debug": "^4.3.4", + "music-metadata": "^7.13.3", + "readable-stream": "^4.3.0", + "readable-web-to-node-stream": "^3.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/music-metadata-browser/node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/music-metadata/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/mute-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", @@ -15328,6 +15764,18 @@ "node": ">=8" } }, + "node_modules/peek-readable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", + "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -15697,6 +16145,14 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prismjs": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", + "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==", + "engines": { + "node": ">=6" + } + }, "node_modules/proc-log": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz", @@ -15706,6 +16162,14 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -15989,6 +16453,11 @@ "node": ">=0.9" } }, + "node_modules/qr-creator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/qr-creator/-/qr-creator-1.0.0.tgz", + "integrity": "sha512-C0cqfbS1P5hfqN4NhsYsUXePlk9BO+a45bAQ3xLYjBL3bOIFzoVEjs79Fado9u9BPBD3buHi3+vY+C8tHh4qMQ==" + }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -16113,7 +16582,6 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -16123,6 +16591,21 @@ "node": ">= 6" } }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz", + "integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==", + "dependencies": { + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -17847,7 +18330,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -17996,6 +18478,22 @@ "node": ">=4" } }, + "node_modules/strtok3": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", + "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -18303,6 +18801,22 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", + "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/topojson-client": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", @@ -18915,8 +19429,7 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/utils-merge": { "version": "1.0.1", diff --git a/package.json b/package.json index fe04e6c91..34617925b 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@angular/common": "17.1.2", "@angular/compiler": "17.1.2", "@angular/core": "17.1.2", + "@angular/elements": "^17.1.2", "@angular/forms": "17.1.2", "@angular/google-maps": "^17.0.5", "@angular/localize": "17.1.2", @@ -42,6 +43,7 @@ "@angular/platform-server": "17.1.2", "@angular/router": "17.1.2", "@angular/ssr": "^17.1.2", + "@ecoacoustics/web-components": "1.2.0", "@fortawesome/angular-fontawesome": "^0.14.1", "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1", @@ -81,6 +83,7 @@ "zone.js": "~0.14.3" }, "devDependencies": { + "@angular-builders/custom-webpack": "^17.0.2", "@angular-devkit/build-angular": "17.2.2", "@angular-eslint/builder": "17.2.0", "@angular-eslint/eslint-plugin": "17.2.0", diff --git a/scripts/version.ps1 b/scripts/version.ps1 index 08e74758f..08bf28789 100644 --- a/scripts/version.ps1 +++ b/scripts/version.ps1 @@ -8,7 +8,7 @@ param( [string] $release_tag ) - + . $PSScriptRoot/exec.ps1 $now = Get-Date @@ -17,16 +17,25 @@ $minor = $now.ToString("MM") $patch = $now.ToString("dd") $describe = exec { git describe --long --always } $parts = $describe.split('-') -$height = [int]$parts[-2] +$height = 0 $unique_hash = $parts[-1].Trim('g') $full_hash = exec { git rev-parse --verify HEAD } $version = "$major.$minor.$patch" - # add build number if this is not the first tag with this version $pre_release = "" -if ((git tag -l $version) -and ($height -gt 0)) { - $pre_release = "-build$height" +Write-Output "Checking if tag exists $describe" +if (git tag -l $version) { + # because we use the date for the version, we can have multiple versions + # with the same name + # to fix this we add a "build" number to the version if a build tag of the + # same version already exists + # we keep incrementing the build number until we find the next build number + # that has not been used + while (git tag -l "$version-build$height") { + $height++ + } + $pre_release = "-build$height" } # build must be metadata only, can't alter version meaning @@ -45,4 +54,4 @@ return [PSCustomObject]@{ DescribeVersion = "${version}${pre_release}+${build}" # Calender version including pre-release and build DockerTag = "${version}${pre_release}_${build}" -} \ No newline at end of file +} diff --git a/server.ts b/server.ts index b61404b90..bc8ed9ea1 100644 --- a/server.ts +++ b/server.ts @@ -18,6 +18,7 @@ import { environment } from "src/environments/environment"; import { API_CONFIG } from "@services/config/config.tokens"; import { AppServerModule } from "./src/main.server"; import { REQUEST, RESPONSE } from "./src/express.tokens"; +import angularConfig from "./angular.json"; // The Express app is exported so that it can be used by serverless Functions. export function app(path: string): express.Express { @@ -58,6 +59,21 @@ export function app(path: string): express.Express { next(); }); + // we add the COOP and COEP headers so that we can use SharedArrayBuffer + // if you want to update these headers, update the angular.json file + // so that the headers are updated in the dev server as well + server.use((_, res, next) => { + // we use the angular.json config as the source of truth for headers + // so that we don't have to maintain the same headers for both the dev + // server and the production SSR server + const serverHeaders = angularConfig.projects["workbench-client"].architect.serve.options.headers; + for (const [key, value] of Object.entries(serverHeaders)) { + res.setHeader(key, value); + } + + next(); + }) + // special case rendering our settings file - we already have it loaded server.get(`${assetRoot}/environment.json`, (request, response) => { response.type("application/json"); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index dd21fdf22..4ee056c2b 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -2,6 +2,7 @@ import { Component, Inject, Injectable, + Injector, OnInit, ViewEncapsulation, } from "@angular/core"; @@ -24,6 +25,11 @@ import { MenuService } from "@services/menu/menu.service"; import { SharedActivatedRouteService } from "@services/shared-activated-route/shared-activated-route.service"; import { filter, Observable, takeUntil } from "rxjs"; import { isInstantiated } from "@helpers/isInstantiated/isInstantiated"; +import { createCustomElement } from "@angular/elements"; +import { + GridTileContentComponent, + gridTileContentSelector, +} from "@components/web-components/grid-tile-content/grid-tile-content.component"; import { IS_SERVER_PLATFORM } from "./app.helper"; import { withUnsubscribe } from "./helpers/unsubscribe/unsubscribe"; import { ConfigService } from "./services/config/config.service"; @@ -52,6 +58,7 @@ export class AppComponent extends withUnsubscribe() implements OnInit { public constructor( public menu: MenuService, + protected injector: Injector, private sharedRoute: SharedActivatedRouteService, private router: Router, @Inject(IS_SERVER_PLATFORM) private isServer: boolean, @@ -64,6 +71,21 @@ export class AppComponent extends withUnsubscribe() implements OnInit { */ this.router.initialNavigation(); globals.initialize(); + + // register all web components here + // we make some of our standalone angular components into standards based web components + // so that they can operate entirely independently - e.g. in shadow dom + if (!this.isServer) { + const hasCustomElement = !!customElements.get(gridTileContentSelector); + + if (!hasCustomElement) { + const webComponentElement = createCustomElement( + GridTileContentComponent, + { injector } + ); + customElements.define(gridTileContentSelector, webComponentElement); + } + } } public ngOnInit(): void { @@ -105,10 +127,7 @@ export class AppComponent extends withUnsubscribe() implements OnInit { @Injectable() export class PageTitleStrategy extends TitleStrategy { - public constructor( - private title: Title, - private config: ConfigService, - ) { + public constructor(private title: Title, private config: ConfigService) { super(); } @@ -131,7 +150,10 @@ export class PageTitleStrategy extends TitleStrategy { const hideProjects: boolean = this.config.settings.hideProjects; const titleOptions: TitleOptionsHash = { hideProjects }; - const routeFragmentTitle = subRoute.title(this.routerState, titleOptions); + const routeFragmentTitle = subRoute.title( + this.routerState, + titleOptions + ); // to explicitly omit a route title fragment, the title callback will return null if (isInstantiated(routeFragmentTitle)) { @@ -149,8 +171,8 @@ export class PageTitleStrategy extends TitleStrategy { } return subRoute?.parent - ? this.buildHierarchicalTitle(subRoute.parent) + componentTitle - : componentTitle; + ? this.buildHierarchicalTitle(subRoute.parent) + componentTitle + : componentTitle; } // all site titles should follow the format <> | ...PageComponentTitles diff --git a/src/app/app.module.ts b/src/app/app.module.ts index ad1035a6b..49b944f56 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,4 +1,8 @@ -import { DoBootstrap, NgModule } from "@angular/core"; +import { + CUSTOM_ELEMENTS_SCHEMA, + DoBootstrap, + NgModule, +} from "@angular/core"; import { ReactiveFormsModule } from "@angular/forms"; import { BrowserModule } from "@angular/platform-browser"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; @@ -24,6 +28,7 @@ import { environment } from "src/environments/environment"; import { TitleStrategy } from "@angular/router"; import { AnnotationsImportModule } from "@components/import-annotations/import-annotations.module"; import { WebsiteStatusModule } from "@components/website-status/website-status.module"; +import { AnnotationModule } from "@components/annotations/annotation.module"; import { AppRoutingModule } from "./app-routing.module"; import { AppComponent, PageTitleStrategy } from "./app.component"; import { toastrRoot } from "./app.helper"; @@ -64,6 +69,7 @@ export const appImports = [ DataRequestModule, HarvestModule, ReportsModule, + AnnotationModule, AnnotationsImportModule, LibraryModule, ListenModule, @@ -105,6 +111,7 @@ export const appImports = [ { provide: LOADING_BAR_CONFIG, useValue: { latencyThreshold: 200 } }, ], exports: [], + schemas: [CUSTOM_ELEMENTS_SCHEMA], }) export class AppModule implements DoBootstrap { public ngDoBootstrap(app: any): void { diff --git a/src/app/components/annotations/annotation.menu.ts b/src/app/components/annotations/annotation.menu.ts new file mode 100644 index 000000000..94e3e63a4 --- /dev/null +++ b/src/app/components/annotations/annotation.menu.ts @@ -0,0 +1,99 @@ +import { Category, menuRoute, MenuRoute } from "@interfaces/menusInterfaces"; +import { siteMenuItem } from "@components/sites/sites.menus"; +import { pointMenuItem } from "@components/sites/points.menus"; +import { regionMenuItem } from "@components/regions/regions.menus"; +import { projectMenuItem } from "@components/projects/projects.menus"; +import { isLoggedInPredicate } from "src/app/app.menus"; +import { annotationSearchRoute, verificationRoute, AnnotationRoute } from "./annotation.routes"; + +export type AnnotationMenuRoutes = Record; + +function makeVerificationCategory(subRoute: AnnotationRoute): Category { + return { + icon: ["fas", "circle-check"], + label: "Verification", + route: verificationRoute[subRoute], + }; +} + +function makeAnnotationSearchCategory(subRoute: AnnotationRoute): Category { + return { + icon: ["fas", "layer-group"], + label: "Annotations", + route: annotationSearchRoute[subRoute], + }; +} + +function makeVerificationMenuItem( + subRoute: AnnotationRoute, + parent?: MenuRoute, +) { + return menuRoute({ + icon: ["fas", "circle-check"], + label: "Verify Annotations", + tooltip: () => "(BETA) Verify Annotations", + predicate: isLoggedInPredicate, + route: verificationRoute[subRoute], + parent, + }); +} + +function makeAnnotationSearchMenuItem( + subRoute: AnnotationRoute, + parent?: MenuRoute, +) { + return menuRoute({ + icon: ["fas", "layer-group"], + label: "Search Annotations", + tooltip: () => "(BETA) Search Annotations", + route: annotationSearchRoute[subRoute], + parent, + }); +} + +const annotationSearchMenuitem: AnnotationMenuRoutes = { + /** /project/:projectId/site/:siteId/annotations */ + site: makeAnnotationSearchMenuItem("site", siteMenuItem), + /** /project/:projectId/region/:regionId/site/:siteId/annotations */ + siteAndRegion: makeAnnotationSearchMenuItem("siteAndRegion", pointMenuItem), + /** /project/:projectId/region/:regionId/annotations */ + region: makeAnnotationSearchMenuItem("region", regionMenuItem), + /** /project/:projectId/annotations */ + project: makeAnnotationSearchMenuItem("project", projectMenuItem), +}; + +const verificationMenuItem: AnnotationMenuRoutes = { + /** /project/:projectId/site/:siteId/annotations/verify */ + site: makeVerificationMenuItem("site", annotationSearchMenuitem.site), + /** /project/:projectId/region/:regionId/site/:siteId/annotations/verify */ + siteAndRegion: makeVerificationMenuItem("siteAndRegion", annotationSearchMenuitem.siteAndRegion), + /** /project/:projectId/region/:regionId/annotations/verify */ + region: makeVerificationMenuItem("region", annotationSearchMenuitem.region), + /** /project/:projectId/annotations/verify */ + project: makeVerificationMenuItem("project", annotationSearchMenuitem.project), +}; + + +const verificationCategory = { + site: makeVerificationCategory("site"), + siteAndRegion: makeVerificationCategory("siteAndRegion"), + region: makeVerificationCategory("region"), + project: makeVerificationCategory("project"), +}; + +const annotationSearchCategory = { + site: makeAnnotationSearchCategory("site"), + siteAndRegion: makeAnnotationSearchCategory("siteAndRegion"), + region: makeAnnotationSearchCategory("region"), + project: makeAnnotationSearchCategory("project"), +}; + +export const annotationCategories = { + search: annotationSearchCategory, + verify: verificationCategory, +}; + +export const annotationMenuItems = { + search: annotationSearchMenuitem, + verify: verificationMenuItem, +}; diff --git a/src/app/components/annotations/annotation.module.ts b/src/app/components/annotations/annotation.module.ts new file mode 100644 index 000000000..112d3636d --- /dev/null +++ b/src/app/components/annotations/annotation.module.ts @@ -0,0 +1,35 @@ +import { getRouteConfigForPage } from "@helpers/page/pageRouting"; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from "@angular/core"; +import { SharedModule } from "@shared/shared.module"; +import { RouterModule } from "@angular/router"; +import { StrongRoute } from "@interfaces/strongRoute"; +import { verificationRoute } from "./annotation.routes"; +import { VerificationComponent } from "./pages/verification/verification.component"; +import { ProgressWarningComponent } from "./components/modals/progress-warning/progress-warning.component"; +import { AnnotationSearchFormComponent } from "./components/annotation-search-form/annotation-search-form.component"; +import { AnnotationSearchComponent } from "./pages/search/search.component"; +import { SearchFiltersModalComponent } from "./components/modals/search-filters/search-filters.component"; +import { FiltersWarningModalComponent } from "./components/modals/filters-warning/filters-warning.component"; + +const internalComponents = [ + SearchFiltersModalComponent, + ProgressWarningComponent, + FiltersWarningModalComponent, + AnnotationSearchFormComponent, +]; + +const internalModules = []; + +const components = [VerificationComponent, AnnotationSearchComponent]; + +const routes = Object.values(verificationRoute) + .map((route: StrongRoute) => route.compileRoutes(getRouteConfigForPage)) + .flat(); + +@NgModule({ + declarations: [...internalComponents, ...components], + imports: [SharedModule, RouterModule.forChild(routes), ...internalModules], + exports: [RouterModule, ...components], + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class AnnotationModule {} diff --git a/src/app/components/annotations/annotation.routes.ts b/src/app/components/annotations/annotation.routes.ts new file mode 100644 index 000000000..c9be7ca8c --- /dev/null +++ b/src/app/components/annotations/annotation.routes.ts @@ -0,0 +1,46 @@ +import { projectRoute } from "@components/projects/projects.routes"; +import { regionRoute } from "@components/regions/regions.routes"; +import { pointRoute } from "@components/sites/points.routes"; +import { siteRoute } from "@components/sites/sites.routes"; +import { StrongRoute } from "@interfaces/strongRoute"; + +const annotationsRouteName = "annotations"; +const verificationRouteName = "verify"; + +const annotationSearchRouteQueryParamResolver = (params: Record) => + params + ? { + projects: params.projects, + regions: params.regions, + sites: params.sties, + tags: params.tags, + onlyUnverified: params.onlyUnverified, + date: params.date, + time: params.time, + } + : {}; + +export type AnnotationRoute = "project" | "region" | "site" | "siteAndRegion"; +export type AnnotationStrongRoute = Record; + +export const annotationSearchRoute: AnnotationStrongRoute = { + /** /project/:projectId/site/:siteId/annotations */ + site: siteRoute.add(annotationsRouteName, annotationSearchRouteQueryParamResolver), + /** /project/:projectId/region/:regionId/site/:siteId/annotations */ + siteAndRegion: pointRoute.add(annotationsRouteName, annotationSearchRouteQueryParamResolver), + /** /project/:projectId/region/:regionId/annotations */ + region: regionRoute.add(annotationsRouteName, annotationSearchRouteQueryParamResolver), + /** /project/:projectId/annotations */ + project: projectRoute.add(annotationsRouteName, annotationSearchRouteQueryParamResolver), +}; + +export const verificationRoute: AnnotationStrongRoute = { + /** /project/:projectId/site/:siteId/annotations/verify */ + site: annotationSearchRoute.site.add(verificationRouteName, annotationSearchRouteQueryParamResolver), + /** /project/:projectId/region/:regionId/site/:siteId/annotations/verify */ + siteAndRegion: annotationSearchRoute.siteAndRegion.add(verificationRouteName, annotationSearchRouteQueryParamResolver), + /** /project/:projectId/region/:regionId/annotations/verify */ + region: annotationSearchRoute.region.add(verificationRouteName, annotationSearchRouteQueryParamResolver), + /** /project/:projectId/annotations/verify */ + project: annotationSearchRoute.project.add(verificationRouteName, annotationSearchRouteQueryParamResolver), +}; diff --git a/src/app/components/annotations/components/annotation-search-form/annotation-search-form.component.html b/src/app/components/annotations/components/annotation-search-form/annotation-search-form.component.html new file mode 100644 index 000000000..08294befb --- /dev/null +++ b/src/app/components/annotations/components/annotation-search-form/annotation-search-form.component.html @@ -0,0 +1,115 @@ +
+ + + + + + + + + + + + +
+ + + +
+
+ +
+ + +
+ +
+
+
+ + + + + + + + + + + + diff --git a/src/app/components/annotations/components/annotation-search-form/annotation-search-form.component.scss b/src/app/components/annotations/components/annotation-search-form/annotation-search-form.component.scss new file mode 100644 index 000000000..aa7de7d0f --- /dev/null +++ b/src/app/components/annotations/components/annotation-search-form/annotation-search-form.component.scss @@ -0,0 +1,11 @@ +.advanced-filters { + :not(:last-child):has(~ .show, ~ .collapsing) { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + + :not(:first-child) { + border-top-left-radius: 0; + border-top-right-radius: 0; + } +} diff --git a/src/app/components/annotations/components/annotation-search-form/annotation-search-form.component.spec.ts b/src/app/components/annotations/components/annotation-search-form/annotation-search-form.component.spec.ts new file mode 100644 index 000000000..1f19282ed --- /dev/null +++ b/src/app/components/annotations/components/annotation-search-form/annotation-search-form.component.spec.ts @@ -0,0 +1,269 @@ +import { + createComponentFactory, + Spectator, + SpyObject, +} from "@ngneat/spectator"; +import { SharedModule } from "@shared/shared.module"; +import { MockBawApiModule } from "@baw-api/baw-apiMock.module"; +import { AnnotationSearchParameters } from "@components/annotations/pages/annotationSearchParameters"; +import { Injector, INJECTOR } from "@angular/core"; +import { Project } from "@models/Project"; +import { generateProject } from "@test/fakes/Project"; +import { TagsService } from "@baw-api/tag/tags.service"; +import { AUDIO_RECORDING, SHALLOW_SITE, TAG } from "@baw-api/ServiceTokens"; +import { Tag } from "@models/Tag"; +import { of } from "rxjs"; +import { generateTag } from "@test/fakes/Tag"; +import { ShallowSitesService } from "@baw-api/site/sites.service"; +import { Site } from "@models/Site"; +import { generateSite } from "@test/fakes/Site"; +import { + selectFromTypeahead, + toggleDropdown, + waitForDropdown, +} from "@test/helpers/html"; +import { discardPeriodicTasks, fakeAsync, flush } from "@angular/core/testing"; +import { modelData } from "@test/helpers/faker"; +import { DateTimeFilterComponent } from "@shared/date-time-filter/date-time-filter.component"; +import { TypeaheadInputComponent } from "@shared/typeahead-input/typeahead-input.component"; +import { Params } from "@angular/router"; +import { AudioRecordingsService } from "@baw-api/audio-recording/audio-recordings.service"; +import { AudioRecording } from "@models/AudioRecording"; +import { generateAudioRecording } from "@test/fakes/AudioRecording"; +import { AnnotationSearchFormComponent } from "./annotation-search-form.component"; + +describe("AnnotationSearchFormComponent", () => { + let spectator: Spectator; + let injector: SpyObject; + + let tagsApiSpy: SpyObject; + let sitesApiSpy: SpyObject; + let recordingsApiSpy: SpyObject; + let modelChangeSpy: jasmine.Spy; + + let mockTagsResponse: Tag[] = []; + let mockSitesResponse: Site[] = []; + let mockProject: Project; + let mockRecording: AudioRecording; + + const createComponent = createComponentFactory({ + component: AnnotationSearchFormComponent, + imports: [MockBawApiModule, SharedModule], + declarations: [DateTimeFilterComponent, TypeaheadInputComponent], + }); + + function setup(params: Params = {}): void { + spectator = createComponent({ detectChanges: false }); + + injector = spectator.inject(INJECTOR); + tagsApiSpy = spectator.inject(TAG.token); + sitesApiSpy = spectator.inject(SHALLOW_SITE.token); + recordingsApiSpy = spectator.inject(AUDIO_RECORDING.token); + + mockTagsResponse = Array.from( + { length: 10 }, + () => new Tag(generateTag(), injector) + ); + mockSitesResponse = Array.from( + { length: 10 }, + () => new Site(generateSite(), injector) + ); + mockProject = new Project(generateProject(), injector); + mockRecording = new AudioRecording(generateAudioRecording(), injector); + + modelChangeSpy = spyOn(spectator.component.searchParametersChange, "emit"); + + tagsApiSpy.filter.andCallFake(() => of(mockTagsResponse)); + sitesApiSpy.filter.andCallFake(() => of(mockSitesResponse)); + recordingsApiSpy.filter.andCallFake(() => of([mockRecording])); + + const searchParameters = new AnnotationSearchParameters(params, injector); + searchParameters.routeProjectModel = mockProject; + spectator.setInput("searchParameters", searchParameters); + } + + const sitesTypeahead = () => spectator.query("#sites-input"); + const onlyVerifiedCheckbox = () => spectator.query("#filter-verified"); + + const tagsTypeahead = () => spectator.query("#tags-input"); + const tagPills = () => + tagsTypeahead().querySelectorAll(".item-pill"); + + const projectsInput = () => projectsTypeahead().querySelector("input"); + const projectsTypeahead = () => spectator.query("#projects-input"); + + const dateToggleInput = () => + spectator.query("#date-filtering"); + const endDateInput = () => + spectator.query("#date-finished-before"); + + const advancedFiltersToggle = () => + spectator.query("#advanced-filters-toggle"); + const advancedFitlersCollapsable = () => + spectator.query(".advanced-filters>[ng-reflect-collapsed]"); + const recordingsTypeahead = () => spectator.query("#recordings-input"); + + it("should create", () => { + setup(); + expect(spectator.component).toBeInstanceOf(AnnotationSearchFormComponent); + }); + + it("should have a collapsable advanced filters section", fakeAsync(() => { + setup(); + expect(recordingsTypeahead()).toBeHidden(); + toggleDropdown(spectator, advancedFiltersToggle()); + expect(recordingsTypeahead()).toBeVisible(); + })); + + describe("pre-population from first load", () => { + // check the population of a typeahead input that uses a property backing + it("should pre-populate the project typeahead input if provided", () => { + setup(); + expect(projectsInput()).toHaveProperty("placeholder", mockProject.name); + }); + + // check the population of a typeahead input that does not use a property backing + it("should pre-populate the tags typeahead input if provided in the search parameters model", () => { + setup({ tags: "0" }); + const realizedTagPills = tagPills(); + expect(realizedTagPills[0].innerText).toEqual(`${mockTagsResponse[0]}`); + }); + + // check the population of an external component that is not a typeahead input + it("should pre-populate the date filters if provided in the search parameters model", fakeAsync(() => { + const testEndDate = modelData.dateTime(); + const testEndDateString = testEndDate.toFormat("yyyy-MM-dd"); + + setup({ recordingDate: `,${testEndDateString}` }); + waitForDropdown(spectator); + + expect(endDateInput()).toHaveValue(testEndDate.toFormat("yyyy-MM-dd")); + expect(spectator.component.searchParameters.recordingDateStartedAfter).toBeFalsy(); + expect(spectator.component.searchParameters.recordingDateFinishedBefore).toBeTruthy(); + + flush(); + discardPeriodicTasks(); + })); + + it("should not apply date filters if the dropdown is closed", fakeAsync(() => { + const testEndDate = modelData.dateTime(); + const testEndDateString = testEndDate.toFormat("yyyy-MM-dd"); + + setup({ recordingDate: `,${testEndDateString}` }); + // wait for the initial date/time filters to open + waitForDropdown(spectator); + + // close the date/time filters and assert that the filter conditions are + // no longer applied + spectator.click(dateToggleInput()); + waitForDropdown(spectator); + + expect(spectator.component.searchParameters.recordingDateStartedAfter).toBeFalsy(); + expect(spectator.component.searchParameters.recordingDateFinishedBefore).toBeFalsy(); + + flush(); + discardPeriodicTasks(); + })); + + // check the population of a checkbox boolean input + // TODO: enable this test once we have the endpoint avaliable to filter by verified status + xit("should pre-populate the only verified checkbox if provided in the search parameters model", () => { + expect(spectator.component.searchParameters.onlyUnverified).toBeTrue(); + }); + + it("should automatically open the advanced filters if the search parameters have advanced filters", fakeAsync(() => { + setup({ audioRecordings: "1" }); + const expectedText = `Recording IDs of interest ${mockRecording.id}`; + + waitForDropdown(spectator); + + expect(recordingsTypeahead()).toBeVisible(); + expect(recordingsTypeahead()).toHaveExactTrimmedText(expectedText); + expect(advancedFitlersCollapsable()).toHaveClass("show"); + })); + + it("should not apply the advanced filters if the dropdown is closed", fakeAsync(() => { + setup({ audioRecordings: "1" }); + expect(spectator.component.searchParameters.audioRecordings).toHaveLength(1); + + toggleDropdown(spectator, advancedFiltersToggle()); + + const realizedModel = spectator.component.searchParameters; + expect(realizedModel.audioRecordings).toHaveLength(0); + })); + }); + + describe("update emission", () => { + beforeEach(() => { + setup(); + }); + + // check a typeahead input that also has an optional property backing + it("should emit the correct model if the site is updated", fakeAsync(() => { + const testedSite = mockSitesResponse[0]; + selectFromTypeahead(spectator, sitesTypeahead(), testedSite.name); + + expect(spectator.component.searchParameters.sites).toEqual([ + testedSite.id, + ]); + expect(modelChangeSpy).toHaveBeenCalledOnceWith( + spectator.component.searchParameters + ); + })); + + // check a typeahead input that does not have an optional property backing + it("should emit the correct model if the tags are updated", fakeAsync(() => { + const testedTag = mockTagsResponse[0]; + selectFromTypeahead(spectator, tagsTypeahead(), testedTag.text, false); + + expect(spectator.component.searchParameters.tags).toEqual([testedTag.id]); + expect(modelChangeSpy).toHaveBeenCalledOnceWith( + spectator.component.searchParameters + ); + })); + + // check an external component that is not a typeahead input + // + // TODO: I have sunk a lot of time into this test, and I have determined + // that the extra effort to get this test working will not reap benefits + // we should eventually finish this test + // at the moment the date/time filter components form is not triggering + // its form change event when the input is changed and the dropdown is not + // opening when the checkbox is toggled + xit("should emit the correct model if the date filters are updated", fakeAsync(() => { + const testedDate = "2021-10-10"; + const expectedNewModel = {}; + + spectator.click(dateToggleInput()); + waitForDropdown(spectator); + + spectator.typeInElement(testedDate, endDateInput()); + + expect(modelChangeSpy).toHaveBeenCalledOnceWith(expectedNewModel); + })); + + it("should not emit a new model if the date filters are updated with an invalid value", fakeAsync(() => { + const testedDate = "2021109-12"; + + spectator.click(dateToggleInput()); + waitForDropdown(spectator); + + spectator.typeInElement(testedDate, endDateInput()); + + expect(modelChangeSpy).not.toHaveBeenCalled(); + + flush(); + discardPeriodicTasks(); + })); + + // TODO: enable this test once we have the endpoint available to filter by verified status + xit("should emit the correct model if the only verified checkbox is updated", () => { + spectator.click(onlyVerifiedCheckbox()); + + expect(spectator.component.searchParameters.onlyUnverified).toBeTrue(); + expect(modelChangeSpy).toHaveBeenCalledOnceWith( + jasmine.objectContaining({ onlyUnverified: true }) + ); + }); + }); +}); diff --git a/src/app/components/annotations/components/annotation-search-form/annotation-search-form.component.ts b/src/app/components/annotations/components/annotation-search-form/annotation-search-form.component.ts new file mode 100644 index 000000000..5fcb2a34b --- /dev/null +++ b/src/app/components/annotations/components/annotation-search-form/annotation-search-form.component.ts @@ -0,0 +1,176 @@ +import { + Component, + EventEmitter, + Input, + OnInit, + Output, + ViewChild, +} from "@angular/core"; +import { AudioEventsService } from "@baw-api/audio-event/audio-events.service"; +import { AudioRecordingsService } from "@baw-api/audio-recording/audio-recordings.service"; +import { ProjectsService } from "@baw-api/project/projects.service"; +import { ShallowRegionsService } from "@baw-api/region/regions.service"; +import { ShallowSitesService } from "@baw-api/site/sites.service"; +import { TagsService } from "@baw-api/tag/tags.service"; +import { AnnotationSearchParameters } from "@components/annotations/pages/annotationSearchParameters"; +import { isInstantiated } from "@helpers/isInstantiated/isInstantiated"; +import { AudioRecording } from "@models/AudioRecording"; +import { Project } from "@models/Project"; +import { Region } from "@models/Region"; +import { Site } from "@models/Site"; +import { NgbDate } from "@ng-bootstrap/ng-bootstrap"; +import { DateTimeFilterModel } from "@shared/date-time-filter/date-time-filter.component"; +import { + createIdSearchCallback, + createSearchCallback, +} from "@shared/typeahead-input/typeahead-callbacks"; +import { TypeaheadInputComponent } from "@shared/typeahead-input/typeahead-input.component"; +import { DateTime } from "luxon"; + +@Component({ + selector: "baw-annotation-search-form", + templateUrl: "annotation-search-form.component.html", + styleUrl: "annotation-search-form.component.scss", +}) +export class AnnotationSearchFormComponent implements OnInit { + public constructor( + protected recordingsApi: AudioRecordingsService, + protected audioEventsApi: AudioEventsService, + protected projectsApi: ProjectsService, + protected regionsApi: ShallowRegionsService, + protected sitesApi: ShallowSitesService, + protected tagsApi: TagsService + ) {} + + @Input({ required: true }) + public searchParameters: AnnotationSearchParameters; + @Output() + public searchParametersChange = + new EventEmitter(); + + @ViewChild("recordingsTypeahead") + private recordingsTypeahead: TypeaheadInputComponent; + + protected recordingDateTimeFilters: DateTimeFilterModel = {}; + protected createSearchCallback = createSearchCallback; + protected createIdSearchCallback = createIdSearchCallback; + protected hideAdvancedFilters = true; + + protected get project(): Project { + return this.searchParameters.routeProjectModel; + } + + protected get region(): Region { + return this.searchParameters.routeRegionModel; + } + + protected get site(): Site { + return this.searchParameters.routeSiteModel; + } + + public ngOnInit(): void { + // if there are advanced filters when we initially load the page, we should + // automatically open the advanced filters accordion so that the user can + // see that advanced filters are applied + const advancedFilterKeys: (keyof AnnotationSearchParameters)[] = [ + "audioRecordings", + ]; + + for (const key of advancedFilterKeys) { + const value = this.searchParameters[key]; + + if (Array.isArray(value) && value.length > 0) { + this.hideAdvancedFilters = false; + break; + } else if (isInstantiated(value)) { + this.hideAdvancedFilters = false; + break; + } + } + + // we want to set the initial model the date/time filters + // TODO: this should probably be moved to a different spot + const hasDateFilters = this.searchParameters.recordingDate?.length > 0; + if (hasDateFilters) { + const dateFinishedBefore = new NgbDate( + this.searchParameters.recordingDateFinishedBefore.year, + this.searchParameters.recordingDateFinishedBefore.month, + this.searchParameters.recordingDateFinishedBefore.day + ); + + this.recordingDateTimeFilters = { + dateFiltering: true, + dateFinishedBefore, + }; + } + } + + protected toggleAdvancedFilters(): void { + this.hideAdvancedFilters = !this.hideAdvancedFilters; + + if (this.hideAdvancedFilters) { + this.searchParameters.audioRecordings = null; + } else { + const recordingIds = this.recordingsTypeahead.value.map( + (model: AudioRecording) => model.id + ); + + if (recordingIds.length > 0) { + this.searchParameters.audioRecordings = recordingIds; + } + } + + this.searchParametersChange.emit(this.searchParameters); + } + + protected updateSubModel( + key: keyof AnnotationSearchParameters, + subModels: any[] + ): void { + // if the subModels array is empty, the user has not selected any models + // we should set the search parameter to null so that it is not emitted + if (subModels.length === 0) { + this.searchParameters[key as any] = null; + this.searchParametersChange.emit(this.searchParameters); + return; + } + + const ids = subModels.map((model) => model.id); + this.searchParameters[key as any] = ids; + this.searchParametersChange.emit(this.searchParameters); + } + + protected updateRecordingDateTime(dateTimeModel: DateTimeFilterModel): void { + if (dateTimeModel.dateStartedAfter || dateTimeModel.dateFinishedBefore) { + this.searchParameters.recordingDate = [ + dateTimeModel.dateStartedAfter + ? DateTime.fromObject(dateTimeModel.dateStartedAfter) + : null, + dateTimeModel.dateFinishedBefore + ? DateTime.fromObject(dateTimeModel.dateFinishedBefore) + : null, + ]; + } + + if (dateTimeModel.timeStartedAfter || dateTimeModel.timeFinishedBefore) { + this.searchParameters.recordingTime = [ + dateTimeModel.timeStartedAfter, + dateTimeModel.timeFinishedBefore, + ]; + } + + if (!dateTimeModel.dateFiltering) { + this.searchParameters.recordingDate = null; + } + if (!dateTimeModel.timeFiltering) { + this.searchParameters.recordingTime = null; + } + + this.searchParametersChange.emit(this.searchParameters); + } + + protected updateOnlyUnverified(value: boolean): void { + this.searchParameters.onlyUnverified = value; + this.searchParametersChange.emit(this.searchParameters); + } +} diff --git a/src/app/components/annotations/components/modals/filters-warning/filters-warning.component.ts b/src/app/components/annotations/components/modals/filters-warning/filters-warning.component.ts new file mode 100644 index 000000000..33668b10b --- /dev/null +++ b/src/app/components/annotations/components/modals/filters-warning/filters-warning.component.ts @@ -0,0 +1,53 @@ +import { Component, Input } from "@angular/core"; +import { ModalComponent } from "@menu/widget.component"; +import { NgbActiveModal } from "@ng-bootstrap/ng-bootstrap"; + +@Component({ + selector: "baw-filters-warning-modal", + template: ` + + + + `, +}) +export class FiltersWarningModalComponent implements ModalComponent { + @Input({ required: true }) public modal: NgbActiveModal; + @Input({ required: true }) public itemCount: number; + + public closeModal(status: boolean): void { + this.modal.close(status); + } +} diff --git a/src/app/components/annotations/components/modals/progress-warning/progress-warning.component.ts b/src/app/components/annotations/components/modals/progress-warning/progress-warning.component.ts new file mode 100644 index 000000000..1935af8c8 --- /dev/null +++ b/src/app/components/annotations/components/modals/progress-warning/progress-warning.component.ts @@ -0,0 +1,61 @@ +import { Component, Input } from "@angular/core"; +import { ModalComponent } from "@menu/widget.component"; +import { NgbActiveModal } from "@ng-bootstrap/ng-bootstrap"; + +@Component({ + selector: "baw-progress-warning-modal", + template: ` + + + + `, +}) +export class ProgressWarningComponent implements ModalComponent { + @Input() public modal: NgbActiveModal; + + public closeModal(status: boolean): void { + this.modal.close(status); + } +} diff --git a/src/app/components/annotations/components/modals/search-filters/search-filters.component.spec.ts b/src/app/components/annotations/components/modals/search-filters/search-filters.component.spec.ts new file mode 100644 index 000000000..cbce6801e --- /dev/null +++ b/src/app/components/annotations/components/modals/search-filters/search-filters.component.spec.ts @@ -0,0 +1,55 @@ +import { createComponentFactory, Spectator } from "@ngneat/spectator"; +import { MockBawApiModule } from "@baw-api/baw-apiMock.module"; +import { SharedModule } from "@shared/shared.module"; +import { SearchFiltersModalComponent } from "./search-filters.component"; + +describe("SearchFiltersModalComponent", () => { + let spectator: Spectator; + let successSpy: jasmine.Spy; + + const createComponent = createComponentFactory({ + component: SearchFiltersModalComponent, + imports: [MockBawApiModule, SharedModule], + }); + + function setup(): void { + spectator = createComponent({ detectChanges: false }); + + successSpy = spectator.component.successCallback = jasmine.createSpy(); + successSpy.and.stub(); + + spectator.detectChanges(); + } + + const exitButton = () => spectator.query("#exit-btn"); + const updateButton = () => + spectator.query("#update-filters-btn"); + + beforeEach(() => { + setup(); + }); + + it("should create", () => { + expect(spectator.component).toBeInstanceOf(SearchFiltersModalComponent); + }); + + it("should not use the success callback if the cancel button is clicked", () => { + exitButton().click(); + expect(successSpy).not.toHaveBeenCalled(); + }); + + it("should use the success callback if the update button is clicked", () => { + updateButton().click(); + expect(successSpy).toHaveBeenCalled(); + }); + + it("should have a warning button if the host has decisions", () => { + spectator.setInput("hasDecisions", true); + expect(updateButton()).toHaveClass("btn-warning"); + }); + + it("should have a primary button if the host does not have decisions", () => { + spectator.setInput("hasDecisions", false); + expect(updateButton()).toHaveClass("btn-primary"); + }); +}); diff --git a/src/app/components/annotations/components/modals/search-filters/search-filters.component.ts b/src/app/components/annotations/components/modals/search-filters/search-filters.component.ts new file mode 100644 index 000000000..ffcdecadb --- /dev/null +++ b/src/app/components/annotations/components/modals/search-filters/search-filters.component.ts @@ -0,0 +1,90 @@ +import { Component, Input } from "@angular/core"; +import { AnnotationSearchParameters } from "@components/annotations/pages/annotationSearchParameters"; +import { ModalComponent } from "@menu/widget.component"; +import { Project } from "@models/Project"; +import { Region } from "@models/Region"; +import { Site } from "@models/Site"; +import { NgbActiveModal } from "@ng-bootstrap/ng-bootstrap"; + +@Component({ + selector: "baw-search-filters-modal", + template: ` + + + + + + `, +}) +export class SearchFiltersModalComponent implements ModalComponent { + @Input() public modal: NgbActiveModal; + @Input() public formValue: AnnotationSearchParameters; + @Input() public successCallback: (newModel: AnnotationSearchParameters) => void; + + @Input() public project: Project; + @Input() public region: Region; + @Input() public site: Site; + @Input() public hasDecisions: boolean; + + protected isFormDirty = true; + + protected get isDirty(): boolean { + return this.isFormDirty && this.hasDecisions; + } + + public closeModal(): void { + this.modal.close(); + } + + public success(): void { + if (this.isFormDirty) { + this.successCallback(this.formValue); + this.closeModal(); + return; + } + + this.closeModal(); + } +} diff --git a/src/app/components/annotations/pages/annotationSearchParameters.spec.ts b/src/app/components/annotations/pages/annotationSearchParameters.spec.ts new file mode 100644 index 000000000..e9750360c --- /dev/null +++ b/src/app/components/annotations/pages/annotationSearchParameters.spec.ts @@ -0,0 +1,8 @@ +import { AnnotationSearchParameters } from "./annotationSearchParameters"; + +describe("annotationSearchParameters", () => { + it("should create", () => { + const dataModel = new AnnotationSearchParameters(); + expect(dataModel).toBeInstanceOf(AnnotationSearchParameters); + }); +}); diff --git a/src/app/components/annotations/pages/annotationSearchParameters.ts b/src/app/components/annotations/pages/annotationSearchParameters.ts new file mode 100644 index 000000000..1d6b91dfe --- /dev/null +++ b/src/app/components/annotations/pages/annotationSearchParameters.ts @@ -0,0 +1,267 @@ +import { Injector } from "@angular/core"; +import { Params } from "@angular/router"; +import { Filters, InnerFilter } from "@baw-api/baw-api.service"; +import { + AUDIO_RECORDING, + PROJECT, + SHALLOW_REGION, + SHALLOW_SITE, + TAG, +} from "@baw-api/ServiceTokens"; +import { MonoTuple } from "@helpers/advancedTypes"; +import { filterEventRecordingDate } from "@helpers/filters/audioEventFilters"; +import { filterAnd, filterModelIds } from "@helpers/filters/filters"; +import { + deserializeParamsToObject, + IQueryStringParameterSpec, + jsBoolean, + jsNumber, + jsNumberArray, + luxonDateArray, + luxonDurationArray, + serializeObjectToParams, +} from "@helpers/query-string-parameters/query-string-parameters"; +import { CollectionIds, Id } from "@interfaces/apiInterfaces"; +import { AbstractData } from "@models/AbstractData"; +import { hasMany } from "@models/AssociationDecorators"; +import { AudioEvent } from "@models/AudioEvent"; +import { AudioRecording } from "@models/AudioRecording"; +import { IParameterModel } from "@models/data/parametersModel"; +import { ImplementsInjector } from "@models/ImplementsInjector"; +import { Project } from "@models/Project"; +import { Region } from "@models/Region"; +import { Site } from "@models/Site"; +import { Tag } from "@models/Tag"; +import { DateTime, Duration } from "luxon"; + +export interface IAnnotationSearchParameters { + audioRecordings: CollectionIds; + tags: CollectionIds; + onlyUnverified: boolean; + daylightSavings: boolean; + recordingDate: MonoTuple; + recordingTime: MonoTuple; + + // these parameters are used to filter by project, region, and site in the + // query string parameters + // e.g. /annotations?projects=1,2,3®ions=4,5,6&sites=7,8,9 + projects: CollectionIds; + regions: CollectionIds; + sites: CollectionIds; + + // these parameters are used to filter by project, region, and site in the + // route parameters + // e.g. /projects/1/regions/2/sites + // these exist in addition with the query string parameters to allow for + // search parameters such as + // /projects/1/regions/2?sites=3,4,5 + routeProjectId: Id; + routeRegionId: Id; + routeSiteId: Id; + + // TODO: this is a placeholder for future implementation once the api + // supports filtering by event date time + // https://github.com/QutEcoacoustics/baw-server/issues/687 + eventDate: MonoTuple; + eventTime: MonoTuple; +} + +// we exclude project, region, and site from the serialization table because +// we do not want them emitted in the query string +const serializationTable: IQueryStringParameterSpec< + Partial +> = { + audioRecordings: jsNumberArray, + tags: jsNumberArray, + onlyUnverified: jsBoolean, + daylightSavings: jsBoolean, + recordingDate: luxonDateArray, + recordingTime: luxonDurationArray, + + // because the serialization of route parameters is handled by the angular + // router, we only want to serialize the model filter query string parameters + projects: jsNumberArray, + regions: jsNumberArray, + sites: jsNumberArray, +}; + +const deserializationTable: IQueryStringParameterSpec< + Partial +> = { + ...serializationTable, + + routeProjectId: jsNumber, + routeRegionId: jsNumber, + routeSiteId: jsNumber, +}; + +export class AnnotationSearchParameters + extends AbstractData + implements + IAnnotationSearchParameters, + ImplementsInjector, + IParameterModel +{ + public constructor( + protected queryStringParameters: Params = {}, + public injector?: Injector + ) { + const deserializedObject: IAnnotationSearchParameters = + deserializeParamsToObject( + queryStringParameters, + deserializationTable + ); + + const objectData = {}; + const objectKeys = Object.keys(deserializedObject); + for (const key of objectKeys) { + objectData[key] = deserializedObject[key]; + } + + super(objectData); + } + + public audioRecordings: CollectionIds; + public tags: CollectionIds; + public onlyUnverified: boolean; + public daylightSavings: boolean; + public recordingDate: MonoTuple; + public recordingTime: MonoTuple; + + public projects: CollectionIds; + public regions: CollectionIds; + public sites: CollectionIds; + + public routeProjectId: Id; + public routeRegionId: Id; + public routeSiteId: Id; + + // TODO: this is a placeholder for future implementation once the api + // supports filtering by event date time + // https://github.com/QutEcoacoustics/baw-server/issues/687 + public eventDate: MonoTuple; + public eventTime: MonoTuple; + + @hasMany( + AUDIO_RECORDING, + "audioRecordings" + ) + public audioRecordingModels?: AudioRecording[]; + @hasMany(PROJECT, "projects") + public projectModels?: Project[]; + @hasMany(SHALLOW_REGION, "regions") + public regionModels?: Region[]; + @hasMany(SHALLOW_SITE, "sites") + public siteModels?: Site[]; + @hasMany(TAG, "tags") + public tagModels?: Tag[]; + + // TODO: use resolvers here once the association resolver decorators return a promise + // see: https://github.com/QutEcoacoustics/workbench-client/issues/2148 + // @hasOne(PROJECT, "routeProjectId") + public routeProjectModel?: Project; + // @hasOne(SHALLOW_REGION, "routeRegionId") + public routeRegionModel?: Region; + // @hasOne(SHALLOW_SITE, "routeSiteId") + public routeSiteModel?: Site; + + public get recordingDateStartedAfter(): DateTime | null { + return this.recordingDate ? this.recordingDate[0] : null; + } + + public get recordingDateFinishedBefore(): DateTime | null { + return this.recordingDate ? this.recordingDate[1] : null; + } + + public get recordingTimeStartedAfter(): Duration | null { + return this.recordingTime ? this.recordingTime[0] : null; + } + + public get recordingTimeFinishedBefore(): Duration | null { + return this.recordingTime ? this.recordingTime[1] : null; + } + + // TODO: fix up this function + public toFilter(): Filters { + const tagFilters = filterModelIds("tags", this.tags); + const dateTimeFilters = this.recordingDateTimeFilters(tagFilters); + const routeModelFilter = filterAnd(dateTimeFilters, this.routeFilters()); + const filter = this.eventDateTimeFilters(routeModelFilter); + return { filter }; + } + + public toQueryParams(): Params { + return serializeObjectToParams( + this, + serializationTable + ); + } + + public modelFilters(): InnerFilter { + if (this.siteModels.length > 0) { + return filterModelIds("sites", this.sites); + } else if (this.regionModels.length > 0) { + return filterModelIds("regions", this.regions); + } else { + return filterModelIds("projects", this.projects); + } + } + + public routeFilters(): InnerFilter { + let siteIds: number[] = []; + + // because this filter is constructed for audio events, but the project + // model is associated with the audio recording model, we need to do a + // association of an association filter + // e.g. audioRecordings.projects.id: { in: [1, 2, 3] } + // however, the api doesn't currently support this functionality + // therefore, we do a virtual join by filtering on the project/region site + // ids on the client + if (this.routeSiteId) { + siteIds = [this.routeSiteId]; + } else if (this.routeRegionId) { + siteIds = Array.from(this.routeRegionModel.siteIds); + } else { + siteIds = Array.from(this.routeProjectModel.siteIds); + } + + return { + "audioRecordings.siteId": { + in: siteIds, + }, + } as any; + } + + private recordingDateTimeFilters( + initialFilter: InnerFilter + ): InnerFilter { + const dateFilter = filterEventRecordingDate( + initialFilter, + this.recordingDateStartedAfter, + this.recordingDateFinishedBefore + ); + + // time filtering is currently disabled until we can filter on custom fields + // and association + // see https://github.com/QutEcoacoustics/baw-server/issues/689 + // TODO: enable time filtering once the api adds support + // + // const dateTimeFilter = filterTime( + // dateFilter, + // this.daylightSavings, + // this.recordingTimeStartedAfter, + // this.recordingTimeFinishedBefore + // ); + + return dateFilter; + } + + // TODO: this function is a placeholder for future implementation once the api + // supports filtering by event date time + // https://github.com/QutEcoacoustics/baw-server/issues/687 + private eventDateTimeFilters( + initialFilter: InnerFilter + ): InnerFilter { + return initialFilter; + } +} diff --git a/src/app/components/annotations/pages/search/search.component.html b/src/app/components/annotations/pages/search/search.component.html new file mode 100644 index 000000000..870584aa4 --- /dev/null +++ b/src/app/components/annotations/pages/search/search.component.html @@ -0,0 +1,73 @@ +

Annotation Search

+ +
+ + +
+ + + + + + +
+
+ +
+ +
+ + @if (!loading) { @if (searchResults.length > 0) { +

+ Displaying + {{ searchResults.length }} of + all {{ paginationInformation?.total }} audio + events. +

+ } } @else { +

Loading...

+ } + +
+ @for (verificationModel of searchResults; track $index) { +
+ +
+ } +
+ + @if (searchResults.length === 0 && !loading) { +

+ No {{ searchParameters.onlyUnverified ? "unverified" : "" }} annotations + found +

+ } @if(displayPagination) { + + } +
+ +
+ + + + diff --git a/src/app/components/annotations/pages/search/search.component.scss b/src/app/components/annotations/pages/search/search.component.scss new file mode 100644 index 000000000..66af1791f --- /dev/null +++ b/src/app/components/annotations/pages/search/search.component.scss @@ -0,0 +1,13 @@ +.annotations-grid { + display: flex; + justify-content: space-evenly; + flex-wrap: wrap; + + position: relative; + gap: 1em; + min-height: 2rem; +} + +.annotation-grid-controls { + position: relative; +} diff --git a/src/app/components/annotations/pages/search/search.component.spec.ts b/src/app/components/annotations/pages/search/search.component.spec.ts new file mode 100644 index 000000000..f7a399f7a --- /dev/null +++ b/src/app/components/annotations/pages/search/search.component.spec.ts @@ -0,0 +1,210 @@ +import { createRoutingFactory, Spectator, SpyObject } from "@ngneat/spectator"; +import { Params } from "@angular/router"; +import { of } from "rxjs"; +import { CUSTOM_ELEMENTS_SCHEMA, INJECTOR, Injector } from "@angular/core"; +import { modelData } from "@test/helpers/faker"; +import { MEDIA, SHALLOW_AUDIO_EVENT, SHALLOW_SITE } from "@baw-api/ServiceTokens"; +import { MockBawApiModule } from "@baw-api/baw-apiMock.module"; +import { SharedModule } from "@shared/shared.module"; +import { RouterTestingModule } from "@angular/router/testing"; +import { Project } from "@models/Project"; +import { Region } from "@models/Region"; +import { Site } from "@models/Site"; +import { generateProject } from "@test/fakes/Project"; +import { generateRegion } from "@test/fakes/Region"; +import { generateSite } from "@test/fakes/Site"; +import { fakeAsync } from "@angular/core/testing"; +import { SpectrogramComponent } from "@ecoacoustics/web-components/@types/components/spectrogram/spectrogram"; +import { getElementByInnerText } from "@test/helpers/html"; +import { Filters, Meta } from "@baw-api/baw-api.service"; +import { ShallowAudioEventsService } from "@baw-api/audio-event/audio-events.service"; +import { AudioEvent } from "@models/AudioEvent"; +import { generateAudioEvent } from "@test/fakes/AudioEvent"; +import { generateAnnotationSearchUrlParameters } from "@test/fakes/data/AnnotationSearchParameters"; +import { AnnotationService } from "@services/models/annotation.service"; +import { Annotation } from "@models/data/Annotation"; +import { generateAnnotation } from "@test/fakes/data/Annotation"; +import { MediaService } from "@services/media/media.service"; +import { AudioRecording } from "@models/AudioRecording"; +import { generateAudioRecording } from "@test/fakes/AudioRecording"; +import { ShallowSitesService } from "@baw-api/site/sites.service"; +import { patchSharedArrayBuffer } from "src/patches/tests/testPatches"; +import { testAsset } from "@test/helpers/karma"; +import { AnnotationSearchParameters } from "../annotationSearchParameters"; +import { AnnotationSearchComponent } from "./search.component"; + +describe("AnnotationSearchComponent", () => { + const responsePageSize = 24; + + let spectator: Spectator; + let injector: Injector; + + let audioEventsApiSpy: SpyObject; + let mediaServiceSpy: SpyObject; + let shallowSiteSpy: SpyObject; + + let mockAudioEventsResponse: AudioEvent[] = []; + let mockAnnotationResponse: Annotation; + let mockSearchParameters: AnnotationSearchParameters; + let mockAudioRecording: AudioRecording; + + let routeProject: Project; + let routeRegion: Region; + let routeSite: Site; + + const createComponent = createRoutingFactory({ + component: AnnotationSearchComponent, + imports: [MockBawApiModule, SharedModule, RouterTestingModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }); + + function setup(queryParameters: Params = {}): void { + spectator = createComponent({ + detectChanges: false, + params: { + projectId: routeProject.id, + regionId: routeRegion.id, + siteId: routeSite.id, + }, + providers: [ + { + provide: AnnotationService, + useValue: { show: () => mockAnnotationResponse }, + }, + ], + queryParams: queryParameters, + }); + + injector = spectator.inject(INJECTOR); + mediaServiceSpy = spectator.inject(MEDIA.token); + mediaServiceSpy.createMediaUrl = jasmine.createSpy("createMediaUrl") as any; + mediaServiceSpy.createMediaUrl.and.returnValue(testAsset("example.flac")); + + spectator.component.searchParameters = mockSearchParameters; + + mockAudioEventsResponse = modelData.randomArray( + responsePageSize, + responsePageSize, + () => { + const model = new AudioEvent(generateAudioEvent(), injector); + const metadata: Meta = { + paging: { + items: responsePageSize, + page: 1, + total: responsePageSize, + maxPage: modelData.datatype.number({ min: 1, max: 5 }), + }, + }; + + model.addMetadata(metadata); + + return model; + } + ); + + mockAudioRecording = new AudioRecording( + generateAudioRecording({ + siteId: routeSite.id, + }), + injector + ); + + mockAnnotationResponse = new Annotation( + generateAnnotation({ + audioRecording: mockAudioRecording, + }), + mediaServiceSpy + ); + + audioEventsApiSpy = spectator.inject(SHALLOW_AUDIO_EVENT.token); + audioEventsApiSpy.filter.andCallFake(() => of(mockAudioEventsResponse)); + + shallowSiteSpy = spectator.inject(SHALLOW_SITE.token); + shallowSiteSpy.show.andCallFake(() => of(routeSite)); + + spectator.detectChanges(); + } + + const spectrogramElements = () => + spectator.queryAll("oe-spectrogram"); + + beforeEach(fakeAsync(() => { + patchSharedArrayBuffer(); + + routeProject = new Project(generateProject()); + routeRegion = new Region(generateRegion()); + routeSite = new Site(generateSite()); + + mockSearchParameters = new AnnotationSearchParameters( + generateAnnotationSearchUrlParameters() + ); + mockSearchParameters.routeProjectModel = routeProject; + mockSearchParameters.routeRegionModel = routeRegion; + mockSearchParameters.routeSiteModel = routeSite; + + setup(); + })); + + it("should create", () => { + expect(spectator.component).toBeInstanceOf(AnnotationSearchComponent); + }); + + it("should make the correct api call", () => { + const expectedBody: Filters = { + paging: { + page: 1, + items: responsePageSize, + }, + filter: { + and: [ + { + "tags.id": { + in: Array.from(mockSearchParameters.tags), + }, + }, + { + "audioRecordings.siteId": { + in: Array.from(routeProject.siteIds), + }, + }, + ], + }, + } as any; + + expect(audioEventsApiSpy.filter).toHaveBeenCalledWith(expectedBody); + }); + + it("should display an error if there are no search results", () => { + const expectedText = "No annotations found"; + + spectator.component.searchResults = []; + spectator.component.loading = false; + spectator.detectChanges(); + + const element = getElementByInnerText(spectator, expectedText); + expect(element).toExist(); + }); + + it("should not display an error if the search results are still loading", () => { + const expectedText = "No annotations found"; + + spectator.component.searchResults = []; + spectator.component.loading = true; + spectator.detectChanges(); + + const element = getElementByInnerText(spectator, expectedText); + expect(element).not.toExist(); + }) + + it("should display a page of search results", () => { + spectator.detectChanges(); + + const expectedResults = mockAudioEventsResponse.length; + const realizedResults = spectrogramElements().length; + expect(realizedResults).toEqual(expectedResults); + }); + + it("should add a query string parameters when a filter condition is added", () => {}); + + it("should remove a query string parameter when a filter condition is removed", () => {}); +}); diff --git a/src/app/components/annotations/pages/search/search.component.ts b/src/app/components/annotations/pages/search/search.component.ts new file mode 100644 index 000000000..3eed96d15 --- /dev/null +++ b/src/app/components/annotations/pages/search/search.component.ts @@ -0,0 +1,216 @@ +import { + Component, + ElementRef, + Injector, + OnInit, + ViewChild, +} from "@angular/core"; +import { projectResolvers } from "@baw-api/project/projects.service"; +import { siteResolvers } from "@baw-api/site/sites.service"; +import { annotationMenuItems } from "@components/annotations/annotation.menu"; +import { IPageInfo } from "@helpers/page/pageInfo"; +import { retrieveResolvers } from "@baw-api/resolver-common"; +import { ActivatedRoute, Params, Router } from "@angular/router"; +import { Paging } from "@baw-api/baw-api.service"; +import { StrongRoute } from "@interfaces/strongRoute"; +import { regionResolvers } from "@baw-api/region/regions.service"; +import { FiltersWarningModalComponent } from "@components/annotations/components/modals/filters-warning/filters-warning.component"; +import { PaginationTemplate } from "@helpers/paginationTemplate/paginationTemplate"; +import { NgbModal, NgbPaginationConfig } from "@ng-bootstrap/ng-bootstrap"; +import { ShallowAudioEventsService } from "@baw-api/audio-event/audio-events.service"; +import { AudioEvent } from "@models/AudioEvent"; +import { Annotation } from "@models/data/Annotation"; +import { + annotationResolvers, + AnnotationService, +} from "@services/models/annotation.service"; +import { firstValueFrom } from "rxjs"; +import { Region } from "@models/Region"; +import { Project } from "@models/Project"; +import { Site } from "@models/Site"; +import { AnnotationSearchParameters } from "../annotationSearchParameters"; + +const projectKey = "project"; +const regionKey = "region"; +const siteKey = "site"; +const annotationsKey = "annotations"; + +@Component({ + selector: "baw-annotations-search", + templateUrl: "search.component.html", + styleUrl: "search.component.scss", +}) +class AnnotationSearchComponent + extends PaginationTemplate + implements OnInit +{ + public constructor( + protected audioEventApi: ShallowAudioEventsService, + protected route: ActivatedRoute, + protected router: Router, + protected config: NgbPaginationConfig, + protected modals: NgbModal, + protected annotationService: AnnotationService, + private injector: Injector + ) { + super( + router, + route, + config, + audioEventApi, + "id", + () => [], + async (newResults: AudioEvent[]) => { + this.loading = true; + this.searchResults = await Promise.all( + newResults.map(async (result) => await annotationService.show(result)) + ); + + if (newResults.length > 0) { + this.paginationInformation = newResults[0].getMetadata().paging; + } + this.loading = false; + }, + () => this.searchParameters.toFilter().filter + ); + + // we make the page size an even number so that the page of results is more + // likely to fit on the screen in a nice way + this.pageSize = 24; + } + + @ViewChild("broadSearchWarningModal") + public broadFilterWarningModal: ElementRef; + + public searchParameters: AnnotationSearchParameters; + protected paginationInformation: Paging; + public searchResults: Annotation[] = []; + protected verificationRoute: StrongRoute; + + public ngOnInit(): void { + const models = retrieveResolvers(this.route.snapshot.data as IPageInfo); + this.searchParameters ??= models[ + annotationsKey + ] as AnnotationSearchParameters; + this.searchParameters.injector = this.injector; + + this.searchParameters.routeProjectModel ??= models[projectKey] as Project; + if (models[regionKey]) { + this.searchParameters.routeRegionModel ??= models[regionKey] as Region; + } + if (models[siteKey]) { + this.searchParameters.routeSiteModel ??= models[siteKey] as Site; + } + + this.verificationRoute = this.verifyAnnotationsRoute(); + + // calling the pagination templates ngOnInit() method causes the first page + // of results to be fetched and displayed + super.ngOnInit(); + } + + // the PaginationTemplate that we extend only supports a single filter + // query string parameter e.g. a projects name + // since we have multiple conditions, I have overridden the updateQueryParms + // method + // + // TODO: the correct fix here would be to add support for any length qsps + // to the pagination template + protected override updateQueryParams(page: number): void { + const queryParams: Params = this.searchParameters.toQueryParams(); + + // we have this condition so that undefined page numbers and + // the first (default) page number is not shown in the query parameters + if (page && page !== 1) { + queryParams.page = page; + } + + this.router.navigate([], { relativeTo: this.route, queryParams }); + } + + protected updateSearchParameters(model: AnnotationSearchParameters): void { + this.searchParameters = model; + this.updateQueryParams(this.page); + } + + protected async navigateToVerificationGrid(): Promise { + const queryParameters = this.searchParameters.toQueryParams(); + const numberOfParameters = Object.keys(queryParameters).length; + + // if the user has not added any search filters, we want to confirm that the + // user wanted to create a verification task over all annotations in the + // project, region or site + if (numberOfParameters === 0) { + // if the verification task has less than 1,000 annotations, we don't need + // to show an error modal + const request = this.audioEventApi.filter({ + filter: this.searchParameters.toFilter().filter, + paging: { items: 1 }, + }); + + const response = await firstValueFrom(request); + + const itemWarningThreshold = 1_000 as const; + const responseMetadata = response[0].getMetadata(); + const numberOfItems = responseMetadata.paging.total; + + if (numberOfItems > itemWarningThreshold) { + const warningModal = this.modals.open(this.broadFilterWarningModal); + const success = await warningModal.result.catch((_) => false); + + // the user doesn't want to continue with the search + // we return early to prevent router navigation + if (!success) { + return; + } + } + } + + const queryParams = this.searchParameters.toQueryParams(); + + this.router.navigate( + [ + this.verificationRoute.toRouterLink({ + projectId: this.searchParameters.routeProjectId, + regionId: this.searchParameters.routeRegionId, + siteId: this.searchParameters.routeSiteId, + }), + ], + { queryParams } + ); + } + + protected verifyAnnotationsRoute(): StrongRoute { + if (this.searchParameters.routeSiteId) { + return this.searchParameters.routeSiteModel.isPoint + ? annotationMenuItems.verify.siteAndRegion.route + : annotationMenuItems.verify.site.route; + } else if (this.searchParameters.routeRegionId) { + return annotationMenuItems.verify.region.route; + } + + return annotationMenuItems.verify.project.route; + } +} + +function getPageInfo( + subRoute: keyof typeof annotationMenuItems.search +): IPageInfo { + return { + pageRoute: annotationMenuItems.search[subRoute], + category: annotationMenuItems.search[subRoute], + resolvers: { + [projectKey]: projectResolvers.showOptional, + [regionKey]: regionResolvers.showOptional, + [siteKey]: siteResolvers.showOptional, + [annotationsKey]: annotationResolvers.showOptional, + }, + }; +} + +AnnotationSearchComponent.linkToRoute(getPageInfo("project")) + .linkToRoute(getPageInfo("region")) + .linkToRoute(getPageInfo("site")) + .linkToRoute(getPageInfo("siteAndRegion")); + +export { AnnotationSearchComponent }; diff --git a/src/app/components/annotations/pages/verification/verification.component.html b/src/app/components/annotations/pages/verification/verification.component.html new file mode 100644 index 000000000..de27cb599 --- /dev/null +++ b/src/app/components/annotations/pages/verification/verification.component.html @@ -0,0 +1,70 @@ + +
+
+

+ @if (!verificationGridFocused) { + + } Annotation Verification +

+
+ + +
+ + + + + + + + + + + + + + + + + + diff --git a/src/app/components/annotations/pages/verification/verification.component.scss b/src/app/components/annotations/pages/verification/verification.component.scss new file mode 100644 index 000000000..5f88f0599 --- /dev/null +++ b/src/app/components/annotations/pages/verification/verification.component.scss @@ -0,0 +1,21 @@ +// TODO: This should probably be computed so that if the navbar height changes +// in the future, the header height will update correctly +// we probably shouldn't compute it at runtime because it it unlikely to change +// during runtime, but we should make the header height a computed variable +// that is defined by the height of the `.nav` bootstrap class +$header-height: 56px; + +#verification-grid { + // this should be be 100dvh subtracting the height of the header + height: calc(100vh - #{$header-height}); +} + +.header-content { + display: flex; + justify-content: space-between; + align-items: center; +} + +.filter-button { + height: fit-content; +} diff --git a/src/app/components/annotations/pages/verification/verification.component.spec.ts b/src/app/components/annotations/pages/verification/verification.component.spec.ts new file mode 100644 index 000000000..b04e66c34 --- /dev/null +++ b/src/app/components/annotations/pages/verification/verification.component.spec.ts @@ -0,0 +1,368 @@ +import { + createRoutingFactory, + SpectatorRouting, + SpyObject, +} from "@ngneat/spectator"; +import { MockBawApiModule } from "@baw-api/baw-apiMock.module"; +import { SharedModule } from "@shared/shared.module"; +import { Project } from "@models/Project"; +import { Region } from "@models/Region"; +import { Site } from "@models/Site"; +import { Params } from "@angular/router"; +import { of } from "rxjs"; +import { generateProject } from "@test/fakes/Project"; +import { generateRegion } from "@test/fakes/Region"; +import { generateSite } from "@test/fakes/Site"; +import { assertPageInfo } from "@test/helpers/pageRoute"; +import { + MEDIA, + PROJECT, + SHALLOW_AUDIO_EVENT, + SHALLOW_REGION, + SHALLOW_SITE, + TAG, +} from "@baw-api/ServiceTokens"; +import { CUSTOM_ELEMENTS_SCHEMA, INJECTOR, Injector } from "@angular/core"; +import { TagsService } from "@baw-api/tag/tags.service"; +import { VerificationGridComponent } from "@ecoacoustics/web-components/@types/components/verification-grid/verification-grid"; +import { VerificationHelpDialogComponent } from "@ecoacoustics/web-components/@types/components/verification-grid/help-dialog"; +import { modelData } from "@test/helpers/faker"; +import { Tag } from "@models/Tag"; +import { discardPeriodicTasks, fakeAsync, flush, tick } from "@angular/core/testing"; +import { generateTag } from "@test/fakes/Tag"; +import { RouterTestingModule } from "@angular/router/testing"; +import { selectFromTypeahead } from "@test/helpers/html"; +import { ShallowAudioEventsService } from "@baw-api/audio-event/audio-events.service"; +import { AudioEvent } from "@models/AudioEvent"; +import { generateAudioEvent } from "@test/fakes/AudioEvent"; +import { AnnotationService } from "@services/models/annotation.service"; +import { AudioRecording } from "@models/AudioRecording"; +import { Annotation } from "@models/data/Annotation"; +import { generateAudioRecording } from "@test/fakes/AudioRecording"; +import { generateAnnotation } from "@test/fakes/data/Annotation"; +import { MediaService } from "@services/media/media.service"; +import { generateAnnotationSearchUrlParameters } from "@test/fakes/data/AnnotationSearchParameters"; +import { NgbModal, NgbModalConfig } from "@ng-bootstrap/ng-bootstrap"; +import { AnnotationSearchFormComponent } from "@components/annotations/components/annotation-search-form/annotation-search-form.component"; +import { SearchFiltersModalComponent } from "@components/annotations/components/modals/search-filters/search-filters.component"; +import { ShallowRegionsService } from "@baw-api/region/regions.service"; +import { ShallowSitesService } from "@baw-api/site/sites.service"; +import { ProjectsService } from "@baw-api/project/projects.service"; +import { detectChanges } from "@test/helpers/changes"; +import { nodeModule, testAsset } from "@test/helpers/karma"; +import { patchSharedArrayBuffer } from "src/patches/tests/testPatches"; +import { ProgressWarningComponent } from "@components/annotations/components/modals/progress-warning/progress-warning.component"; +import { AnnotationSearchParameters } from "../annotationSearchParameters"; +import { VerificationComponent } from "./verification.component"; + +describe("VerificationComponent", () => { + let spectator: SpectatorRouting; + let injector: SpyObject; + + let mockAudioEventsApi: SpyObject; + let mediaServiceSpy: SpyObject; + + let tagsApiSpy: SpyObject; + let projectApiSpy: SpyObject; + let regionApiSpy: SpyObject; + let sitesApiSpy: SpyObject; + + let modalsSpy: NgbModal; + let modalConfigService: NgbModalConfig; + + let routeProject: Project; + let routeRegion: Region; + let routeSite: Site; + + let mockSearchParameters: AnnotationSearchParameters; + let mockAudioEventsResponse: AudioEvent[] = []; + let defaultFakeTags: Tag[]; + let mockAudioRecording: AudioRecording; + let mockAnnotationResponse: Annotation; + + const createComponent = createRoutingFactory({ + component: VerificationComponent, + imports: [MockBawApiModule, SharedModule, RouterTestingModule], + declarations: [ + SearchFiltersModalComponent, + ProgressWarningComponent, + AnnotationSearchFormComponent, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }); + + async function setup(queryParameters: Params = {}) { + spectator = createComponent({ + detectChanges: false, + params: { + projectId: routeProject.id, + regionId: routeRegion.id, + siteId: routeSite.id, + }, + providers: [ + { + provide: AnnotationService, + useValue: { show: () => mockAnnotationResponse }, + }, + ], + queryParams: queryParameters, + }); + + injector = spectator.inject(INJECTOR); + + mediaServiceSpy = spectator.inject(MEDIA.token); + mediaServiceSpy.createMediaUrl = jasmine.createSpy("createMediaUrl") as any; + mediaServiceSpy.createMediaUrl.and.returnValue(testAsset("example.flac")); + + mockSearchParameters = new AnnotationSearchParameters( + generateAnnotationSearchUrlParameters(queryParameters), + injector + ); + mockSearchParameters.routeSiteModel = routeSite; + mockSearchParameters.routeSiteId = routeSite.id; + + mockSearchParameters.routeRegionModel = routeRegion; + mockSearchParameters.routeRegionId = routeRegion.id; + + mockSearchParameters.routeProjectModel = routeProject; + mockSearchParameters.routeProjectId = routeProject.id; + + defaultFakeTags = modelData.randomArray( + 3, + 10, + () => new Tag(generateTag(), injector) + ); + + mockAudioEventsResponse = modelData.randomArray( + 3, + 3, + () => new AudioEvent(generateAudioEvent(), injector) + ); + + mockAudioRecording = new AudioRecording( + generateAudioRecording({ siteId: routeSite.id }), + injector + ); + + mockAnnotationResponse = new Annotation( + generateAnnotation({ audioRecording: mockAudioRecording }), + mediaServiceSpy + ); + + spectator.component.searchParameters = mockSearchParameters; + spectator.component.project = routeProject; + spectator.component.region = routeRegion; + spectator.component.site = routeSite; + + mockAudioEventsApi = spectator.inject(SHALLOW_AUDIO_EVENT.token); + tagsApiSpy = spectator.inject(TAG.token); + projectApiSpy = spectator.inject(PROJECT.token); + regionApiSpy = spectator.inject(SHALLOW_REGION.token); + sitesApiSpy = spectator.inject(SHALLOW_SITE.token); + + // inject the bootstrap modal config service so that we can disable animations + // this is needed so that buttons can be clicked without waiting for the async animation + modalsSpy = spectator.inject(NgbModal); + modalConfigService = spectator.inject(NgbModalConfig); + modalConfigService.animation = false; + + // TODO: this should probably be replaced with callThrough() + modalsSpy.open = jasmine.createSpy("open").and.callFake(modalsSpy.open); + + // needed for AnnotationSearchParameters associated models + mockAudioEventsApi.filter.and.callFake(() => of(mockAudioEventsResponse)); + tagsApiSpy.filter.and.callFake(() => of(defaultFakeTags)); + projectApiSpy.filter.and.callFake(() => of([routeProject])); + regionApiSpy.filter.and.callFake(() => of([routeRegion])); + sitesApiSpy.filter.and.callFake(() => of([routeSite])); + + await detectChanges(spectator); + } + + beforeEach(async () => { + patchSharedArrayBuffer(); + + // we import the web components using a dynamic import statement so that + // the web components are loaded through the karma test server + // + // we also use the webpackIgnore comment so that the webpack bundler does + // not bundle the web components when dynamically imported + // if we were to bundle the assets first, the web components would be served + // under the __karma_webpack__ sub-path, but workers dynamically loaded by + // the web components would be served under the root path + // + // under some circumstances, Karma will re-use the same browser instance + // between tests. Meaning that the custom element can registration can + // persist between multiple tests. + // to prevent re-declaring the same custom element, we conditionally + // import the web components only if they are not already defined + if (!customElements.get("oe-verification-grid")) { + await import( + /* webpackIgnore: true */ nodeModule( + "@ecoacoustics/web-components/dist/components.js" + ) + ); + } + + routeProject = new Project(generateProject()); + routeRegion = new Region(generateRegion({ projectId: routeProject.id })); + routeSite = new Site(generateSite({ regionId: routeRegion.id })); + }); + + afterEach(() => { + // modals can persist between tests, meaning that we might have multiple + // modal windows open at the same time if we do not explicitly dismiss them + // after each test + modalsSpy?.dismissAll(); + }); + + const dialogToggleButton = () => + spectator.query(".filter-button"); + + // when + const tagsTypeahead = () => + document.querySelector("#tags-input"); + const updateFiltersButton = () => + document.querySelector("#update-filters-btn"); + + const verificationGrid = () => + spectator.query("oe-verification-grid"); + const verificationGridRoot = (): ShadowRoot => verificationGrid().shadowRoot; + + // a lot of the web components elements of interest are in the shadow DOM + // therefore, we have to chain some query selectors to get to the elements + const helpElement = (): VerificationHelpDialogComponent => + verificationGridRoot().querySelector("oe-verification-help-dialog"); + const helpCloseButton = (): HTMLButtonElement => + helpElement().shadowRoot.querySelector(".close-btn"); + + function toggleParameters(): void { + spectator.click(dialogToggleButton()); + tick(1_000); + discardPeriodicTasks(); + } + + assertPageInfo(VerificationComponent, "Verify Annotations"); + + // if this test fails, the test runners server might not be running with the + // correct headers + // see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements + xit("should have sharedArrayBuffer defined", () => { + // note that this test does not use the setup() function + expect(SharedArrayBuffer).toBeDefined(); + }); + + it("should create", async () => { + await setup(); + expect(spectator.component).toBeInstanceOf(VerificationComponent); + }); + + describe("search parameters", () => { + describe("no initial search parameters", () => { + beforeEach(async () => { + await setup(); + + helpCloseButton().click(); + await detectChanges(spectator); + }); + + // TODO: fix this test. Something is leaking causing there to be no results in the dropdown + xit("should update the search parameters when filter conditions are added", fakeAsync(() => { + const targetTag = defaultFakeTags[0]; + const tagText = targetTag.text; + const expectedTagId = targetTag.id; + + toggleParameters(); + selectFromTypeahead(spectator, tagsTypeahead(), tagText); + + const realizedComponentParams = spectator.component.searchParameters; + expect(realizedComponentParams.tags).toContain(expectedTagId); + })); + + it("should show and hide the search paramters dialog correctly", fakeAsync(() => { + expect(modalsSpy.open).not.toHaveBeenCalled(); + toggleParameters(); + expect(modalsSpy.open).toHaveBeenCalledTimes(1); + })); + }); + + describe("with initial search parameters", () => { + let mockTagIds: number[]; + + beforeEach(async () => { + mockTagIds = modelData.ids(); + + const testedQueryParameters: Params = { + tags: mockTagIds.join(","), + }; + + // we recreate the fixture with query parameters so that we can test + // the component's behavior when query parameters are present + // on load + await setup(testedQueryParameters); + + helpCloseButton().click(); + await detectChanges(spectator); + }); + + it("should create the correct search parameter model from query string parameters", () => { + const realizedParameterModel = spectator.component.searchParameters; + + expect(realizedParameterModel).toEqual( + jasmine.objectContaining({ + tags: jasmine.arrayContaining(mockTagIds), + }) + ); + }); + + describe("verification grid functionality", () => { + describe("initial state", () => { + it("should be mount all the required Open-Ecoacoustics web components as custom elements", () => { + const expectedCustomElements: string[] = [ + "oe-verification-grid", + "oe-verification-grid-tile", + "oe-verification", + "oe-media-controls", + "oe-indicator", + "oe-axes", + ]; + + for (const selector of expectedCustomElements) { + const customElementClass = customElements.get(selector); + expect(customElementClass).withContext(selector).toBeDefined(); + } + }); + + it("should have the correct grid size target", () => { + const expectedTarget = 10; + const realizedTarget = verificationGrid().targetGridSize; + expect(realizedTarget).toEqual(expectedTarget); + }); + }); + + // TODO: this test seems to fail only when running in CI because the tags typeahead isn't populated correctly + xit("should reset the verification grids getPage function when the search parameters are changed", fakeAsync(() => { + const initialPagingCallback = verificationGrid().getPage; + const targetTag = defaultFakeTags[0]; + const tagText = targetTag.text; + + toggleParameters(); + selectFromTypeahead(spectator, tagsTypeahead(), tagText); + spectator.click(updateFiltersButton()); + detectChanges(spectator); + + const newPagingCallback = verificationGrid().getPage; + expect(newPagingCallback).not.toBe(initialPagingCallback); + + flush(); + discardPeriodicTasks(); + })); + + it("should populate the verification grid correctly for the first page", () => { + const realizedTileCount = verificationGrid().populatedTileCount; + expect(realizedTileCount).toBeGreaterThan(0); + }); + }); + }); + }); +}); diff --git a/src/app/components/annotations/pages/verification/verification.component.ts b/src/app/components/annotations/pages/verification/verification.component.ts new file mode 100644 index 000000000..b3f2c01d6 --- /dev/null +++ b/src/app/components/annotations/pages/verification/verification.component.ts @@ -0,0 +1,269 @@ +import { + AfterViewInit, + Component, + ElementRef, + Injector, + OnInit, + ViewChild, +} from "@angular/core"; +import { + projectResolvers, + ProjectsService, +} from "@baw-api/project/projects.service"; +import { + regionResolvers, + ShallowRegionsService, +} from "@baw-api/region/regions.service"; +import { + ShallowSitesService, + siteResolvers, +} from "@baw-api/site/sites.service"; +import { PageComponent } from "@helpers/page/pageComponent"; +import { IPageInfo } from "@helpers/page/pageInfo"; +import { retrieveResolvers } from "@baw-api/resolver-common"; +import { Project } from "@models/Project"; +import { Region } from "@models/Region"; +import { Site } from "@models/Site"; +import { ActivatedRoute, Router } from "@angular/router"; +import { Location } from "@angular/common"; +import { firstValueFrom } from "rxjs"; +import { annotationMenuItems } from "@components/annotations/annotation.menu"; +import { Filters, InnerFilter, Paging } from "@baw-api/baw-api.service"; +import { VerificationGridComponent } from "@ecoacoustics/web-components/@types/components/verification-grid/verification-grid"; +import { TagsService } from "@baw-api/tag/tags.service"; +import { StrongRoute } from "@interfaces/strongRoute"; +import { ProgressWarningComponent } from "@components/annotations/components/modals/progress-warning/progress-warning.component"; +import { NgbModal } from "@ng-bootstrap/ng-bootstrap"; +import { SearchFiltersModalComponent } from "@components/annotations/components/modals/search-filters/search-filters.component"; +import { UnsavedInputCheckingComponent } from "@guards/input/input.guard"; +import { ShallowAudioEventsService } from "@baw-api/audio-event/audio-events.service"; +import { AudioEvent } from "@models/AudioEvent"; +import { PageFetcherContext } from "@ecoacoustics/web-components/@types/services/gridPageFetcher"; +import { annotationResolvers, AnnotationService } from "@services/models/annotation.service"; +import { AnnotationSearchParameters } from "../annotationSearchParameters"; + +// TODO: using extends here makes the interface loosely typed +// we should some sort of "satisfies" operation instead +interface PagingContext extends PageFetcherContext { + page: number; +} + +const projectKey = "project"; +const regionKey = "region"; +const siteKey = "site"; +const annotationsKey = "annotations"; + +@Component({ + selector: "baw-verification", + templateUrl: "verification.component.html", + styleUrl: "verification.component.scss", +}) +class VerificationComponent + extends PageComponent + implements OnInit, AfterViewInit, UnsavedInputCheckingComponent +{ + public constructor( + public modals: NgbModal, + + protected audioEventApi: ShallowAudioEventsService, + protected projectsApi: ProjectsService, + protected regionsApi: ShallowRegionsService, + protected sitesApi: ShallowSitesService, + protected tagsApi: TagsService, + protected annotationsService: AnnotationService, + + private route: ActivatedRoute, + private router: Router, + private location: Location, + private injector: Injector + ) { + super(); + } + + @ViewChild("progressWarningModal") + private lostProgressWarningModal: ElementRef; + + @ViewChild("searchFiltersModal") + private searchFiltersModal: ElementRef; + + @ViewChild("verificationGrid") + private verificationGridElement: ElementRef; + + public searchParameters: AnnotationSearchParameters; + public hasUnsavedChanges = false; + protected verificationGridFocused = true; + private doneInitialScroll = false; + + public ngOnInit(): void { + const models = retrieveResolvers(this.route.snapshot.data as IPageInfo); + this.searchParameters ??= models[annotationsKey] as AnnotationSearchParameters; + this.searchParameters.injector = this.injector; + + this.searchParameters.routeProjectModel ??= models[projectKey] as Project; + if (models[regionKey]) { + this.searchParameters.routeRegionModel ??= models[regionKey] as Region; + } + if (models[siteKey]) { + this.searchParameters.routeSiteModel ??= models[siteKey] as Site; + } + } + + public ngAfterViewInit(): void { + this.updateGridCallback(); + } + + protected handleGridLoaded(): void { + if (this.doneInitialScroll) { + return; + } + + // because the verification grid keybindings are scoped at the component + // level, we automatically focus the verification grid component so that + // users don't have to manually focus the verification grid to start using + // shortcuts + // + // without this automatic focusing, the user would have to click on the + // verification grid (e.g. to make a sub-selection) before being able to + // use the shortcut keys + this.focusVerificationGrid(); + this.updateGridShape(); + + const timeoutDurationMilliseconds = 1_000 as const; + + // we wait a second after the verification grid has loaded to give the user + // some time to see the grid in the context of the website before we scroll + // them down to the grid + setTimeout(() => { + this.scrollToVerificationGrid(); + }, timeoutDurationMilliseconds); + + // we set the done initial scroll value before the timeout so that we don't + // send two scroll events if the user makes a decision before the timeout + this.doneInitialScroll = true; + } + + protected handleDecision(): void { + this.hasUnsavedChanges = true; + } + + protected focusVerificationGrid(): void { + this.verificationGridElement.nativeElement.focus(); + } + + protected openSearchFiltersModal(): void { + this.modals.open(this.searchFiltersModal, { size: "xl" }); + } + + protected requestModelUpdate(newModel: AnnotationSearchParameters) { + if (!this.hasUnsavedChanges) { + this.searchParameters = newModel; + this.updateGridCallback(); + return; + } + + // if the user has unsaved changes, we want to warn them that their progress + // will be lost if they update the search parameters + const confirmationModal = this.modals.open(this.lostProgressWarningModal); + confirmationModal.result.then((success: boolean) => { + if (success) { + this.searchParameters = newModel; + this.updateGridCallback(); + } else { + this.openSearchFiltersModal(); + } + }); + } + + protected verifyAnnotationsRoute(): StrongRoute { + if (this.site) { + return this.site.isPoint + ? annotationMenuItems.verify.siteAndRegion.route + : annotationMenuItems.verify.site.route; + } else if (this.region) { + return annotationMenuItems.verify.region.route; + } + + return annotationMenuItems.verify.project.route; + } + + protected getPageCallback(): any { + return async ({ page }: PagingContext) => { + const nextPage = (page ?? 0) + 1; + const filters = this.filterConditions(nextPage); + const serviceObservable = this.audioEventApi.filter(filters); + + const items: AudioEvent[] = await firstValueFrom(serviceObservable); + const annotations = await Promise.all( + items.map((item) => this.annotationsService.show(item)) + ); + + return new Object({ + subjects: annotations, + context: { page: nextPage }, + totalItems: items.length, + }); + }; + } + + protected updateGridCallback(): void { + if (!this.verificationGridElement) { + console.error("Could not find verification grid element"); + return; + } + + this.verificationGridElement.nativeElement.getPage = this.getPageCallback(); + this.updateUrlParameters(); + this.hasUnsavedChanges = false; + } + + private updateGridShape(): void { + this.verificationGridElement.nativeElement.targetGridSize = 12; + } + + private scrollToVerificationGrid(): void { + this.verificationGridElement.nativeElement.scrollIntoView({ + behavior: "smooth", + block: "end", + }); + } + + private filterConditions(page: number): Filters { + const filter: InnerFilter = this.searchParameters.toFilter().filter; + const paging: Paging = { page }; + + return { filter, paging }; + } + + private updateUrlParameters(): void { + const queryParams = this.searchParameters.toQueryParams(); + const urlTree = this.router.createUrlTree([], { queryParams }); + + // TODO: remove this guard before review. For some reason urlTree is null during testing + if (urlTree) { + this.location.replaceState(urlTree.toString()); + } + } +} + +function getPageInfo( + subRoute: keyof typeof annotationMenuItems.verify +): IPageInfo { + return { + pageRoute: annotationMenuItems.verify[subRoute], + category: annotationMenuItems.verify[subRoute], + resolvers: { + [projectKey]: projectResolvers.showOptional, + [regionKey]: regionResolvers.showOptional, + [siteKey]: siteResolvers.showOptional, + [annotationsKey]: annotationResolvers.showOptional, + }, + fullscreen: true, + }; +} + +VerificationComponent.linkToRoute(getPageInfo("project")) + .linkToRoute(getPageInfo("region")) + .linkToRoute(getPageInfo("site")) + .linkToRoute(getPageInfo("siteAndRegion")); + +export { VerificationComponent }; diff --git a/src/app/components/profile/profile.menus.ts b/src/app/components/profile/profile.menus.ts index d8ff1ab9d..226dc8648 100644 --- a/src/app/components/profile/profile.menus.ts +++ b/src/app/components/profile/profile.menus.ts @@ -37,7 +37,11 @@ export const myAccountMenuItem = menuRoute({ retrieveResolvedModel(pageInfo, User)?.userName, title: (routeData: RouterStateSnapshot): string => { const componentModel = routeData.root.firstChild.data; - return `${componentModel.account.model.userName}'s Profile`; + + // when viewing another persons profile, the route model will be under the + // "account" key. However, when viewing "my profile", the key will be "user" + const accountModel = componentModel.account ?? componentModel.user; + return `${accountModel.model.userName}'s Profile`; }, }); diff --git a/src/app/components/projects/pages/details/details.component.ts b/src/app/components/projects/pages/details/details.component.ts index 02446386b..6066492d7 100644 --- a/src/app/components/projects/pages/details/details.component.ts +++ b/src/app/components/projects/pages/details/details.component.ts @@ -36,6 +36,7 @@ import { NgbPaginationConfig } from "@ng-bootstrap/ng-bootstrap"; import { List } from "immutable"; import { ToastrService } from "ngx-toastr"; import { merge, Observable, takeUntil } from "rxjs"; +import { annotationMenuItems } from "@components/annotations/annotation.menu"; export const projectMenuItemActions = [ visualizeMenuItem, @@ -49,6 +50,7 @@ export const projectMenuItemActions = [ audioRecordingMenuItems.batch.project, harvestsMenuItem, reportMenuItems.new.project, + annotationMenuItems.search.project, ]; const projectKey = "project"; diff --git a/src/app/components/regions/pages/details/details.component.ts b/src/app/components/regions/pages/details/details.component.ts index 6b5257086..0c4e12965 100644 --- a/src/app/components/regions/pages/details/details.component.ts +++ b/src/app/components/regions/pages/details/details.component.ts @@ -30,6 +30,7 @@ import { ConfigService } from "@services/config/config.service"; import { List } from "immutable"; import { ToastrService } from "ngx-toastr"; import { takeUntil } from "rxjs"; +import { annotationMenuItems } from "@components/annotations/annotation.menu"; export const regionMenuItemActions = [ deleteRegionModal, @@ -39,6 +40,7 @@ export const regionMenuItemActions = [ audioRecordingMenuItems.list.region, audioRecordingMenuItems.batch.region, reportMenuItems.new.region, + annotationMenuItems.search.region, ]; const projectKey = "project"; diff --git a/src/app/components/reports/pages/event-summary/EventSummaryReportParameters.ts b/src/app/components/reports/pages/event-summary/EventSummaryReportParameters.ts index 5a980a784..b9901e9ce 100644 --- a/src/app/components/reports/pages/event-summary/EventSummaryReportParameters.ts +++ b/src/app/components/reports/pages/event-summary/EventSummaryReportParameters.ts @@ -23,7 +23,7 @@ import { luxonDurationArray, jsStringArray, } from "@helpers/query-string-parameters/query-string-parameters"; -import { CollectionIds, Id, Ids } from "@interfaces/apiInterfaces"; +import { CollectionIds } from "@interfaces/apiInterfaces"; import { hasMany } from "@models/AssociationDecorators"; import { AudioEventProvenance } from "@models/AudioEventProvenance"; import { EventSummaryReport } from "@models/EventSummaryReport"; @@ -31,6 +31,7 @@ import { ImplementsInjector } from "@models/ImplementsInjector"; import { Region } from "@models/Region"; import { Site } from "@models/Site"; import { Tag } from "@models/Tag"; +import { IParameterModel } from "@models/data/parametersModel"; import { DateTime, Duration } from "luxon"; export enum Chart { @@ -50,10 +51,10 @@ export enum BucketSize { } export interface IEventSummaryReportParameters { - sites: Ids | Id[]; - points: Ids | Id[]; - provenances: Ids | Id[]; - tags: Ids | Id[]; + sites: CollectionIds; + points: CollectionIds; + provenances: CollectionIds; + tags: CollectionIds; score: number; bucketSize: BucketSize; daylightSavings: boolean; @@ -76,7 +77,10 @@ const serializationTable: IQueryStringParameterSpec = { }; export class EventSummaryReportParameters - implements IEventSummaryReportParameters, ImplementsInjector + implements + IEventSummaryReportParameters, + ImplementsInjector, + IParameterModel { public constructor( queryStringParameters: Params = {}, @@ -207,10 +211,11 @@ export class EventSummaryReportParameters } public toQueryParams(): Params { - const queryParameters = serializeObjectToParams( - this, - serializationTable - ); + const queryParameters = + serializeObjectToParams( + this, + serializationTable + ); return queryParameters; } diff --git a/src/app/components/shared/audio-event-card/annotation-event-card.component.html b/src/app/components/shared/audio-event-card/annotation-event-card.component.html new file mode 100644 index 000000000..0b12febf6 --- /dev/null +++ b/src/app/components/shared/audio-event-card/annotation-event-card.component.html @@ -0,0 +1,47 @@ +
+ + + + + + +
+
{{ annotation.tags }}
+ +
+ + +
diff --git a/src/app/components/shared/audio-event-card/annotation-event-card.component.scss b/src/app/components/shared/audio-event-card/annotation-event-card.component.scss new file mode 100644 index 000000000..dddf12311 --- /dev/null +++ b/src/app/components/shared/audio-event-card/annotation-event-card.component.scss @@ -0,0 +1,15 @@ +.info-line { + display: block; + width: 100%; +} + +.card-header { + display: flex; + justify-content: space-between; + + .card-title { + flex: 0 0 auto; + max-width: 210px; + align-content: center; + } +} diff --git a/src/app/components/shared/audio-event-card/annotation-event-card.component.spec.ts b/src/app/components/shared/audio-event-card/annotation-event-card.component.spec.ts new file mode 100644 index 000000000..19c664c4e --- /dev/null +++ b/src/app/components/shared/audio-event-card/annotation-event-card.component.spec.ts @@ -0,0 +1,128 @@ +import { + createComponentFactory, + Spectator, + SpyObject, +} from "@ngneat/spectator"; +import { MockBawApiModule } from "@baw-api/baw-apiMock.module"; +import { SharedModule } from "@shared/shared.module"; +import { INJECTOR, Injector } from "@angular/core"; +import { AudioRecording } from "@models/AudioRecording"; +import { Tag } from "@models/Tag"; +import { generateAudioRecording } from "@test/fakes/AudioRecording"; +import { AudioRecordingsService } from "@baw-api/audio-recording/audio-recordings.service"; +import { TagsService } from "@baw-api/tag/tags.service"; +import { generateTag } from "@test/fakes/Tag"; +import { + AUDIO_RECORDING, + MEDIA, + SHALLOW_SITE, + TAG, +} from "@baw-api/ServiceTokens"; +import { of } from "rxjs"; +import { ShallowSitesService } from "@baw-api/site/sites.service"; +import { Site } from "@models/Site"; +import { generateSite } from "@test/fakes/Site"; +import { SpectrogramComponent } from "@ecoacoustics/web-components/@types/components/spectrogram/spectrogram"; +import { Annotation } from "@models/data/Annotation"; +import { generateAnnotation } from "@test/fakes/data/Annotation"; +import { MediaService } from "@services/media/media.service"; +import { patchSharedArrayBuffer } from "src/patches/tests/testPatches"; +import { testAsset } from "@test/helpers/karma"; +import { AnnotationEventCardComponent } from "./annotation-event-card.component"; + +describe("AudioEventCardComponent", () => { + let spectator: Spectator; + let injectorSpy: SpyObject; + + let mediaServiceSpy: SpyObject; + let audioRecordingApiSpy: SpyObject; + let tagApiSpy: SpyObject; + let siteApiSpy: SpyObject; + + let mockAnnotation: Annotation; + let mockAudioRecording: AudioRecording; + let mockTag: Tag; + let mockSite: Site; + + const createComponent = createComponentFactory({ + component: AnnotationEventCardComponent, + imports: [MockBawApiModule, SharedModule], + }); + + function setup(): void { + spectator = createComponent({ detectChanges: false }); + + injectorSpy = spectator.inject(INJECTOR); + + mediaServiceSpy = spectator.inject(MEDIA.token); + mediaServiceSpy.createMediaUrl = jasmine.createSpy("createMediaUrl") as any; + mediaServiceSpy.createMediaUrl.and.returnValue( + testAsset("example.flac") + ); + + mockTag = new Tag(generateTag(), injectorSpy); + mockSite = new Site(generateSite(), injectorSpy); + mockAudioRecording = new AudioRecording( + generateAudioRecording(), + injectorSpy + ); + mockAnnotation = new Annotation( + generateAnnotation({ + audioRecording: mockAudioRecording, + audioRecordingId: mockAudioRecording.id, + startTimeSeconds: 0, + endTimeSeconds: 5, + tags: [mockTag], + }), + mediaServiceSpy + ); + + audioRecordingApiSpy = spectator.inject(AUDIO_RECORDING.token); + audioRecordingApiSpy.show.andCallFake(() => of(mockAudioRecording)); + audioRecordingApiSpy.filter.andCallFake(() => of([mockAudioRecording])); + + tagApiSpy = spectator.inject(TAG.token); + tagApiSpy.show.andCallFake(() => of(mockTag)); + tagApiSpy.filter.andCallFake(() => of([mockTag])); + + siteApiSpy = spectator.inject(SHALLOW_SITE.token); + siteApiSpy.show.andCallFake(() => of(mockSite)); + + siteApiSpy.filter.andCallFake(() => of([mockSite])); + + spectator.setInput("annotation", mockAnnotation); + } + + const spectrogram = () => + spectator.query("oe-spectrogram"); + const listenLink = () => + spectator.query(".more-information-link"); + const cardTitle = () => spectator.query(".card-title"); + + beforeEach(() => { + patchSharedArrayBuffer(); + setup(); + }); + + it("should create", () => { + expect(spectator.component).toBeInstanceOf(AnnotationEventCardComponent); + }); + + it("should have the correct spectrogram source", () => { + const expectedSource = mockAnnotation.audioLink; + const realizedSource = spectrogram().src; + expect(realizedSource).toEqual(expectedSource); + }); + + it("should have the correct link to the listen page", () => { + const expectedHref = mockAnnotation.viewUrl; + expect(listenLink()).toHaveAttribute("href", expectedHref); + }); + + it("should use the tag text as the card title", () => { + const expectedTitle = mockTag.text; + expect(cardTitle()).toHaveText(expectedTitle); + }); + + xit("should be able to play the spectrogram", () => {}); +}); diff --git a/src/app/components/shared/audio-event-card/annotation-event-card.component.ts b/src/app/components/shared/audio-event-card/annotation-event-card.component.ts new file mode 100644 index 000000000..8c101e1d0 --- /dev/null +++ b/src/app/components/shared/audio-event-card/annotation-event-card.component.ts @@ -0,0 +1,26 @@ +import { AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild } from "@angular/core"; +import { MediaControlsComponent } from "@ecoacoustics/web-components/@types/components/media-controls/media-controls"; +import { Annotation } from "@models/data/Annotation"; + +@Component({ + selector: "baw-annotation-event-card", + templateUrl: "annotation-event-card.component.html", + styleUrl: "annotation-event-card.component.scss", +}) +export class AnnotationEventCardComponent implements OnInit, AfterViewInit { + @Input({ required: true }) + public annotation: Annotation; + + protected spectrogramId: string; + + @ViewChild("mediaControls") + private mediaControls: ElementRef; + + public ngOnInit(): void { + this.spectrogramId = `spectrogram-${this.annotation.id}`; + } + + public ngAfterViewInit(): void { + this.mediaControls.nativeElement.for = this.spectrogramId; + } +} diff --git a/src/app/components/shared/audio-event-card/annotation-event-card.module.ts b/src/app/components/shared/audio-event-card/annotation-event-card.module.ts new file mode 100644 index 000000000..beeafc4b6 --- /dev/null +++ b/src/app/components/shared/audio-event-card/annotation-event-card.module.ts @@ -0,0 +1,20 @@ +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; +import { ZonedDateTimeComponent } from "@shared/datetime-formats/datetime/zoned-datetime/zoned-datetime.component"; +import { PipesModule } from "@pipes/pipes.module"; +import { AnnotationEventCardComponent } from "./annotation-event-card.component"; + +@NgModule({ + declarations: [AnnotationEventCardComponent], + imports: [ + CommonModule, + FontAwesomeModule, + PipesModule, + + ZonedDateTimeComponent, + ], + exports: [AnnotationEventCardComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class AudioEventCardModule {} diff --git a/src/app/components/shared/can/can.component.spec.ts b/src/app/components/shared/can/can.component.spec.ts new file mode 100644 index 000000000..79eb3e4dc --- /dev/null +++ b/src/app/components/shared/can/can.component.spec.ts @@ -0,0 +1,106 @@ +import { createHostFactory, Spectator, SpyObject } from "@ngneat/spectator"; +import { BawSessionService } from "@baw-api/baw-session.service"; +import { SharedModule } from "@shared/shared.module"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { MockBawApiModule } from "@baw-api/baw-apiMock.module"; +import { IfLoggedInComponent } from "./can.component"; + +describe("IsLoggedInComponent", () => { + let spectator: Spectator; + let sessionSpy: SpyObject; + + let isLoggedIn: boolean; + + const createHost = createHostFactory({ + component: IfLoggedInComponent, + imports: [SharedModule, HttpClientTestingModule, MockBawApiModule], + }); + + function setup(setupState: boolean) { + const template = ` + +

non-interactive element

+ + + +
+ `; + + spectator = createHost(template, { detectChanges: false }); + + isLoggedIn = setupState; + + sessionSpy = spectator.inject(BawSessionService); + spyOnProperty(sessionSpy, "isLoggedIn").and.callFake(() => isLoggedIn); + + spectator.detectChanges(); + } + + const wrapperSpan = () => spectator.query("span"); + + const nonInteractiveElement = () => spectator.query("#non-interactive-p"); + const shallowButton = () => spectator.query("#shallow-button"); + const shallowInput = () => spectator.query("#shallow-input"); + + function updateAuthState(state: boolean) { + isLoggedIn = state; + spectator.component.updateState(); + spectator.detectChanges(); + } + + it("should create", () => { + setup(false); + expect(spectator.component).toBeInstanceOf(IfLoggedInComponent); + }); + + describe("logged in predicate", () => { + describe("when the user is logged in", () => { + beforeEach(() => { + setup(true); + }); + + it("should not have a tooltip", () => { + expect(wrapperSpan()).not.toHaveAttribute("ngbTooltip"); + }); + + it("should not disable any elements", () => { + expect(shallowButton()).not.toHaveAttribute("disabled"); + expect(shallowInput()).not.toHaveAttribute("disabled"); + expect(nonInteractiveElement()).not.toHaveAttribute("disabled"); + }); + + it("should go to a disabled state when the user logs out", () => { + updateAuthState(false); + + expect(shallowButton()).toHaveAttribute("disabled"); + expect(shallowInput()).toHaveAttribute("disabled"); + }); + }); + + describe("when the user is not logged in", () => { + beforeEach(() => { + setup(false); + }); + + it("should have a tooltip", () => { + const expectedContent = "You must be logged in"; + expect(wrapperSpan()).toHaveAttribute( + "ng-reflect-ngb-tooltip", + expectedContent + ); + }); + + it("should disable buttons and input elements", () => { + expect(shallowButton()).toHaveAttribute("disabled"); + expect(shallowInput()).toHaveAttribute("disabled"); + }); + + it("should go to an enabled state when the user logs in", () => { + updateAuthState(true); + + expect(shallowButton()).not.toHaveAttribute("disabled"); + expect(shallowInput()).not.toHaveAttribute("disabled"); + }); + }); + }); +}); diff --git a/src/app/components/shared/can/can.component.ts b/src/app/components/shared/can/can.component.ts new file mode 100644 index 000000000..1f6cf0c76 --- /dev/null +++ b/src/app/components/shared/can/can.component.ts @@ -0,0 +1,94 @@ +import { + AfterViewInit, + Component, + ElementRef, + Input, + OnInit, + ViewChild, +} from "@angular/core"; +import { BawSessionService } from "@baw-api/baw-session.service"; +import { Observable } from "rxjs"; + +interface CanPredicate { + watcher: Observable; + predicate: () => boolean; +} + +// TODO: we might be able to add capability checking here +// e.g. +// +/** + * A component to conditionally disable content if the user does not have a + * condition or capability met. + * + * @example + * ```html + * + * + * + * ``` + */ +@Component({ + selector: "baw-can", + template: ` + + + + `, +}) +export class IfLoggedInComponent implements OnInit, AfterViewInit { + public constructor( + public session: BawSessionService, + public elementRef: ElementRef + ) {} + + @Input() + public ifLoggedIn: boolean; + + @ViewChild("contentWrapper") + private contentWrapper: ElementRef; + + private predicates: CanPredicate[] = []; + + public ngOnInit(): void { + if (this.ifLoggedIn) { + this.predicates.push(this.ifLoggedInPredicate()); + } + } + + public ngAfterViewInit(): void { + for (const predicate of this.predicates) { + predicate.watcher + // eslint-disable-next-line rxjs-angular/prefer-takeuntil + .subscribe(() => this.updateState()); + } + } + + public updateState(): void { + const shouldDisable = this.predicates.some((predicate) => !predicate.predicate()); + this.disableInteractiveContent(shouldDisable); + } + + private disableInteractiveContent(state: boolean): void { + // TODO: use a ContentChild decorator here + const content = this.contentWrapper.nativeElement.children; + + for (const element of content) { + if (state) { + element.setAttribute("disabled", "true"); + } else { + element.removeAttribute("disabled"); + } + } + } + + private ifLoggedInPredicate(): CanPredicate { + return { + watcher: this.session.authTrigger, + predicate: () => this.session.isLoggedIn, + }; + } +} diff --git a/src/app/components/shared/date-time-filter/date-time-filter.component.html b/src/app/components/shared/date-time-filter/date-time-filter.component.html index 8f7ad039b..fb084b493 100644 --- a/src/app/components/shared/date-time-filter/date-time-filter.component.html +++ b/src/app/components/shared/date-time-filter/date-time-filter.component.html @@ -2,6 +2,7 @@
@@ -64,14 +70,18 @@ name="dateFinishedBefore" class="form-control" placeholder="yyyy-mm-dd" - [class.is-invalid]="dateFinishedBeforeForm.errors?.ngbDate?.invalid" + [class.is-invalid]=" + dateFinishedBeforeForm.errors?.ngbDate?.invalid + " [minDate]="model.dateStartedAfter" [(ngModel)]="model.dateFinishedBefore" + [disabled]="disableEndDate" /> @@ -102,7 +112,7 @@
-
+
@@ -147,6 +158,7 @@ name="timeFinishedBefore" label="End Time" [(ngModel)]="model.timeFinishedBefore" + [disabled]="disableEndTime" >
@@ -158,7 +170,7 @@ name="ignoreDaylightSavings" type="checkbox" class="form-check-input" - [disabled]="!model.timeFiltering" + [disabled]="!model.timeFiltering || disableIgnoreDaylightSavings" [(ngModel)]="model.ignoreDaylightSavings" /> diff --git a/src/app/components/shared/date-time-filter/date-time-filter.component.spec.ts b/src/app/components/shared/date-time-filter/date-time-filter.component.spec.ts index afdd484dd..31de157e6 100644 --- a/src/app/components/shared/date-time-filter/date-time-filter.component.spec.ts +++ b/src/app/components/shared/date-time-filter/date-time-filter.component.spec.ts @@ -802,4 +802,40 @@ describe("AudioRecordingsFilter", () => { })); }); }); + + describe("disabled inputs", () => { + it("should disable the start date input if 'disableStartDate' is set", () => { + spectator.setInput("disableStartDate", true); + expect(getDateStartedAfterInput()).toBeDisabled(); + }); + + it("should disable the end date input if 'disableEndDate' is set", () => { + spectator.setInput("disableEndDate", true); + expect(getDateFinishedBeforeInput()).toBeDisabled(); + }); + + it("should disable the start time input if 'disableStartTime' is set", () => { + spectator.setInput("disableStartTime", true); + expect(getTimeOfDayStartedAfterInput()).toBeDisabled(); + }); + + it("should disable the end time input if 'disableEndTime' is set", () => { + spectator.setInput("disableEndTime", true); + expect(getTimeOfDayFinishedBeforeInput()).toBeDisabled(); + }); + + it("should not show a toggle for date inputs if both start and end dates are disabled", () => { + spectator.setInput("disableStartDate", true); + spectator.setInput("disableEndDate", true); + + expect(getDateToggleInput()).not.toExist(); + }); + + it("should not show a toggle for time inputs if both start and end times are disabled", () => { + spectator.setInput("disableStartTime", true); + spectator.setInput("disableEndTime", true); + + expect(getTimeOfDayToggleInput()).not.toExist(); + }); + }); }); diff --git a/src/app/components/shared/date-time-filter/date-time-filter.component.ts b/src/app/components/shared/date-time-filter/date-time-filter.component.ts index cb72be693..873152fee 100644 --- a/src/app/components/shared/date-time-filter/date-time-filter.component.ts +++ b/src/app/components/shared/date-time-filter/date-time-filter.component.ts @@ -59,9 +59,16 @@ export class DateTimeFilterComponent @Input() public region: Region; @Input() public site: Site; @Input() public constructedFilters: BehaviorSubject>; + + @Input() public disableStartDate = false; + @Input() public disableEndDate = false; + @Input() public disableStartTime = false; + @Input() public disableEndTime = false; + @Input() public disableIgnoreDaylightSavings = false; + @Output() public modelChange = new EventEmitter(); + @Input() public model: DateTimeFilterModel = { ignoreDaylightSavings: true }; - public model: DateTimeFilterModel = { ignoreDaylightSavings: true }; private previousFilters: FromJS>; public ngAfterViewInit(): void { diff --git a/src/app/components/shared/menu/widgets/widget.component.ts b/src/app/components/shared/menu/widgets/widget.component.ts index 7b269a3ed..f610a68a8 100644 --- a/src/app/components/shared/menu/widgets/widget.component.ts +++ b/src/app/components/shared/menu/widgets/widget.component.ts @@ -8,5 +8,5 @@ export interface WidgetComponent {} export interface ModalComponent extends WidgetComponent { closeModal: (result: any) => void; dismissModal?: (reason: any) => void; - successCallback?: () => void; + successCallback?: (...params: unknown[]) => void; } diff --git a/src/app/components/shared/shared.components.ts b/src/app/components/shared/shared.components.ts index 940457df7..cfa698fa9 100644 --- a/src/app/components/shared/shared.components.ts +++ b/src/app/components/shared/shared.components.ts @@ -56,6 +56,8 @@ import { TimeSinceComponent } from "./datetime-formats/time-since/time-since.com import { DurationComponent } from "./datetime-formats/duration/duration.component"; import { ZonedDateTimeComponent } from "./datetime-formats/datetime/zoned-datetime/zoned-datetime.component"; import { DatetimeComponent } from "./datetime-formats/datetime/datetime/datetime.component"; +import { AudioEventCardModule } from "./audio-event-card/annotation-event-card.module"; +import { IfLoggedInComponent } from "./can/can.component"; export const sharedComponents = [ AnnotationDownloadComponent, @@ -72,6 +74,7 @@ export const sharedComponents = [ ChartComponent, InlineListComponent, WebsiteStatusWarningComponent, + IfLoggedInComponent, // modals ConfirmationComponent, @@ -114,6 +117,7 @@ export const sharedModules = [ LoadingModule, MenuModule, ModelCardsModule, + AudioEventCardModule, PipesModule, ProgressModule, StepperModule, diff --git a/src/app/components/shared/typeahead-input/typeahead-callbacks.ts b/src/app/components/shared/typeahead-input/typeahead-callbacks.ts new file mode 100644 index 000000000..ee1d452c4 --- /dev/null +++ b/src/app/components/shared/typeahead-input/typeahead-callbacks.ts @@ -0,0 +1,40 @@ +import { ApiFilter } from "@baw-api/api-common"; +import { AbstractModelWithoutId } from "@models/AbstractModel"; +import { contains, filterAnd, notIn } from "@helpers/filters/filters"; +import { Observable } from "rxjs"; +import { InnerFilter } from "@baw-api/baw-api.service"; +import { TypeaheadSearchCallback } from "./typeahead-input.component"; + +// create a callback that can be used to filter for items in a typeahead +export function createSearchCallback( + api: ApiFilter, + key: keyof T, + filters: InnerFilter = {} +): TypeaheadSearchCallback { + return (text: any, activeItems: T[]): Observable => + api.filter({ + filter: filterAnd( + contains(key, text, filters), + + // we add a "not in" condition to exclude items that are already selected + notIn(key, activeItems) + ), + }); +} + +export function createIdSearchCallback( + api: ApiFilter, + key: keyof T, + filters: InnerFilter = {} +): TypeaheadSearchCallback { + return (text: any): Observable => { + const id = Number(text); + if (!isFinite(id)) { + throw new Error("Invalid id"); + } + + return api.filter({ + filter: filterAnd({ [key]: { eq: id } }, filters), + }); + }; +} diff --git a/src/app/components/shared/typeahead-input/typeahead-input.component.scss b/src/app/components/shared/typeahead-input/typeahead-input.component.scss index 0eec6de24..8f4001b45 100644 --- a/src/app/components/shared/typeahead-input/typeahead-input.component.scss +++ b/src/app/components/shared/typeahead-input/typeahead-input.component.scss @@ -13,6 +13,7 @@ .item-pill { display: flex; + align-items: center; max-width: fit-content; background-color: var(--baw-highlight); font-size: medium; diff --git a/src/app/components/shared/typeahead-input/typeahead-input.component.ts b/src/app/components/shared/typeahead-input/typeahead-input.component.ts index 87d7551a7..66087fbb4 100644 --- a/src/app/components/shared/typeahead-input/typeahead-input.component.ts +++ b/src/app/components/shared/typeahead-input/typeahead-input.component.ts @@ -27,8 +27,6 @@ export type TypeaheadSearchCallback = ( styleUrls: ["typeahead-input.component.scss"], }) export class TypeaheadInputComponent { - public constructor() {} - /** * The options callback is typically linked to a service as it should return a list observable of options that the user could select * Active items are included in the callback as the api request should have a filter condition to filter these results out @@ -45,14 +43,15 @@ export class TypeaheadInputComponent { */ @Input() public inputPlaceholder = ""; @Input() public inputDisabled = false; - /** An event emitter when a user adds, removes, or selects and item from the typeahead input */ - @Output() public modelChange = new EventEmitter(); // if multiple items are enabled, they will be added to the value // if multiple inputs are disabled, the value will always be an array with a single element // we use the variable name "value" so the component can be used in ngForms and can bind to [(ngModel)] - public value: object[] = []; - protected inputModel!: string | null; + @Input() public value: object[] = []; + /** An event emitter when a user adds, removes, or selects and item from the typeahead input */ + @Output() public modelChange = new EventEmitter(); + + protected inputModel: string | null; public findOptions = (text$: Observable): Observable => { const maximumResults = 10; diff --git a/src/app/components/shared/wip/wip.component.scss b/src/app/components/shared/wip/wip.component.scss index 397e78e4a..dd9723e2c 100644 --- a/src/app/components/shared/wip/wip.component.scss +++ b/src/app/components/shared/wip/wip.component.scss @@ -4,20 +4,21 @@ --wip-border-color: #f39c12; } +// the wrapper that is used in production .wip-placeholder { height: 100px; display: flex; flex-direction: row; border: var(--wip-border-color) 1px solid; - & > div { + & > .wip-text { display: flex; justify-content: center; align-items: center; height: 100%; } - & > :first-child { + & > .wip-icon { width: 100px; background-color: var(--wip-bg-color); color: var(--wip-color); @@ -29,12 +30,13 @@ } } +// the wrapper that is used in development .wip-wrapper { display: flex; flex-direction: column; border: var(--wip-border-color) 1px solid; - & > :first-child { + & > .wip-icon { background-color: var(--wip-bg-color); color: var(--wip-color); display: flex; diff --git a/src/app/components/shared/wip/wip.component.ts b/src/app/components/shared/wip/wip.component.ts index e62ab9e1a..4e59ebc8a 100644 --- a/src/app/components/shared/wip/wip.component.ts +++ b/src/app/components/shared/wip/wip.component.ts @@ -29,11 +29,9 @@ import { ConfigService } from "@services/config/config.service"; ngbTooltip="This feature is a work in progress" > -
- - This section is a work in progress. Expect new things here soon! - -
+

+ This section is a work in progress. Expect new things here soon! +

diff --git a/src/app/components/sites/pages/details/details.component.ts b/src/app/components/sites/pages/details/details.component.ts index 05a4acb84..9a50fb689 100644 --- a/src/app/components/sites/pages/details/details.component.ts +++ b/src/app/components/sites/pages/details/details.component.ts @@ -29,6 +29,7 @@ import { takeUntil } from "rxjs"; import { ConfigService } from "@services/config/config.service"; import { shallowRegionsRoute } from "@components/regions/regions.routes"; import { reportMenuItems } from "@components/reports/reports.menu"; +import { annotationMenuItems } from "@components/annotations/annotation.menu"; import { editSiteMenuItem, siteMenuItem, @@ -43,6 +44,7 @@ export const siteMenuItemActions = [ audioRecordingMenuItems.list.site, audioRecordingMenuItems.batch.site, reportMenuItems.new.site, + annotationMenuItems.search.site, ]; export const pointMenuItemActions = [ @@ -53,6 +55,7 @@ export const pointMenuItemActions = [ audioRecordingMenuItems.list.siteAndRegion, audioRecordingMenuItems.batch.siteAndRegion, reportMenuItems.new.siteAndRegion, + annotationMenuItems.search.siteAndRegion, ]; const projectKey = "project"; diff --git a/src/app/components/web-components/grid-tile-content/grid-tile-content.component.html b/src/app/components/web-components/grid-tile-content/grid-tile-content.component.html new file mode 100644 index 000000000..82907d163 --- /dev/null +++ b/src/app/components/web-components/grid-tile-content/grid-tile-content.component.html @@ -0,0 +1,52 @@ +
+ Go To Source + + +
+ +@if (contextExpanded) { +
+
+ + Context + + + (Showing 30 seconds around audio event) + + + + + + +
+ +
+ + + + + + + +
+
+} diff --git a/src/app/components/web-components/grid-tile-content/grid-tile-content.component.scss b/src/app/components/web-components/grid-tile-content/grid-tile-content.component.scss new file mode 100644 index 000000000..bfc5c0c23 --- /dev/null +++ b/src/app/components/web-components/grid-tile-content/grid-tile-content.component.scss @@ -0,0 +1,66 @@ +@import "../../../../styles.scss"; + +:host { + display: block; + position: relative; + width: 100%; + height: 100%; +} + +.content-wrapper { + display: flex; + justify-content: space-between; + align-items: center; +} + +.content-link { + font-size: 0.75em; + line-height: 0.75em; +} + +.context-anchor { + anchor-name: --context-anchor; +} + +.context-anchor-item { + position: absolute; + + position-anchor: --context-anchor; + left: anchor(left); + top: anchor(bottom); + + position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline; + position-try: flip-block, flip-inline, flip-block flip-inline; +} + +.context-card { + position: fixed; + max-width: 700px; + width: 100%; + padding: 1rem; + + background-color: white; + border-radius: 10px; + border: 1px solid #e0e0e0; + + overflow: visible; + z-index: 99999; +} + +.context-card-header { + display: flex; + justify-content: space-between; + align-items: center; + + padding-bottom: 1rem; + + .context-card-title { + font-weight: bold; + } +} + +// TODO: remove this flexbox container once ecoacoustics/web-components#214 is resolved +.context-card-elements { + display: flex; + flex-direction: column; +} diff --git a/src/app/components/web-components/grid-tile-content/grid-tile-content.component.spec.ts b/src/app/components/web-components/grid-tile-content/grid-tile-content.component.spec.ts new file mode 100644 index 000000000..c5125259a --- /dev/null +++ b/src/app/components/web-components/grid-tile-content/grid-tile-content.component.spec.ts @@ -0,0 +1,170 @@ +import { + createComponentFactory, + Spectator, + SpyObject, +} from "@ngneat/spectator"; +import { MockBawApiModule } from "@baw-api/baw-apiMock.module"; +import { SharedModule } from "@shared/shared.module"; +import { getElementByInnerText } from "@test/helpers/html"; +import { SpectrogramComponent } from "@ecoacoustics/web-components/@types/components/spectrogram/spectrogram"; +import { Annotation } from "@models/data/Annotation"; +import { generateAnnotation } from "@test/fakes/data/Annotation"; +import { MediaService } from "@services/media/media.service"; +import { MEDIA } from "@baw-api/ServiceTokens"; +import { AnnotationService } from "@services/models/annotation.service"; +import { AudioRecording } from "@models/AudioRecording"; +import { generateAudioRecording } from "@test/fakes/AudioRecording"; +import { patchSharedArrayBuffer } from "src/patches/tests/testPatches"; +import { detectChanges } from "@test/helpers/changes"; +import { testAsset } from "@test/helpers/karma"; +import { GridTileContentComponent } from "./grid-tile-content.component"; + +describe("GridTileContentComponent", () => { + let spectator: Spectator; + + let mediaServiceSpy: SpyObject; + let contextRequestSpy: jasmine.Spy; + + let mockAnnotation: Annotation; + let mockAudioRecording: AudioRecording; + + const createComponent = createComponentFactory({ + component: GridTileContentComponent, + imports: [MockBawApiModule, SharedModule], + }); + + function setup(): void { + spectator = createComponent({ + detectChanges: false, + providers: [ + { + provide: AnnotationService, + useValue: { show: () => mockAnnotation }, + }, + ], + }); + + mediaServiceSpy = spectator.inject(MEDIA.token); + + // I hard code the audio recording duration and event start/end times so + // that I know the audio event will neatly fit within the audio recording + // when context is added + mockAudioRecording = new AudioRecording( + generateAudioRecording({ + durationSeconds: 600, + }) + ); + + mockAudioRecording.getMediaUrl = jasmine + .createSpy("getSplittableUrl") + .and.returnValue(testAsset("example.flac")); + + mockAnnotation = new Annotation( + generateAnnotation({ + startTimeSeconds: 60, + endTimeSeconds: 120, + audioRecording: mockAudioRecording, + audioRecordingId: mockAudioRecording.id, + }), + mediaServiceSpy + ); + + updateContext(mockAnnotation); + } + + function updateContext(model: Annotation): void { + spectator.component.handleContextChange({ subject: model } as any); + + contextRequestSpy = jasmine.createSpy("event"); + spectator.component.elementRef.nativeElement.addEventListener("context-request", contextRequestSpy); + + spectator.detectChanges(); + } + + const listenLink = () => getElementByInnerText(spectator, "Go To Source"); + const contextButton = () => getElementByInnerText(spectator, "Show More"); + const contextCloseButton = () => spectator.query("#close-btn"); + const contextCard = () => spectator.query(".context-card"); + const spectrogram = () => + spectator.query("oe-spectrogram"); + + beforeEach(() => { + patchSharedArrayBuffer(); + setup(); + detectChanges(spectator); + }); + + it("should create", () => { + spectator.detectChanges(); + expect(spectator.component).toBeInstanceOf(GridTileContentComponent); + }); + + it("should emit a context request event when loaded", () => { + expect(contextRequestSpy).toHaveBeenCalledOnceWith( + jasmine.objectContaining({ callback: jasmine.any(Function) }) + ); + }); + + describe("listen link", () => { + it("should have the audio link for the event", () => { + const expectedHref = mockAnnotation.viewUrl; + expect(listenLink()).toHaveAttribute("href", expectedHref); + }); + + it("should have the correct audio link if a new subject is provided", () => { + const newTestSubject = new Annotation( + generateAnnotation(), + mediaServiceSpy + ); + updateContext(newTestSubject); + + const expectedHref = newTestSubject.viewUrl; + expect(listenLink()).toHaveAttribute("href", expectedHref); + }); + }); + + // TODO: this test is temporarily disabled until the web component resize + // observer can correctly detect if it has completed a render cycle + xdescribe("context card", () => { + it("should toggle a context card when the context button is clicked", () => { + spectator.click(contextButton()); + expect(contextCard()).toBeVisible(); + + // test that we can close the context card again by clicking the button + // while the context card is open + spectator.click(contextButton()); + expect(contextCard()).not.toBeVisible(); + }); + + it("should close the context card when the close button is clicked", () => { + spectator.click(contextButton()); + expect(contextCard()).toBeVisible(); + + spectator.click(contextCloseButton()); + expect(contextCard()).not.toBeVisible(); + }); + + it("should be able to play the context card spectrogram", () => { + spectator.click(contextButton()); + }); + + // because we have hard coded the audio recording duration and event + // start/end times to "nice" value, we know that the context will be neatly + // added to either size of the audio event + // + // padding rounding and overflow is tested in the media service tests and + // so we don't have to retest the overflow logic here + it("should have the correct context source", () => { + spectator.click(contextButton()); + + const expectedContextSize = 15; + const expectedStartOffset = mockAnnotation.startTimeSeconds - expectedContextSize; + const expectedEndOffset = mockAnnotation.endTimeSeconds + expectedContextSize; + const expectedOffsetParameters = `?start_offset=${expectedStartOffset}&end_offset=${expectedEndOffset}`; + + const realizedSpectrogramSource = spectrogram().src; + + expect(realizedSpectrogramSource).toContain(expectedOffsetParameters); + }); + }); +}); diff --git a/src/app/components/web-components/grid-tile-content/grid-tile-content.component.ts b/src/app/components/web-components/grid-tile-content/grid-tile-content.component.ts new file mode 100644 index 000000000..7cc861bb4 --- /dev/null +++ b/src/app/components/web-components/grid-tile-content/grid-tile-content.component.ts @@ -0,0 +1,90 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + CUSTOM_ELEMENTS_SCHEMA, + ElementRef, + signal, + ViewChild, + ViewEncapsulation, +} from "@angular/core"; +import { NgElement, WithProperties } from "@angular/elements"; +import { SubjectWrapper } from "@ecoacoustics/web-components/@types/models/subject"; +import { SpectrogramComponent } from "@ecoacoustics/web-components/@types/components/spectrogram/spectrogram"; +import { gridTileContext } from "@ecoacoustics/web-components/dist/components/helpers/constants/contextTokens"; +import { MediaControlsComponent } from "@ecoacoustics/web-components/@types/components/media-controls/media-controls"; +import { Annotation } from "@models/data/Annotation"; +import { + ContextSubscription, + WithContext, +} from "@helpers/context/context-decorators"; + +export const gridTileContentSelector = "baw-grid-tile-content" as const; + +@Component({ + standalone: true, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + encapsulation: ViewEncapsulation.ShadowDom, + changeDetection: ChangeDetectionStrategy.OnPush, + + selector: "baw-ng-grid-tile-content", + templateUrl: "grid-tile-content.component.html", + styleUrl: "grid-tile-content.component.scss", +}) +export class GridTileContentComponent implements WithContext { + public constructor( + public elementRef: ElementRef, + public changeDetectorRef: ChangeDetectorRef + ) {} + + @ViewChild("contextSpectrogram") + private contextSpectrogram: ElementRef; + + @ViewChild("contextMediaControls") + private contextMediaControls: ElementRef; + + protected model = signal(undefined); + protected contextExpanded = false; + + public get listenLink(): string { + return this.model()?.viewUrl; + } + + public get contextSpectrogramId(): string { + return `spectrogram-${this.model()?.id}-context`; + } + + public get contextSource(): string { + if (!this.model()) { + return ""; + } + + const contextSize = 30 as const; + return this.model().contextUrl(contextSize); + } + + @ContextSubscription(gridTileContext) + public handleContextChange(subjectWrapper: SubjectWrapper): void { + this.contextExpanded = false; + this.model.set(subjectWrapper.subject as any); + } + + protected toggleContext(): void { + this.contextExpanded = !this.contextExpanded; + } + + protected closeContext(): void { + this.contextExpanded = false; + } + + protected contextSpectrogramLoaded(): void { + this.contextMediaControls.nativeElement.for = this.contextSpectrogramId; + } +} + +declare global { + interface HTMLElementTagNameMap { + "baw-grid-tile-content": NgElement & + WithProperties; + } +} diff --git a/src/app/components/web-components/web-components.module.ts b/src/app/components/web-components/web-components.module.ts new file mode 100644 index 000000000..bd78eb2ec --- /dev/null +++ b/src/app/components/web-components/web-components.module.ts @@ -0,0 +1,16 @@ +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from "@angular/core"; +import { SharedModule } from "@shared/shared.module"; +import { GridTileContentComponent } from "./grid-tile-content/grid-tile-content.component"; + +const modules = [ + // the grid tile content component is a standalone component + // this is why I have imported it as a module + GridTileContentComponent, +]; + +@NgModule({ + imports: [SharedModule, ...modules], + exports: modules, + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class AnnotationModule {} diff --git a/src/app/guards/form/form.guard.ts b/src/app/guards/form/form.guard.ts index 23ae8db22..8527cf227 100644 --- a/src/app/guards/form/form.guard.ts +++ b/src/app/guards/form/form.guard.ts @@ -5,8 +5,8 @@ import { Type, ViewChildren, } from "@angular/core"; - import { FormComponent } from "@shared/form/form.component"; +import { CanDeactivate } from "@angular/router"; /** * Interface for FormCheckingPageComponent. @@ -54,7 +54,7 @@ export function withFormCheck>(base: T = class {} as any) { * modified by the user in any way. */ @Injectable() -export class FormTouchedGuard { +export class FormTouchedGuard implements CanDeactivate { @ViewChildren(FormComponent) public appForms: QueryList; public canDeactivate(component: FormCheckingComponent): boolean { diff --git a/src/app/guards/input/input.guard.ts b/src/app/guards/input/input.guard.ts index a3cfb9d1a..108505557 100644 --- a/src/app/guards/input/input.guard.ts +++ b/src/app/guards/input/input.guard.ts @@ -1,4 +1,5 @@ import { Injectable } from "@angular/core"; +import { CanDeactivate } from "@angular/router"; export interface UnsavedInputCheckingComponent { hasUnsavedChanges: boolean; @@ -10,7 +11,7 @@ export interface UnsavedInputCheckingComponent { * modified by the user in any way which has not been saved */ @Injectable() -export class UnsavedInputGuard { +export class UnsavedInputGuard implements CanDeactivate { public canDeactivate(component: UnsavedInputCheckingComponent): boolean { // canDeactivate guards can be called with null components: https://github.com/angular/angular/issues/40545 if (!component) { @@ -19,7 +20,7 @@ export class UnsavedInputGuard { return component.hasUnsavedChanges ? confirm( - "Changes to this page may be lost! Are you sure you want to leave?" + "Changes to this page will be lost! Are you sure you want to leave?" ) : true; } diff --git a/src/app/helpers/app-initializer/app-initializer.ts b/src/app/helpers/app-initializer/app-initializer.ts index 7871e39be..6b2596bc8 100644 --- a/src/app/helpers/app-initializer/app-initializer.ts +++ b/src/app/helpers/app-initializer/app-initializer.ts @@ -1,8 +1,11 @@ import { Inject, Injectable, Optional } from "@angular/core"; import { ConfigService } from "@services/config/config.service"; import { API_CONFIG } from "@services/config/config.tokens"; +import { ImportsService } from "@services/import/import.service"; import { BawTheme } from "@services/theme/theme.service"; +type AppInitializerType = () => Promise; + /** * App Initializer class. * Class is a wrapper for the factory function as error handler @@ -13,9 +16,15 @@ export class AppInitializer { public static initializerFactory( // SSR Sets a default config @Optional() @Inject(API_CONFIG) config: Promise, - configService: ConfigService - ): () => Promise { - return async (): Promise => configService.init(config); + configService: ConfigService, + _httpBackend: any, + _IS_SERVER_PLATFORM: any, + importsService: ImportsService + ): AppInitializerType { + const instantiatedConfig = configService.init(config); + const dynamicImports = importsService.init(); + + return async () => await Promise.all([instantiatedConfig, dynamicImports]); } public static apiRootFactory(configService: ConfigService): string { diff --git a/src/app/helpers/context/context-decorators.ts b/src/app/helpers/context/context-decorators.ts new file mode 100644 index 000000000..6684595d8 --- /dev/null +++ b/src/app/helpers/context/context-decorators.ts @@ -0,0 +1,49 @@ +import { ChangeDetectorRef, ElementRef } from "@angular/core"; +import { ContextRequestEvent, UnknownContext } from "./context"; + +export interface WithContext { + elementRef: ElementRef; + changeDetectorRef: ChangeDetectorRef; + ngAfterViewInit?(): void; +} + +/** + * You can use this decorator to bind a context subscription to a class property. + * + * @example + * ```ts + * class MyComponent { + * @ContextSubscription("my-context") + * public handleContextChange(newValue) { + * console.debug({ newValue }); + * } + * } + * ``` + */ +export function ContextSubscription(token: UnknownContext) { + return (target: WithContext, propertyKey: string) => { + const originalNgAfterViewInit = target.ngAfterViewInit; + + target.ngAfterViewInit = function () { + // invoke the original ngAfterViewInit method if it exists + if (originalNgAfterViewInit) { + originalNgAfterViewInit(); + } + + const handler = (...args: unknown[]) => { + // call the original method with the correct context + target[propertyKey].apply(this, args); + + // request a zone.js change detection cycle + // we do this so that when a context change occurs, the Angular's zone + // detection cycle is triggered + this.changeDetectorRef.detectChanges(); + }; + + const contextRequest = new ContextRequestEvent(token, handler, true); + + const element = this.elementRef.nativeElement; + element.dispatchEvent(contextRequest); + }; + }; +} diff --git a/src/app/helpers/context/context.ts b/src/app/helpers/context/context.ts new file mode 100644 index 000000000..d1a3950ea --- /dev/null +++ b/src/app/helpers/context/context.ts @@ -0,0 +1,52 @@ +// an implementation of the web components context's proposal +// https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md + +/** + * A context key. + * + * A context key can be any type of object, including strings and symbols. The + * Context type brands the key type with the `__context__` property that + * carries the type of the value the context references. + */ +export type Context = KeyType & { __context__: ValueType }; + +/** + * An unknown context type + */ +export type UnknownContext = Context; + +/** + * A helper type which can extract a Context value type from a Context type + */ +export type ContextType = T extends Context< + // eslint-disable-next-line @typescript-eslint/no-unused-vars + infer _, + infer V +> + ? V + : never; + +/** + * A function which creates a Context value object + */ +export const createContext = (key: unknown) => + key as Context; + +/** + * A callback which is provided by a context requester and is called with the value satisfying the request. + * This callback can be called multiple times by context providers as the requested value is changed. + */ +export type ContextCallback = ( + value: ValueType, + unsubscribe?: () => void +) => void; + +export class ContextRequestEvent extends Event { + public constructor( + public readonly context: T, + public readonly callback: ContextCallback>, + public readonly subscribe: boolean = false + ) { + super("context-request", { bubbles: true, composed: true }); + } +} diff --git a/src/app/helpers/filters/audioEventFilters.ts b/src/app/helpers/filters/audioEventFilters.ts new file mode 100644 index 000000000..b28de7da7 --- /dev/null +++ b/src/app/helpers/filters/audioEventFilters.ts @@ -0,0 +1,39 @@ +import { InnerFilter } from "@baw-api/baw-api.service"; +import { AudioRecording } from "@models/AudioRecording"; +import { DateTime } from "luxon"; +import { filterAnd } from "./filters"; + +/** + * Adds date range conditions to an existing filter in the ISO8601 date format + * + * @param filters An existing filter to add the date range conditions to. If no filter is provided, a new filter will be created + * @param startDate (optional) The minimum `recordedDate` of the date range + * @param endDate (optional) The maximum `recordedEndDate` allowed + * @returns A new filter with all the same conditions as the initial filter, with the date range conditions added in an `and` expression + */ +export function filterEventRecordingDate( + filters: InnerFilter, + startDate?: DateTime, + endDate?: DateTime +): InnerFilter { + if (startDate) { + // to return the most data that matches the date range interval we want to return any audio recordings that have any audio that overlaps + // with the date range interval. But we don't want to return any recordings that just touch on the ends + // therefore, the conditions should be `recordedEndDate > startFilterDate && recordedDate < endFilterDate` + const startDateFilter = { + "audioRecordings.recordedEndDate": { greaterThan: startDate }, + } as InnerFilter; + + filters = filterAnd(filters, startDateFilter); + } + + if (endDate) { + const endDateFilters: InnerFilter = { + "audioRecordings.recordedDate": { lessThan: endDate }, + } as InnerFilter; + + filters = filterAnd(filters, endDateFilters); + } + + return filters; +} diff --git a/src/app/helpers/page/pageRouting.ts b/src/app/helpers/page/pageRouting.ts index c801d37bd..fb489750e 100644 --- a/src/app/helpers/page/pageRouting.ts +++ b/src/app/helpers/page/pageRouting.ts @@ -4,6 +4,7 @@ import { FormTouchedGuard } from "@guards/form/form.guard"; import { Option } from "@helpers/advancedTypes"; import { isInstantiated } from "@helpers/isInstantiated/isInstantiated"; import { StrongRoute } from "@interfaces/strongRoute"; +import { UnsavedInputGuard } from "@guards/input/input.guard"; import { getPageInfos } from "./pageComponent"; /** @@ -32,7 +33,7 @@ export function getRouteConfigForPage(strongRoute: StrongRoute): Option { path: "", pathMatch: "full", component: pageInfo.component, - canDeactivate: [FormTouchedGuard], + canDeactivate: [FormTouchedGuard, UnsavedInputGuard], }, { path: "", diff --git a/src/app/helpers/paginationTemplate/paginationTemplate.ts b/src/app/helpers/paginationTemplate/paginationTemplate.ts index 54a407ed2..c1b55150c 100644 --- a/src/app/helpers/paginationTemplate/paginationTemplate.ts +++ b/src/app/helpers/paginationTemplate/paginationTemplate.ts @@ -30,8 +30,14 @@ export abstract class PaginationTemplate * Maximum number of elements for current filter */ public collectionSize: number; + // TODO: this condition seems to be an artifact of an underlying bug + // we should find why we have to use this condition with ngb-pagination and + // fix the root cause of the bug /** * Tracks whether to display the pagination buttons + * if you do not place the paginations inside an if condition using this value + * all query string parameters (such as page=2) will be removed when the page + * first loads */ public displayPagination: boolean; /** @@ -46,6 +52,11 @@ export abstract class PaginationTemplate * Tracks the current user filter input */ public filter: string; + /** + * A configuraiton property that can be used to overwrite how many + * items are fetched in a page of results + */ + public pageSize?: number; /** * Tracks the current filter page */ @@ -85,8 +96,10 @@ export abstract class PaginationTemplate public ngOnInit() { // Set pagination defaults + // TODO: this is overwriting the global NgbPagination config every time + // a component that uses this paginationTemplate is created this.config.maxSize = 3; - this.config.pageSize = defaultApiPageSize; + this.config.pageSize = this.pageSize ?? defaultApiPageSize; this.config.rotate = true; this.displayPagination = false; @@ -178,8 +191,17 @@ export abstract class PaginationTemplate * Generate the filter for the api request */ protected generateFilter(): Filters { + // if the template has an explicit page size set, we should add the number + // of items to the request body + // if the user has not set an explit page size, we want to use the default + // returned by the api + const pageItemFilters = this.pageSize ? { items: this.pageSize } : {}; + return { - paging: { page: this.page }, + paging: { + page: this.page, + ...pageItemFilters, + }, filter: this.filter ? ({ ...this.defaultInnerFilter(), diff --git a/src/app/helpers/query-string-parameters/query-string-parameters.ts b/src/app/helpers/query-string-parameters/query-string-parameters.ts index c1d595567..99d5843a0 100644 --- a/src/app/helpers/query-string-parameters/query-string-parameters.ts +++ b/src/app/helpers/query-string-parameters/query-string-parameters.ts @@ -2,9 +2,9 @@ import { Params } from "@angular/router"; import { isInstantiated } from "@helpers/isInstantiated/isInstantiated"; import { DateTime, Duration } from "luxon"; -export interface IQueryStringParameterSpec { - [key: string]: ISerializationTechnique; -} +export type IQueryStringParameterSpec> = { + [K in keyof T]: ISerializationTechnique; +}; interface ISerializationTechnique { serialize: (value: any) => string; @@ -79,6 +79,7 @@ export function serializeObjectToParams( // null and undefined values are omitted when used on angular HTTPParams // therefore, we should not serialize them as they will have no effect on the query string // we use isInstantiated here because we want to serialize "falsey" values such as 0 and empty strings + // we also omit empty arrays so that we don't end up with empty query string parameters for arrays if (!isInstantiated(value)) { return; } diff --git a/src/app/models/AudioRecording.ts b/src/app/models/AudioRecording.ts index 2d5ab81dd..187d59f1b 100644 --- a/src/app/models/AudioRecording.ts +++ b/src/app/models/AudioRecording.ts @@ -1,6 +1,9 @@ import { Injector } from "@angular/core"; import { id, IdOr } from "@baw-api/api-common"; -import { audioRecordingOriginalEndpoint } from "@baw-api/audio-recording/audio-recordings.service"; +import { + audioRecordingOriginalEndpoint, + audioRecordingMediaEndpoint, +} from "@baw-api/audio-recording/audio-recordings.service"; import { ACCOUNT, SHALLOW_SITE } from "@baw-api/ServiceTokens"; import { audioRecordingBatchRoutes, @@ -131,6 +134,16 @@ export class AudioRecording return apiRoot + audioRecordingOriginalEndpoint(this.id); } + /** + * An api endpoint that can be used to split media using start_offset and + * end_offset url parameters + */ + public getMediaUrl(apiRoot: string): string { + return ( + apiRoot + audioRecordingMediaEndpoint(this.id, this.originalFileExtension) + ); + } + /** Routes to the batch download page */ public getBatchDownloadUrl( project?: IdOr, @@ -146,6 +159,10 @@ export class AudioRecording return this.getDetailsUrl(); } + public get originalFileExtension(): string { + return this.originalFileName?.split(".").pop() ?? ""; + } + /** Routes to the details page relative to the parent models */ public getDetailsUrl( project?: IdOr, diff --git a/src/app/models/data/Annotation.ts b/src/app/models/data/Annotation.ts new file mode 100644 index 000000000..f336d0814 --- /dev/null +++ b/src/app/models/data/Annotation.ts @@ -0,0 +1,77 @@ +import { annotationMenuItem } from "@components/library/library.menus"; +import { listenRecordingMenuItem } from "@components/listen/listen.menus"; +import { DateTimeTimezone } from "@interfaces/apiInterfaces"; +import { AbstractModelWithoutId } from "@models/AbstractModel"; +import { IAudioEvent } from "@models/AudioEvent"; +import { AudioRecording } from "@models/AudioRecording"; +import { ITag } from "@models/Tag"; +import { ITagging, Tagging } from "@models/Tagging"; +import { MediaService } from "@services/media/media.service"; + +export interface IAnnotation extends Required { + tags: ITag[]; + audioRecording: AudioRecording; +} + +// this class is not backed by the api or a database table +// I have created this model so that we can pass around a single model that +// contains all the information we need about an annotation +// +// this model is created from the AnnotationService and MediaService's +export class Annotation extends AbstractModelWithoutId implements IAnnotation { + public constructor(data: IAnnotation, mediaService: MediaService) { + super(data); + this.mediaService = mediaService; + } + + public id: number; + public audioRecordingId: number; + public startTimeSeconds: number; + public endTimeSeconds: number; + public lowFrequencyHertz: number; + public highFrequencyHertz: number; + public isReference: boolean; + public taggings: Tagging[] | ITagging[]; + public provenanceId: number; + public creatorId: number; + public createdAt: string | DateTimeTimezone; + public updaterId: number; + public updatedAt: string | DateTimeTimezone; + public deleterId: number; + public deletedAt: string | DateTimeTimezone; + public tags: ITag[]; + public audioRecording: AudioRecording; + + private mediaService: MediaService; + + public get viewUrl(): string { + return annotationMenuItem.route.format({ + audioRecordingId: this.audioRecordingId, + audioEventId: this.id, + }); + } + + public get listenViewUrl(): string { + return listenRecordingMenuItem.route.format( + { audioRecordingId: this.audioRecordingId }, + { start: this.startTimeSeconds, padding: 10 } + ); + } + + public get audioLink(): string { + return this.mediaService.createMediaUrl( + this.audioRecording, + this.startTimeSeconds, + this.endTimeSeconds + ); + } + + public contextUrl(contextSize: number): string { + return this.mediaService.createMediaUrl( + this.audioRecording, + this.startTimeSeconds, + this.endTimeSeconds, + contextSize, + ); + } +} diff --git a/src/app/models/data/parametersModel.ts b/src/app/models/data/parametersModel.ts new file mode 100644 index 000000000..11d8163ab --- /dev/null +++ b/src/app/models/data/parametersModel.ts @@ -0,0 +1,21 @@ +import { Params } from "@angular/router"; +import { Filters } from "@baw-api/baw-api.service"; +import { IQueryStringParameterSpec } from "@helpers/query-string-parameters/query-string-parameters"; +import { AbstractModelWithoutId } from "@models/AbstractModel"; + +export interface IParameterModel { + toQueryParams(): Params; + toFilter(): Filters; +} + +export function ParameterModel(_serialization: IQueryStringParameterSpec) { + return class Base implements IParameterModel { + public toQueryParams(): Params { + throw new Error("Method not implemented."); + } + + public toFilter(): Filters { + throw new Error("Method not implemented."); + } + } +} diff --git a/src/app/services/baw-api/ServiceProviders.ts b/src/app/services/baw-api/ServiceProviders.ts index 14fb920af..cbb1da838 100644 --- a/src/app/services/baw-api/ServiceProviders.ts +++ b/src/app/services/baw-api/ServiceProviders.ts @@ -1,3 +1,5 @@ +import { annotationResolvers, AnnotationService } from "@services/models/annotation.service"; +import { MediaService } from "@services/media/media.service"; import { accountResolvers, AccountsService } from "./account/accounts.service"; import { analysisJobItemResultResolvers, @@ -290,6 +292,15 @@ const serviceList = [ serviceToken: Tokens.WEBSITE_STATUS, service: WebsiteStatusService, }, + { + serviceToken: Tokens.ANNOTATION, + service: AnnotationService, + resolvers: annotationResolvers, + }, + { + serviceToken: Tokens.MEDIA, + service: MediaService, + } ]; const services = serviceList.map(({ service }) => service); diff --git a/src/app/services/baw-api/ServiceTokens.ts b/src/app/services/baw-api/ServiceTokens.ts index 6f53be6f2..26fca165c 100644 --- a/src/app/services/baw-api/ServiceTokens.ts +++ b/src/app/services/baw-api/ServiceTokens.ts @@ -39,6 +39,9 @@ import { AudioEventProvenance } from "@models/AudioEventProvenance"; import { EventSummaryReport } from "@models/EventSummaryReport"; import { AudioEventImport } from "@models/AudioEventImport"; import { WebsiteStatus } from "@models/WebsiteStatus"; +import { Annotation } from "@models/data/Annotation"; +import { AnnotationService } from "@services/models/annotation.service"; +import { MediaService } from "@services/media/media.service"; import type { AccountsService } from "./account/accounts.service"; import type { AnalysisJobItemsService } from "./analysis/analysis-job-items.service"; import type { AnalysisJobsService } from "./analysis/analysis-jobs.service"; @@ -211,3 +214,5 @@ export const AUDIO_EVENT_PROVENANCE = new ServiceToken("AUDIO_EVENT_SUMMARY_REPORT"); export const AUDIO_EVENT_IMPORT = new ServiceToken("AUDIO_EVENT_IMPORT"); export const WEBSITE_STATUS = new ServiceToken("WEBSITE_STATUS"); +export const ANNOTATION = new ServiceToken("ANNOTATION"); +export const MEDIA = new ServiceToken("MEDIA"); diff --git a/src/app/services/baw-api/audio-recording/audio-recordings.service.ts b/src/app/services/baw-api/audio-recording/audio-recordings.service.ts index 2ee251ef6..ff8c06b46 100644 --- a/src/app/services/baw-api/audio-recording/audio-recordings.service.ts +++ b/src/app/services/baw-api/audio-recording/audio-recordings.service.ts @@ -14,12 +14,15 @@ import { IdOr, IdParamOptional, option, + param, ReadonlyApi, } from "../api-common"; import { BawApiService, Filters } from "../baw-api.service"; import { Resolvers } from "../resolver-common"; const audioRecordingId: IdParamOptional = id; +const fileExtension = param; + const endpoint = stringTemplate`/audio_recordings/${audioRecordingId}${option}`; const downloadEndpoint = stringTemplate`/audio_recordings/downloader`; @@ -30,6 +33,12 @@ const downloadEndpoint = stringTemplate`/audio_recordings/downloader`; */ export const audioRecordingOriginalEndpoint = stringTemplate`/audio_recordings/${audioRecordingId}/original`; +/** + * A path that can be used to download audio recordings that have been split + * with start_offset and end_offset url parameters + */ +export const audioRecordingMediaEndpoint = stringTemplate`/audio_recordings/${audioRecordingId}/media.${fileExtension}`; + @Injectable() export class AudioRecordingsService implements ReadonlyApi { public constructor( diff --git a/src/app/services/baw-api/baw-session.service.spec.ts b/src/app/services/baw-api/baw-session.service.spec.ts index a5e57ed7e..3af06c3c8 100644 --- a/src/app/services/baw-api/baw-session.service.spec.ts +++ b/src/app/services/baw-api/baw-session.service.spec.ts @@ -119,4 +119,61 @@ describe("BawSessionService", () => { logout(); }); }); + + describe("addAuthTokenToUrl", () => { + beforeEach(() => { + defaultUser = new User(generateUser()); + defaultAuthToken = modelData.authToken(); + spec.service.setLoggedInUser(defaultUser, defaultAuthToken); + }); + + it("should not modify the url if the user is logged out", () => { + logout(); + + const testUrl = modelData.internet.url(); + const result = spec.service.addAuthTokenToUrl(testUrl); + expect(result).toBe(testUrl); + }); + + it("should update the auth token if it has changed", () => { + const testUrl = modelData.internet.url(); + const initialUrl = spec.service.addAuthTokenToUrl(testUrl); + + defaultUser = new User(generateUser()); + defaultAuthToken = modelData.authToken(); + login(); + + const newUrl = spec.service.addAuthTokenToUrl(testUrl); + expect(initialUrl).not.toEqual(newUrl); + }); + + it("should not update the auth token if it has not changed", () => { + const testUrl = modelData.internet.url(); + const initialUrl = spec.service.addAuthTokenToUrl(testUrl); + + login(); + + const newUrl = spec.service.addAuthTokenToUrl(testUrl); + expect(initialUrl).toEqual(newUrl); + }); + + // because there are no url parameters, we expect that the auth token + // will be added with the "?" prefix + it("should add an auth token to a url without any parameters", () => { + const testUrl = modelData.internet.url(); + const result = spec.service.addAuthTokenToUrl(testUrl); + expect(result).toEqual(`${testUrl}/?user_token=${defaultAuthToken}`); + }); + + // because there are already url parameters, we expect that the auth token + // will be added with the "&" prefix + it("should add an auth token to a url with url parameters", () => { + const testUrl = `${modelData.internet.url()}/?token=foo¶m2=bar`; + + const expected = `${testUrl}&user_token=${defaultAuthToken}`; + const realized = spec.service.addAuthTokenToUrl(testUrl); + + expect(realized).toEqual(expected); + }); + }); }); diff --git a/src/app/services/baw-api/baw-session.service.ts b/src/app/services/baw-api/baw-session.service.ts index fa7352799..c782ce669 100644 --- a/src/app/services/baw-api/baw-session.service.ts +++ b/src/app/services/baw-api/baw-session.service.ts @@ -61,4 +61,15 @@ export class BawSessionService { public get authTrigger(): Observable { return this._authTrigger; } + + public addAuthTokenToUrl(url: string): string { + if (!this.authToken) { + return url; + } + + const urlObj = new URL(url); + urlObj.searchParams.set("user_token", this.authToken); + + return urlObj.toString(); + } } diff --git a/src/app/services/baw-api/reports/event-report/event-summary-report.service.ts b/src/app/services/baw-api/reports/event-report/event-summary-report.service.ts index 4d1066795..8648d177d 100644 --- a/src/app/services/baw-api/reports/event-report/event-summary-report.service.ts +++ b/src/app/services/baw-api/reports/event-report/event-summary-report.service.ts @@ -192,7 +192,7 @@ export class EventSummaryReportService ], missingAnalysisCoverage: [ { startDate: "2023-05-23", endDate: "2023-05-24" }, - { startDate: "2023-05-28", endDate: "2023-05-29" } + { startDate: "2023-05-28", endDate: "2023-05-29" }, ], failedAnalysisCoverage: [ { startDate: "2023-05-26", endDate: "2023-05-27" }, @@ -215,7 +215,10 @@ export class EventSummaryReportService // we need to fill in the metadata object that would usually come with responses fakeReport.eventGroups.forEach((eventGroup) => eventGroup.addMetadata({ - paging: { total: fakeReport.eventGroups.length, items: fakeReport.eventGroups.length }, + paging: { + total: fakeReport.eventGroups.length, + items: fakeReport.eventGroups.length, + }, }) ); @@ -265,14 +268,11 @@ class EventSummaryReportResolver extends BawResolver< public createProviders( name: string, - resolver: Type< - { - resolve: ResolveFn>; -} - >, + resolver: Type<{ + resolve: ResolveFn< + ResolvedModel<[EventSummaryReport, EventSummaryReportParameters]> + >; + }>, deps: Type[] ): ResolverNames & { providers: BawProvider[] } { const filterShowProvider = { @@ -297,7 +297,7 @@ class EventSummaryReportResolver extends BawResolver< const fakeProvenances = [1]; const parametersModel = new EventSummaryReportParameters({ - ...route.queryParams + ...route.queryParams, }); parametersModel.tags = fakeEvents; @@ -324,10 +324,6 @@ class EventSummaryReportResolver extends BawResolver< } } -export class EventSummaryReportResolverNative { - public userResolver() {} -} - export const eventSummaryResolvers = new EventSummaryReportResolver([ EventSummaryReportService, ]).create("AudioEventSummaryReport"); diff --git a/src/app/services/config/config.module.ts b/src/app/services/config/config.module.ts index 0c0a51cdb..e12d9f593 100644 --- a/src/app/services/config/config.module.ts +++ b/src/app/services/config/config.module.ts @@ -3,6 +3,7 @@ import { APP_INITIALIZER, NgModule, Optional } from "@angular/core"; import { AppInitializer } from "@helpers/app-initializer/app-initializer"; import { ToastrModule } from "ngx-toastr"; import { IS_SERVER_PLATFORM } from "src/app/app.helper"; +import { ImportsService } from "@services/import/import.service"; import { ConfigService } from "./config.service"; import { API_CONFIG, API_ROOT } from "./config.tokens"; @@ -17,6 +18,7 @@ import { API_CONFIG, API_ROOT } from "./config.tokens"; ConfigService, HttpBackend, IS_SERVER_PLATFORM, + ImportsService, ], multi: true, }, @@ -26,6 +28,7 @@ import { API_CONFIG, API_ROOT } from "./config.tokens"; deps: [ConfigService], }, ConfigService, + ImportsService, ], }) export class ConfigModule {} diff --git a/src/app/services/import/import.service.spec.ts b/src/app/services/import/import.service.spec.ts new file mode 100644 index 000000000..923bffd24 --- /dev/null +++ b/src/app/services/import/import.service.spec.ts @@ -0,0 +1,18 @@ +import { createServiceFactory, SpectatorService } from "@ngneat/spectator"; +import { ImportsService } from "./import.service"; + +describe("ImportsService", () => { + let spectator: SpectatorService; + + const createService = createServiceFactory({ + service: ImportsService, + }); + + beforeEach(() => { + spectator = createService(); + }); + + it("should create", () => { + expect(spectator.service).toBeInstanceOf(ImportsService); + }); +}); diff --git a/src/app/services/import/import.service.ts b/src/app/services/import/import.service.ts new file mode 100644 index 000000000..88adab4f8 --- /dev/null +++ b/src/app/services/import/import.service.ts @@ -0,0 +1,34 @@ +import { Inject, Injectable } from "@angular/core"; +import { IS_SERVER_PLATFORM } from "src/app/app.helper"; + +@Injectable() +export class ImportsService { + public constructor(@Inject(IS_SERVER_PLATFORM) private isServer: boolean) {} + + public async init(): Promise { + if (this.isServer) { + return; + } + + // we have dynamically imported the web components after the SSR guard + // so that Lit doesn't try to run in an SSR context + // If Lit does end up running inside an SSR context, it will throw an error + // because it can't bootstrap itself to the document, and cannot find the + // custom elements registry + // + // we use a APP_INITIALIZER so that we can await the dynamic import to prevent + // race conditions in defining custom elements and using the web components + // we cannot put this in the AppComponent's ngOnInit because ngOnInit does + // not support async operations + await this.importDynamicModule("@ecoacoustics/web-components/components.js"); + } + + public async importDynamicModule(path: string): Promise { + const clientOrigin = window.location.origin; + const importUrl = `${clientOrigin}/${path}`; + + // we have to use vite ignore so that vite doesn't bundle and cache the import + // allowing the module to be dynamically imported + await import(/* @vite-ignore */ importUrl); + } +} diff --git a/src/app/services/media/media.service.spec.ts b/src/app/services/media/media.service.spec.ts new file mode 100644 index 000000000..d295070c6 --- /dev/null +++ b/src/app/services/media/media.service.spec.ts @@ -0,0 +1,241 @@ +import { BawSessionService } from "@baw-api/baw-session.service"; +import { createServiceFactory, SpectatorService } from "@ngneat/spectator"; +import { API_ROOT } from "@services/config/config.tokens"; +import { AudioRecording } from "@models/AudioRecording"; +import { generateAudioRecording } from "@test/fakes/AudioRecording"; +import { modelData } from "@test/helpers/faker"; +import { MediaService } from "./media.service"; + +describe("MediaService", () => { + let spec: SpectatorService; + let mockAudio: AudioRecording; + + const createService = createServiceFactory({ + service: MediaService, + providers: [ + { provide: BawSessionService, useValue: {} }, + { provide: API_ROOT, useValue: modelData.internet.domainName() }, + ], + }); + + function setup(): void { + spec = createService(); + } + + beforeEach(() => { + mockAudio = new AudioRecording(generateAudioRecording()); + setup(); + }); + + describe("createMediaUrl", () => { + describe("errors", () => { + // prettier-ignore + const testMatrix = [ + { start: -1, end: 10, error: "Start time must be greater than or equal to 0" }, + { start: 0, end: -1, error: "End time must be greater than or equal to 0" }, + { start: 10, end: 0, error: "End time must be greater than start time" }, + ]; + + for (const test of testMatrix) { + it(test.error, () => { + const testFn = () => { + spec.service.createMediaUrl(mockAudio, test.start, test.end); + }; + + expect(testFn).toThrowError(test.error); + }); + } + + // test tests are not in the test matrix because they depend on the + // instance of the audio recording for their start/end times + it("should throw an error if the start time is greater than the duration of the audio recording", () => { + const expectedError = + "Start time is greater than the duration of the audio recording"; + const test = () => { + spec.service.createMediaUrl( + mockAudio, + mockAudio.durationSeconds + 0.01, + mockAudio.durationSeconds + 10 + ); + }; + + expect(test).toThrowError(expectedError); + }); + + it("should throw an error if the end time is greater than the duration of the audio recording", () => { + const expectedError = + "End time is greater than the duration of the audio recording"; + const test = () => { + spec.service.createMediaUrl( + mockAudio, + 0, + mockAudio.durationSeconds + 0.01 + ); + }; + + expect(test).toThrowError(expectedError); + }); + }); + + // TODO: these tests and associated code should be removed once the api + // range request bug is fixed + // see: https://github.com/QutEcoacoustics/baw-server/issues/681 + describe("start/end time rounding", () => { + it("should round down the start times of the recording", () => { + // if we are going to round start times, we want to round down so that + // no information is cut off / lost + const start = 0.8; + const expectedStart = 0; + const end = mockAudio.durationSeconds; + + const url = spec.service.createMediaUrl(mockAudio, start, end); + + expect(url).toContain(`start_offset=${expectedStart}`); + }); + + it("should round up the end times of the recording", () => { + // if we are going to round end times, we want to round up so that no + // information is cut off / lost + const end = 10.1; + const expectedEnd = 11; + + const url = spec.service.createMediaUrl(mockAudio, 0, end); + + expect(url).toContain(`end_offset=${expectedEnd}`); + }); + }); + + // a lot of these tests test functionality that is negated by the start/end + // time rounding + // once start/end time rounding is no longer needed, these tests should + // become useful for testing functionality + describe("start/end time padding", () => { + it("should allow an argument to allow padding the start and end times", () => { + // we know that these tested start/end times are within the mock + // audio recordings duration because the mock model generator has a + // minimum duration of 30 seconds + const start = 2; + const end = 10; + const padding = 0.1; + + // the padded start/end times should rounded down/up respectively + const expectedStart = 1; + const expectedEnd = 11; + + const url = spec.service.createMediaUrl(mockAudio, start, end, padding); + + expect(url).toContain(`start_offset=${expectedStart}`); + expect(url).toContain(`end_offset=${expectedEnd}`); + }); + + // TODO: this test is failing because the end time is being rounded to 3 + xit("should pad to 0.5 seconds if the padding argument produces a duration than the minimum duration", () => { + // if we executed the function with the requested padding, it would + // result in an audio recording with a duration of 0.4 seconds + // because 0.4 seconds is less than the minimum requirement of 0.5 + // seconds. To fix this, we pad the audio recording to 0.5 seconds + const start = 1; + const end = 1.2; + const padding = 0.2; + + // the padded start/end times should be rounded down/up + const expectedStart = 0; + const expectedEnd = 2; + + const url = spec.service.createMediaUrl(mockAudio, start, end, padding); + + expect(url).toContain(`start_offset=${expectedStart}`); + expect(url).toContain(`end_offset=${expectedEnd}`); + }); + + it("should not pad if the duration is greater than 0.5 seconds", () => { + const start = 1; + const end = 5; + + const url = spec.service.createMediaUrl(mockAudio, start, end); + + expect(url).toContain(`start_offset=${start}`); + expect(url).toContain(`end_offset=${end}`); + }); + + it("should not pad if the duration is 0.5 seconds", () => { + const start = 1; + const end = 1.5; + + // we do expect the end to be rounded to 2 so that it is a whole number + const expectedEnd = 2; + + const url = spec.service.createMediaUrl(mockAudio, start, end); + + expect(url).toContain(`start_offset=${start}`); + expect(url).toContain(`end_offset=${expectedEnd}`); + }); + + // TODO: this test is failing + xit("should pad the duration to 0.5 seconds if the difference is less than 0.5 seconds", () => { + const start = 1; + const end = 1.4; + + const expectedStart = 0; + const expectedEnd = 2; + + const url = spec.service.createMediaUrl(mockAudio, start, end); + + expect(url).toContain(`start_offset=${expectedStart}`); + expect(url).toContain(`end_offset=${expectedEnd}`); + }); + + it("should not pad the start of the event if the start time is 0", () => { + const start = 0; + const end = 0.1; + + const expectedEnd = 1; + + const url = spec.service.createMediaUrl(mockAudio, start, end); + + expect(url).toContain(`start_offset=${start}`); + expect(url).toContain(`end_offset=${expectedEnd}`); + }); + + it("should not pad the end time if the end time is the same as the duration of the audio recording", () => { + const start = mockAudio.durationSeconds - 0.1; + const end = mockAudio.durationSeconds; + + const expectedStart = mockAudio.durationSeconds - 1; + + const url = spec.service.createMediaUrl(mockAudio, start, end); + + expect(url).toContain(`start_offset=${expectedStart}`); + expect(url).toContain(`end_offset=${end}`); + }); + + // we expect that the difference between the start and 0.5 seconds is added + // to the end time + it("should pad to 0 start time if the start time is close to 0", () => { + const start = 0.1; + const end = 0.2; + + const expectedStart = 0; + const expectedEnd = 1; + + const url = spec.service.createMediaUrl(mockAudio, start, end); + + expect(url).toContain(`start_offset=${expectedStart}`); + expect(url).toContain(`end_offset=${expectedEnd}`); + }); + + it("should pad to the recordings duration if the end time is close to the end", () => { + const start = mockAudio.durationSeconds - 0.2; + const end = mockAudio.durationSeconds - 0.1; + + const expectedStart = mockAudio.durationSeconds - 1; + const expectedEnd = mockAudio.durationSeconds; + + const url = spec.service.createMediaUrl(mockAudio, start, end); + + expect(url).toContain(`start_offset=${expectedStart}`); + expect(url).toContain(`end_offset=${expectedEnd}`); + }); + }); + }); +}); diff --git a/src/app/services/media/media.service.ts b/src/app/services/media/media.service.ts new file mode 100644 index 000000000..12150bb1c --- /dev/null +++ b/src/app/services/media/media.service.ts @@ -0,0 +1,174 @@ +import { Inject, Injectable } from "@angular/core"; +import { Params } from "@angular/router"; +import { BawSessionService } from "@baw-api/baw-session.service"; +import { AudioRecording } from "@models/AudioRecording"; +import { API_ROOT } from "@services/config/config.tokens"; + +@Injectable() +export class MediaService { + public constructor( + private session: BawSessionService, + @Inject(API_ROOT) private apiRoot: string + ) {} + + /** + * Generates a media URL for an audio recording with specified start and end offsets. + * Optionally includes an authentication token if the user is logged in and additional query parameters. + * + * @param audioRecording - The ID of the audio recording. + * @param start - The start offset in seconds. + * @param end - The end offset in seconds. + * @param params - Additional query string parameters. + * @returns The generated media URL as a string. + */ + public createMediaUrl( + audioRecording: AudioRecording, + start: number, + end: number, + padding: number = 0, + params: Params = {} + ): string { + // check the start and end times are valid + if (start < 0) { + throw new Error("Start time must be greater than or equal to 0"); + } else if (end < 0) { + throw new Error("End time must be greater than or equal to 0"); + } else if (end < start) { + throw new Error("End time must be greater than start time"); + } + + // check the start and end times fit inside the audio recording + if (start > audioRecording.durationSeconds) { + throw new Error( + "Start time is greater than the duration of the audio recording" + ); + } else if (end > audioRecording.durationSeconds) { + throw new Error( + "End time is greater than the duration of the audio recording" + ); + } + + // this is here to get around a rounding bug with range requests in the api + // see: https://github.com/QutEcoacoustics/baw-server/issues/681 + // TODO: remove the rounding patch once the api is fixed + // TODO: this ceil might result in the audio being rounded to longer than the recording. We should add a condition + const safeStartTime = Math.floor(start); + const safeEndTime = Math.ceil(end); + + // the baw-api enforces that split audio recordings must have a minimum + // duration of 0.5 seconds + // because it is possible to create events less than 0.5 seconds, we need to + // pad the start and end times around the audio event to ensure that the + // split audio is at least 0.5 seconds + const minimumDuration = 0.05 as const; + const proposedDuration = safeStartTime - safeEndTime; + const proposedDifference = proposedDuration - minimumDuration; + const requiredPaddingAmount = Math.max(proposedDifference, padding); + + const [paddedStart, paddedEnd] = this.padAudioUrl( + safeStartTime, + safeEndTime, + requiredPaddingAmount + ); + + let [fitStart, fitEnd] = this.fitAudioUrl( + paddedStart, + paddedEnd, + audioRecording + ); + + // we round here again so that we get a nice round number + // we don't have to worry about checks to see if the recording is a minimum + // length because we only ever add more information when rounding + // + // we do however, have to ensure that the end-time is within the bounds of + // the audio recording + // to do this, we check if rounded end time would be longer than the + // duration of the recording. In this case, we have no option but to round + // round down and add the lost duration to the start time + fitStart = Math.floor(fitStart); + + const roundedEnd = Math.ceil(fitEnd); + if (fitEnd + roundedEnd > audioRecording.durationSeconds) { + const newEnd = Math.floor(fitEnd); + const subtractedDifference = fitEnd - newEnd; + + // we know that there must be room at the start of the recording + // because there is a minimum recording time when uploading that is the + // same length as the minimum split duration + fitStart -= subtractedDifference; + fitEnd = newEnd; + } else { + fitEnd = roundedEnd; + } + + let path = + audioRecording.getMediaUrl(this.apiRoot) + + `?start_offset=${fitStart}` + + `&end_offset=${fitEnd}`; + + // if the user is logged in, we want to add their auth token to the + // query string parameters + // we do not add the auth token if the user is not logged in because the + // auth token will be undefined, resulting in `&user_token=undefined` + if (this.session.authToken) { + path = this.session.addAuthTokenToUrl(path); + } + + // add any additional query string parameters + if (Object.keys(params).length > 0) { + path += `&${new URLSearchParams(params).toString()}`; + } + + return path; + } + + private padAudioUrl( + start: number, + end: number, + padAmount: number = 0 + ): [start: number, end: number] { + const sidePadding = padAmount / 2; + + start -= sidePadding; + end += sidePadding; + + return [start, end]; + } + + private fitAudioUrl( + start: number, + end: number, + audioRecording: AudioRecording + ): [start: number, end: number] { + if (start < 0) { + // because we don't want to create fractional start/end times, we need to + // round up the difference to the nearest whole number + // we round up to guarantee that the start time is at least 0 + // + // TODO: remove this ceil once the following api issue is fixed + // https://github.com/QutEcoacoustics/baw-server/issues/681 + const difference = Math.ceil(Math.abs(start)); + + start += difference; + end += difference; + } + + const recordingDuration = audioRecording.durationSeconds; + if (end > recordingDuration) { + // similar to the start time, we need to round up the difference to the + // nearest whole number to avoid fractional start/end times + // we round up to guarantee that the end time is at most the recording + // duration + // this might result in some of the end of the audio being cut off + // + // TODO: remove this ceil once the following api issue is fixed + // https://github.com/QutEcoacoustics/baw-server/issues/681 + const difference = Math.ceil(end - recordingDuration); + start -= difference; + end -= difference; + } + + return [start, end]; + } +} diff --git a/src/app/services/models/annotation.service.spec.ts b/src/app/services/models/annotation.service.spec.ts new file mode 100644 index 000000000..9680b91b8 --- /dev/null +++ b/src/app/services/models/annotation.service.spec.ts @@ -0,0 +1,86 @@ +import { + createServiceFactory, + SpectatorService, + SpyObject, +} from "@ngneat/spectator"; +import { TagsService } from "@baw-api/tag/tags.service"; +import { AudioRecordingsService } from "@baw-api/audio-recording/audio-recordings.service"; +import { MediaService } from "@services/media/media.service"; +import { AudioEvent } from "@models/AudioEvent"; +import { generateAudioEvent } from "@test/fakes/AudioEvent"; +import { INJECTOR, Injector } from "@angular/core"; +import { of } from "rxjs"; +import { Tag } from "@models/Tag"; +import { AudioRecording } from "@models/AudioRecording"; +import { generateAudioRecording } from "@test/fakes/AudioRecording"; +import { generateTag } from "@test/fakes/Tag"; +import { MockBawApiModule } from "@baw-api/baw-apiMock.module"; +import { AnnotationService } from "./annotation.service"; + +describe("AnnotationService", () => { + let spec: SpectatorService; + let injector: SpyObject; + + let mockAudioEvent: AudioEvent; + let mockRecording: AudioRecording; + let mockTags: Tag[]; + + const createService = createServiceFactory({ + service: AnnotationService, + imports: [MockBawApiModule], + providers: [ + { provide: TagsService, useValue: mockTagsService() }, + { provide: AudioRecordingsService, useValue: mockRecordingsService() }, + MediaService, + ], + }); + + function mockTagsService() { + return { filter: () => of(mockTags) }; + } + + function mockRecordingsService() { + return { show: () => of(mockRecording) }; + } + + function setup(): void { + spec = createService(); + injector = spec.inject(INJECTOR); + + mockAudioEvent = new AudioEvent(generateAudioEvent(), injector); + mockRecording = new AudioRecording(generateAudioRecording(), injector); + mockTags = Array.from( + { length: 5 }, + () => new Tag(generateTag(), injector), + ); + } + + beforeEach(() => { + setup(); + }); + + it("should create", () => { + expect(spec.service).toBeInstanceOf(AnnotationService); + }); + + describe("show", () => { + it("should have all the same property values as the original audio event model", async () => { + const result = await spec.service.show(mockAudioEvent); + expect(result).toEqual( + jasmine.objectContaining(mockAudioEvent as any), + ); + }); + + it("should resolve the associated audio recording model", async () => { + const result = await spec.service.show(mockAudioEvent); + expect(result.audioRecording).toEqual(mockRecording); + }); + + // TODO: this test is disabled until a upstream web components PR is merged + // see: https://github.com/ecoacoustics/web-components/pull/222 + xit("should resolve all the associated tag models", async () => { + const result = await spec.service.show(mockAudioEvent); + expect(result.tags).toEqual(mockTags); + }); + }); +}); diff --git a/src/app/services/models/annotation.service.ts b/src/app/services/models/annotation.service.ts new file mode 100644 index 000000000..4276f8830 --- /dev/null +++ b/src/app/services/models/annotation.service.ts @@ -0,0 +1,130 @@ +import { Injectable, Type } from "@angular/core"; +import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; +import { AudioRecordingsService } from "@baw-api/audio-recording/audio-recordings.service"; +import { ProjectsService } from "@baw-api/project/projects.service"; +import { ShallowRegionsService } from "@baw-api/region/regions.service"; +import { + BawProvider, + BawResolver, + ResolvedModel, +} from "@baw-api/resolver-common"; +import { ShallowSitesService } from "@baw-api/site/sites.service"; +import { TagsService } from "@baw-api/tag/tags.service"; +import { AnnotationSearchParameters } from "@components/annotations/pages/annotationSearchParameters"; +import { AudioEvent } from "@models/AudioEvent"; +import { AudioRecording } from "@models/AudioRecording"; +import { Annotation } from "@models/data/Annotation"; +import { Tag } from "@models/Tag"; +import { MediaService } from "@services/media/media.service"; +import { firstValueFrom, Observable, of } from "rxjs"; + +@Injectable() +export class AnnotationService { + public constructor( + private tagsApi: TagsService, + private audioRecordingsApi: AudioRecordingsService, + private mediaService: MediaService + ) {} + + public async show(audioEvent: AudioEvent): Promise { + const tags = await this.showTags(audioEvent); + const audioRecording = await this.showAudioRecording(audioEvent); + + // TODO: this is a tempoary patch for ecoacoustics/web-components#213 + // until it is fixed upstream + const tagDescriptor = tags.map((tag) => tag.text).join(", "); + + const data = { + ...audioEvent, + tags: tagDescriptor, + audioRecording, + }; + + return new Annotation(data as any, this.mediaService); + } + + private async showTags(audioEvent: AudioEvent): Promise { + const tagIds = audioEvent.taggings.map((tagging) => tagging.tagId); + return await firstValueFrom( + this.tagsApi.filter({ + filter: { + id: { + in: tagIds, + }, + } as any, + }) + ); + } + + private async showAudioRecording( + audioEvent: AudioEvent + ): Promise { + return new AudioRecording( + await firstValueFrom( + this.audioRecordingsApi.show(audioEvent.audioRecordingId) + ) + ); + } +} + +interface ResolverNames { + showOptional: string; +} + +// we use a custom resolver here because the annotation service is a virtual +// service that does not have an api backing +// therefore, we cannot use the standard BawApiResolver here +class AnnotationResolver extends BawResolver< + AnnotationSearchParameters, + undefined, + [], + any, + ResolverNames +> { + public constructor() { + super([ProjectsService, ShallowRegionsService, ShallowSitesService]); + } + + public createProviders( + name: string, + resolver: Type<{ + resolve: ResolveFn>; + }>, + deps: Type[] + ): ResolverNames & { providers: BawProvider[] } { + const showOptionalProvider = { + showOptional: name + "ShowOptionalResolver", + providers: [ + { + provide: name + "ShowOptionalResolver", + useClass: resolver, + deps, + }, + ], + }; + + return showOptionalProvider; + } + + public resolverFn( + route: ActivatedRouteSnapshot + ): Observable { + const routeProjectId = route.params["projectId"]; + const routeRegionId = route.params["regionId"]; + const routeSiteId = route.params["siteId"]; + + const data = { + routeProjectId: routeProjectId, + routeRegionId: routeRegionId, + routeSiteId: routeSiteId, + ...route.queryParams, + }; + + const parameterModel = new AnnotationSearchParameters(data); + return of(parameterModel); + } +} + +export const annotationResolvers = new AnnotationResolver().create( + "Annotations" +); diff --git a/src/app/styles/_web-components.scss b/src/app/styles/_web-components.scss new file mode 100644 index 000000000..1678242d2 --- /dev/null +++ b/src/app/styles/_web-components.scss @@ -0,0 +1,8 @@ +:root { + --oe-theme-hue: var(--baw-highlight-hue) !important; + --oe-theme-saturation: var(--baw-highlight-saturation) !important; + --oe-theme-lightness: var(--baw-highlight-lightness) !important; + + --oe-info-color: var(--baw-info) !important; + --oe-danger-color: var(--baw-danger) !important; +} diff --git a/src/app/test/fakes/data/Annotation.ts b/src/app/test/fakes/data/Annotation.ts new file mode 100644 index 000000000..7d14a9794 --- /dev/null +++ b/src/app/test/fakes/data/Annotation.ts @@ -0,0 +1,35 @@ +import { IAnnotation } from "@models/data/Annotation"; +import { modelData } from "@test/helpers/faker"; +import { Tag } from "@models/Tag"; +import { AudioRecording } from "@models/AudioRecording"; +import { generateAudioEvent } from "../AudioEvent"; +import { generateTag } from "../Tag"; +import { generateAudioRecording } from "../AudioRecording"; + +export function generateAnnotation( + data?: Partial +): Required { + const audioRecording = + data?.audioRecording ?? new AudioRecording(generateAudioRecording()); + + const audioEvent = generateAudioEvent({ + audioRecordingId: audioRecording.id, + startTimeSeconds: modelData.datatype.number({ + min: 0, + max: audioRecording.durationSeconds - 10, + }), + endTimeSeconds: modelData.datatype.number({ + min: audioRecording.durationSeconds - 10, + max: audioRecording.durationSeconds, + }), + }); + + const tags = modelData.randomArray(0, 10, () => new Tag(generateTag())); + + return { + ...audioEvent, + audioRecording, + tags, + ...data, + }; +} diff --git a/src/app/test/fakes/data/AnnotationSearchParameters.ts b/src/app/test/fakes/data/AnnotationSearchParameters.ts new file mode 100644 index 000000000..67953200e --- /dev/null +++ b/src/app/test/fakes/data/AnnotationSearchParameters.ts @@ -0,0 +1,42 @@ +import { Params } from "@angular/router"; +import { IAnnotationSearchParameters } from "@components/annotations/pages/annotationSearchParameters"; +import { modelData } from "@test/helpers/faker"; + +export function generateAnnotationSearchParameters( + data?: Partial +): Required { + return { + projects: modelData.ids(), + regions: modelData.ids(), + sites: modelData.ids(), + + routeProjectId: modelData.id(), + routeRegionId: modelData.id(), + routeSiteId: modelData.id(), + + audioRecordings: modelData.ids(), + tags: modelData.ids(), + onlyUnverified: modelData.bool(), + recordingTime: [modelData.time(), modelData.time()], + recordingDate: [modelData.dateTime(), modelData.dateTime()], + eventDate: [modelData.dateTime(), modelData.dateTime()], + eventTime: [modelData.time(), modelData.time()], + daylightSavings: modelData.bool(), + ...data, + }; +} + +export function generateAnnotationSearchUrlParameters( + data?: Params +): Params { + return { + projects: modelData.ids().join(","), + regions: modelData.ids().join(","), + sites: modelData.ids().join(","), + tags: modelData.ids().join(","), + onlyUnverified: modelData.bool(), + time: [modelData.time(), modelData.time()].join(","), + date: [modelData.dateTime(), modelData.dateTime()].join(","), + ...data, + }; +} diff --git a/src/app/test/helpers/changes.ts b/src/app/test/helpers/changes.ts new file mode 100644 index 000000000..57947c2ac --- /dev/null +++ b/src/app/test/helpers/changes.ts @@ -0,0 +1,57 @@ +/** + * These helpers can be used to consolidate the change detection cycle of + * Angular components and external Lit web components for use in tests + */ + +import { discardPeriodicTasks, fakeAsync, flush } from "@angular/core/testing"; +import { Spectator } from "@ngneat/spectator"; + +/** + * Detect changes in Angular components and Lit web components + * + * @param spectator The spectator instance to detect changes on + */ +export async function detectChanges( + spectator: Spectator +) { + do { + // Detect changes in Angular components + spectator.detectChanges(); + + fakeAsync(() => { + flush(); + discardPeriodicTasks(); + }); + + // wait for the lit lifecycle to complete + // + // we re-query for web component because some web components might create + // other web components during their update cycle + // this means that we have to query for the web components every update + // cycle to ensure that we wait for all of them to be stable + const webComponentSelectors = [ + "oe-verification-grid-tile", + "oe-verification-help-dialog", + "oe-verification", + "oe-media-controls", + "oe-indicator", + "oe-axes", + "oe-verification-grid", + ]; + + const webComponents: any[] = []; + for (const selector of webComponentSelectors) { + const foundElements = spectator.element.querySelectorAll(selector); + webComponents.push(...foundElements); + } + + for (const component of webComponents) { + await component.updateComplete; + } + } while ( + // keep detecting changes until the angular components are stable + // we can be sure that the lit components are stable because we perform their + // update cycle last + !spectator.fixture.isStable + ); +} diff --git a/src/app/test/helpers/html.ts b/src/app/test/helpers/html.ts index 20e689a88..2d038d683 100644 --- a/src/app/test/helpers/html.ts +++ b/src/app/test/helpers/html.ts @@ -1,4 +1,5 @@ -import { ComponentFixture } from "@angular/core/testing"; +import { ComponentFixture, flush, tick } from "@angular/core/testing"; +import { Spectator } from "@ngneat/spectator"; /** * TODO Replace with spectator method @@ -14,6 +15,60 @@ export function inputValue(wrapper: any, selector: string, value: string) { input.dispatchEvent(new Event("input")); } +/** + * Selects an item from a typeahead component + * This function must be used inside a fakeAsync block + */ +export function selectFromTypeahead( + spectator: Spectator, + target: Element | HTMLElement, + text: string, + detectChanges = true +): void { + const inputElement = target.querySelector("input"); + spectator.typeInElement(text, inputElement); + + // wait for the typeahead items to populate the dropdown with options + spectator.detectChanges(); + tick(1_000); + + // we do a document level querySelector so that if the dropdown is not in the + // spectator hosts template, we can still select it + const selectedTypeaheadOption = document.querySelector( + ".dropdown-item.active" + ); + selectedTypeaheadOption.click(); + + if (detectChanges) { + spectator.detectChanges(); + } + + flush(); +} + +/** Toggles a component decorated with ngb-dropdown and waits for it to open */ +export function toggleDropdown( + spectator: Spectator, + target: Element | HTMLElement +): void { + // bootstrap dropdowns take a full second to open + spectator.click(target); + waitForDropdown(spectator); +} + +export function waitForDropdown(spectator: Spectator): void { + spectator.tick(1_000); +} + +export function getElementByInnerText( + spectator: Spectator, + text: string +): T | null { + return spectator.debugElement.query( + (element) => element.nativeElement.innerText === text + )?.nativeElement; +} + /** * Assert form component handles pre-loading model failure * TODO Extract this to custom jasmine matcher diff --git a/src/app/test/helpers/karma.ts b/src/app/test/helpers/karma.ts new file mode 100644 index 000000000..db3e4885c --- /dev/null +++ b/src/app/test/helpers/karma.ts @@ -0,0 +1,7 @@ +export function testAsset(name: string): string { + return `/assets/test-assets/${name}`; +} + +export function nodeModule(path: string): string { + return `/base/node_modules/${path}`; +} diff --git a/src/assets/test-assets/example.flac b/src/assets/test-assets/example.flac new file mode 100644 index 000000000..8c626f67b Binary files /dev/null and b/src/assets/test-assets/example.flac differ diff --git a/src/assets/test-assets/index.html b/src/assets/test-assets/index.html new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/assets/test-assets/index.html @@ -0,0 +1 @@ + diff --git a/src/index.html b/src/index.html index 754495596..5c96e4307 100644 --- a/src/index.html +++ b/src/index.html @@ -77,7 +77,7 @@
- +