From 03fa8fe0287cbd8a73deb4c18730ec8fe4d8ea96 Mon Sep 17 00:00:00 2001 From: Hudson Newey Date: Wed, 30 Oct 2024 15:20:44 +1000 Subject: [PATCH] Add Annotation Search and Verification Pages This pull request creates an annotations search page & accompanying verification grid to the workbench client. Fixes: #2140 Features - Add open-ecoacoustics web components as a dependency - Add annotations search page - Add verification interface page - Add @angular/elements allowing us to create custom elements using Angular components (was needed for full Lit + - Angular interoperability within slotted content) - Add semantic selectors to wip component - Add disabled attributes to date-time-input.component start/end date/time inputs - Add pageSize attribute to pagination template - You can now add response headers to the SSR server using the angular.json serve.options.headers object Bug Fixes - Fix the previously non-functional UnsavedInputGuard - Fix docker image publish workflow doubling the inputted tag name on manual dispatches - Fix docker version build numbers now use an incrementing number instead of the number of commits in the tag - Fixes a bug with route titles when viewing profiles - Fixes a bug where typeahead input component pills would not be vertically centered - Fix failing CI due to deprecated dependencies Code Quality - Disable AOT during development builds and disable lib check (don't typecheck third party library code). These changes result in decreased dev build time by ~40-50% - Add new test helpers selectFromTypeahead and getElementByInnerText --- .github/workflows/publish.yml | 4 +- .github/workflows/workflow.yml | 26 +- angular.json | 29 +- karma.conf.js | 33 +- package-lock.json | 527 +++++++++++++++++- package.json | 3 + scripts/version.ps1 | 21 +- server.ts | 16 + src/app/app.component.ts | 36 +- src/app/app.module.ts | 9 +- .../components/annotations/annotation.menu.ts | 99 ++++ .../annotations/annotation.module.ts | 35 ++ .../annotations/annotation.routes.ts | 46 ++ .../annotation-search-form.component.html | 115 ++++ .../annotation-search-form.component.scss | 11 + .../annotation-search-form.component.spec.ts | 269 +++++++++ .../annotation-search-form.component.ts | 176 ++++++ .../filters-warning.component.ts | 53 ++ .../progress-warning.component.ts | 61 ++ .../search-filters.component.spec.ts | 55 ++ .../search-filters.component.ts | 90 +++ .../pages/annotationSearchParameters.spec.ts | 8 + .../pages/annotationSearchParameters.ts | 267 +++++++++ .../pages/search/search.component.html | 73 +++ .../pages/search/search.component.scss | 13 + .../pages/search/search.component.spec.ts | 210 +++++++ .../pages/search/search.component.ts | 216 +++++++ .../verification/verification.component.html | 70 +++ .../verification/verification.component.scss | 21 + .../verification.component.spec.ts | 368 ++++++++++++ .../verification/verification.component.ts | 269 +++++++++ src/app/components/profile/profile.menus.ts | 6 +- .../pages/details/details.component.ts | 2 + .../pages/details/details.component.ts | 2 + .../EventSummaryReportParameters.ts | 25 +- .../annotation-event-card.component.html | 47 ++ .../annotation-event-card.component.scss | 15 + .../annotation-event-card.component.spec.ts | 128 +++++ .../annotation-event-card.component.ts | 26 + .../annotation-event-card.module.ts | 20 + .../shared/can/can.component.spec.ts | 106 ++++ .../components/shared/can/can.component.ts | 94 ++++ .../date-time-filter.component.html | 24 +- .../date-time-filter.component.spec.ts | 36 ++ .../date-time-filter.component.ts | 9 +- .../shared/menu/widgets/widget.component.ts | 2 +- .../components/shared/shared.components.ts | 4 + .../typeahead-input/typeahead-callbacks.ts | 40 ++ .../typeahead-input.component.scss | 1 + .../typeahead-input.component.ts | 11 +- .../components/shared/wip/wip.component.scss | 8 +- .../components/shared/wip/wip.component.ts | 8 +- .../sites/pages/details/details.component.ts | 3 + .../grid-tile-content.component.html | 52 ++ .../grid-tile-content.component.scss | 66 +++ .../grid-tile-content.component.spec.ts | 170 ++++++ .../grid-tile-content.component.ts | 90 +++ .../web-components/web-components.module.ts | 16 + src/app/guards/form/form.guard.ts | 4 +- src/app/guards/input/input.guard.ts | 5 +- .../app-initializer/app-initializer.ts | 15 +- src/app/helpers/context/context-decorators.ts | 49 ++ src/app/helpers/context/context.ts | 52 ++ src/app/helpers/filters/audioEventFilters.ts | 39 ++ src/app/helpers/page/pageRouting.ts | 3 +- .../paginationTemplate/paginationTemplate.ts | 26 +- .../query-string-parameters.ts | 7 +- src/app/models/AudioRecording.ts | 19 +- src/app/models/data/Annotation.ts | 77 +++ src/app/models/data/parametersModel.ts | 21 + src/app/services/baw-api/ServiceProviders.ts | 11 + src/app/services/baw-api/ServiceTokens.ts | 5 + .../audio-recordings.service.ts | 9 + .../baw-api/baw-session.service.spec.ts | 57 ++ .../services/baw-api/baw-session.service.ts | 11 + .../event-summary-report.service.ts | 26 +- src/app/services/config/config.module.ts | 3 + .../services/import/import.service.spec.ts | 18 + src/app/services/import/import.service.ts | 34 ++ src/app/services/media/media.service.spec.ts | 241 ++++++++ src/app/services/media/media.service.ts | 174 ++++++ .../models/annotation.service.spec.ts | 86 +++ src/app/services/models/annotation.service.ts | 130 +++++ src/app/styles/_web-components.scss | 8 + src/app/test/fakes/data/Annotation.ts | 35 ++ .../fakes/data/AnnotationSearchParameters.ts | 42 ++ src/app/test/helpers/changes.ts | 57 ++ src/app/test/helpers/html.ts | 57 +- src/app/test/helpers/karma.ts | 7 + src/assets/test-assets/example.flac | Bin 0 -> 156681 bytes src/assets/test-assets/index.html | 1 + src/index.html | 2 +- src/patches/tests/testPatches.ts | 13 + src/styles.scss | 1 + tsconfig.json | 1 + tsconfig.spec.json | 3 +- webpack.config.js | 28 + 97 files changed, 5507 insertions(+), 110 deletions(-) create mode 100644 src/app/components/annotations/annotation.menu.ts create mode 100644 src/app/components/annotations/annotation.module.ts create mode 100644 src/app/components/annotations/annotation.routes.ts create mode 100644 src/app/components/annotations/components/annotation-search-form/annotation-search-form.component.html create mode 100644 src/app/components/annotations/components/annotation-search-form/annotation-search-form.component.scss create mode 100644 src/app/components/annotations/components/annotation-search-form/annotation-search-form.component.spec.ts create mode 100644 src/app/components/annotations/components/annotation-search-form/annotation-search-form.component.ts create mode 100644 src/app/components/annotations/components/modals/filters-warning/filters-warning.component.ts create mode 100644 src/app/components/annotations/components/modals/progress-warning/progress-warning.component.ts create mode 100644 src/app/components/annotations/components/modals/search-filters/search-filters.component.spec.ts create mode 100644 src/app/components/annotations/components/modals/search-filters/search-filters.component.ts create mode 100644 src/app/components/annotations/pages/annotationSearchParameters.spec.ts create mode 100644 src/app/components/annotations/pages/annotationSearchParameters.ts create mode 100644 src/app/components/annotations/pages/search/search.component.html create mode 100644 src/app/components/annotations/pages/search/search.component.scss create mode 100644 src/app/components/annotations/pages/search/search.component.spec.ts create mode 100644 src/app/components/annotations/pages/search/search.component.ts create mode 100644 src/app/components/annotations/pages/verification/verification.component.html create mode 100644 src/app/components/annotations/pages/verification/verification.component.scss create mode 100644 src/app/components/annotations/pages/verification/verification.component.spec.ts create mode 100644 src/app/components/annotations/pages/verification/verification.component.ts create mode 100644 src/app/components/shared/audio-event-card/annotation-event-card.component.html create mode 100644 src/app/components/shared/audio-event-card/annotation-event-card.component.scss create mode 100644 src/app/components/shared/audio-event-card/annotation-event-card.component.spec.ts create mode 100644 src/app/components/shared/audio-event-card/annotation-event-card.component.ts create mode 100644 src/app/components/shared/audio-event-card/annotation-event-card.module.ts create mode 100644 src/app/components/shared/can/can.component.spec.ts create mode 100644 src/app/components/shared/can/can.component.ts create mode 100644 src/app/components/shared/typeahead-input/typeahead-callbacks.ts create mode 100644 src/app/components/web-components/grid-tile-content/grid-tile-content.component.html create mode 100644 src/app/components/web-components/grid-tile-content/grid-tile-content.component.scss create mode 100644 src/app/components/web-components/grid-tile-content/grid-tile-content.component.spec.ts create mode 100644 src/app/components/web-components/grid-tile-content/grid-tile-content.component.ts create mode 100644 src/app/components/web-components/web-components.module.ts create mode 100644 src/app/helpers/context/context-decorators.ts create mode 100644 src/app/helpers/context/context.ts create mode 100644 src/app/helpers/filters/audioEventFilters.ts create mode 100644 src/app/models/data/Annotation.ts create mode 100644 src/app/models/data/parametersModel.ts create mode 100644 src/app/services/import/import.service.spec.ts create mode 100644 src/app/services/import/import.service.ts create mode 100644 src/app/services/media/media.service.spec.ts create mode 100644 src/app/services/media/media.service.ts create mode 100644 src/app/services/models/annotation.service.spec.ts create mode 100644 src/app/services/models/annotation.service.ts create mode 100644 src/app/styles/_web-components.scss create mode 100644 src/app/test/fakes/data/Annotation.ts create mode 100644 src/app/test/fakes/data/AnnotationSearchParameters.ts create mode 100644 src/app/test/helpers/changes.ts create mode 100644 src/app/test/helpers/karma.ts create mode 100644 src/assets/test-assets/example.flac create mode 100644 src/assets/test-assets/index.html create mode 100644 src/patches/tests/testPatches.ts create mode 100644 webpack.config.js 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 0000000000000000000000000000000000000000..8c626f67bcfb9a595c65ef0ab89b8768691f0193 GIT binary patch literal 156681 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⩔UjP)liOU-GbwQ1u%aTGm~w3N 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 literal 0 HcmV?d00001 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 @@
- +