diff --git a/.config/config.json b/.config/config.json index 035648c7a3..8f00ec01e9 100644 --- a/.config/config.json +++ b/.config/config.json @@ -5,6 +5,7 @@ "tools": "tools", "dev": "dev", "test": "test", + "test-spi": "test", "prod": "prod" }, "version": "1.0.0", @@ -20,17 +21,20 @@ "staticUrls": { "dev": "dev-biohubbc.apps.silver.devops.gov.bc.ca", "test": "test-biohubbc.apps.silver.devops.gov.bc.ca", + "test-spi": "test-spi-biohubbc.apps.silver.devops.gov.bc.ca", "prod": "biohubbc.apps.silver.devops.gov.bc.ca", "prodVanityUrl": "sims.nrs.gov.bc.ca" }, "staticUrlsAPI": { "dev": "api-dev-biohubbc.apps.silver.devops.gov.bc.ca", "test": "api-test-biohubbc.apps.silver.devops.gov.bc.ca", + "test-spi": "api-test-spi-biohubbc.apps.silver.devops.gov.bc.ca", "prod": "api-biohubbc.apps.silver.devops.gov.bc.ca" }, "siteminderLogoutURL": { "dev": "https://logontest7.gov.bc.ca/clp-cgi/logoff.cgi", "test": "https://logontest7.gov.bc.ca/clp-cgi/logoff.cgi", + "test-spi": "https://logontest7.gov.bc.ca/clp-cgi/logoff.cgi", "prod": "https://logon7.gov.bc.ca/clp-cgi/logoff.cgi" }, "sso": { @@ -68,6 +72,23 @@ "cssApiEnvironment": "test" } }, + "test-spi": { + "host": "https://test.loginproxy.gov.bc.ca/auth", + "realm": "standard", + "clientId": "sims-4461", + "keycloakSecret": "keycloak", + "serviceClient": { + "serviceClientName": "sims-svc-4464", + "keycloakSecretServiceClientPasswordKey": "sims_svc_client_password" + }, + "cssApi": { + "cssApiTokenUrl": "https://loginproxy.gov.bc.ca/auth/realms/standard/protocol/openid-connect/token", + "cssApiClientId": "service-account-team-1190-4229", + "cssApiHost": "https://api.loginproxy.gov.bc.ca/api/v1", + "keycloakSecretCssApiSecretKey": "css_api_client_secret", + "cssApiEnvironment": "test" + } + }, "prod": { "host": "https://loginproxy.gov.bc.ca/auth", "realm": "standard", diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8637ad769a..feed15693d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,6 +7,7 @@ on: types: [opened, reopened, synchronize, ready_for_review] branches-ignore: - test + - test-spi - prod concurrency: diff --git a/.github/workflows/deployStatic.yml b/.github/workflows/deployStatic.yml index d7db1e5f35..c827de0242 100644 --- a/.github/workflows/deployStatic.yml +++ b/.github/workflows/deployStatic.yml @@ -8,6 +8,7 @@ on: branches: - dev - test + - test-spi - prod jobs: diff --git a/Makefile b/Makefile index 31f78ed505..8c18d71028 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ -include .env -# Apply the contents of the .env to the terminal, so that the docker-compose file can use them in its builds +# Apply the contents of the .env to the terminal, so that the compose file can use them in its builds export $(shell sed 's/=.*//' .env) ## ------------------------------------------------------------------------------ @@ -46,13 +46,13 @@ close: ## Closes all project containers @echo "===============================================" @echo "Make: close - closing Docker containers" @echo "===============================================" - @docker-compose -f docker-compose.yml down + @docker compose down clean: ## Closes and cleans (removes) all project containers @echo "===============================================" @echo "Make: clean - closing and cleaning Docker containers" @echo "===============================================" - @docker-compose -f docker-compose.yml down -v --rmi all --remove-orphans + @docker compose down -v --rmi all --remove-orphans prune: ## Deletes ALL docker artifacts (even those not associated to this project) @echo -n "Delete ALL docker artifacts? [y/n] " && read ans && [ $${ans:-n} = y ] @@ -77,13 +77,13 @@ build-postgres: ## Builds the postgres db containers @echo "===============================================" @echo "Make: build-postgres - building postgres db images" @echo "===============================================" - @docker-compose -f docker-compose.yml build db db_setup + @docker compose build db db_setup run-postgres: ## Runs the postgres db containers @echo "===============================================" @echo "Make: run-postgres - running postgres db images" @echo "===============================================" - @docker-compose -f docker-compose.yml up -d db db_setup + @docker compose up -d db db_setup ## ------------------------------------------------------------------------------ ## Build/Run Backend Commands @@ -94,13 +94,13 @@ build-backend: ## Builds all backend containers @echo "===============================================" @echo "Make: build-backend - building backend images" @echo "===============================================" - @docker-compose -f docker-compose.yml build db db_setup api + @docker compose build db db_setup api run-backend: ## Runs all backend containers @echo "===============================================" @echo "Make: run-backend - running backend images" @echo "===============================================" - @docker-compose -f docker-compose.yml up -d db db_setup api + @docker compose up -d db db_setup api ## ------------------------------------------------------------------------------ ## Build/Run Backend+Web Commands (backend + web frontend) @@ -111,13 +111,13 @@ build-web: ## Builds all backend+web containers @echo "===============================================" @echo "Make: build-web - building web images" @echo "===============================================" - @docker-compose -f docker-compose.yml build db db_setup api app + @docker compose build db db_setup api app run-web: ## Runs all backend+web containers @echo "===============================================" @echo "Make: run-web - running web images" @echo "===============================================" - @docker-compose -f docker-compose.yml up -d db db_setup api app + @docker compose up -d db db_setup api app ## ------------------------------------------------------------------------------ ## Commands to shell into the target container @@ -128,19 +128,19 @@ db-container: ## Executes into database container. @echo "Make: Shelling into database container" @echo "===============================================" @export PGPASSWORD=$(DB_ADMIN_PASS) - @docker-compose exec db psql -U $(DB_ADMIN) -d $(DB_DATABASE) + @docker compose exec db psql -U $(DB_ADMIN) -d $(DB_DATABASE) app-container: ## Executes into the app container. @echo "===============================================" @echo "Shelling into app container" @echo "===============================================" - @docker-compose exec app bash + @docker compose exec app bash api-container: ## Executes into the api container. @echo "===============================================" @echo "Shelling into api container" @echo "===============================================" - @docker-compose exec api bash + @docker compose exec api bash ## ------------------------------------------------------------------------------ ## Database migration commands @@ -150,37 +150,37 @@ build-db-setup: ## Build the db knex setup (migrations + seeding) image @echo "===============================================" @echo "Make: build-db-setup - building db knex setup image" @echo "===============================================" - @docker-compose -f docker-compose.yml build db_setup + @docker compose build db_setup run-db-setup: ## Run the database migrations and seeding @echo "===============================================" @echo "Make: run-db-setup - running database migrations and seeding" @echo "===============================================" - @docker-compose -f docker-compose.yml up db_setup + @docker compose up db_setup build-db-migrate: ## Build the db knex migrations image @echo "===============================================" @echo "Make: build-db-migrate - building db knex migrate image" @echo "===============================================" - @docker-compose -f docker-compose.yml build db_migrate + @docker compose build db_migrate run-db-migrate: ## Run the database migrations @echo "===============================================" @echo "Make: run-db-migrate - running database migrations" @echo "===============================================" - @docker-compose -f docker-compose.yml up db_migrate + @docker compose up db_migrate build-db-rollback: ## Build the db knex rollback image @echo "===============================================" @echo "Make: build-db-rollback - building db knex rollback image" @echo "===============================================" - @docker-compose -f docker-compose.yml build db_rollback + @docker compose build db_rollback run-db-rollback: ## Rollback the latest database migrations @echo "===============================================" @echo "Make: run-db-rollback - rolling back the latest database migrations" @echo "===============================================" - @docker-compose -f docker-compose.yml up db_rollback + @docker compose up db_rollback ## ------------------------------------------------------------------------------ ## clamav commands @@ -190,13 +190,13 @@ build-clamav: ## Build the clamav image @echo "===============================================" @echo "Make: build-clamav - building clamav image" @echo "===============================================" - @docker-compose -f docker-compose.yml build clamav + @docker compose build clamav run-clamav: ## Run clamav @echo "===============================================" @echo "Make: run-clamav - running clamav" @echo "===============================================" - @docker-compose -f docker-compose.yml up -d clamav + @docker compose up -d clamav ## ------------------------------------------------------------------------------ ## Run `npm` commands for all projects @@ -314,11 +314,11 @@ pipeline-install: ## Runs `npm install` for all projects args ?= --tail 2000 ## Default args if none are provided -log: ## Runs `docker-compose logs -f` for all containers +log: ## Runs `docker compose logs -f` for all containers @echo "===============================================" @echo "Running docker logs for the app container" @echo "===============================================" - @docker-compose logs -f $(args) + @docker compose logs -f $(args) log-app: ## Runs `docker logs -f` for the app container @echo "===============================================" diff --git a/README.md b/README.md index 020a947546..b23d85a04e 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Below are all of the relevant files that need to be updated when modifying envir #### Local Development - `env.docker` -- `docker-compose.yml` +- `compose.yml` - `app/src/contexts/configContext.tsx` #### Deployed to OpenShift diff --git a/api/.docker/api/Dockerfile b/api/.docker/api/Dockerfile index 1501d7cfdc..a5052b22ce 100644 --- a/api/.docker/api/Dockerfile +++ b/api/.docker/api/Dockerfile @@ -1,5 +1,5 @@ # ######################################################################################################## -# This DockerFile is used for local development (via docker-compose) only. +# This DockerFile is used for local development (via compose.yml) only. # ######################################################################################################## FROM node:20 @@ -21,6 +21,9 @@ ENV PATH ${HOME}/node_modules/.bin/:/usr/local/sbin:/usr/local/bin:/usr/sbin:/us # Copy the rest of the files COPY . ./ +# Update log directory file permissions, prevents permission errors for linux environments +RUN chmod -R a+rw data/logs/* + VOLUME ${HOME} # start api with live reload diff --git a/api/.gitignore b/api/.gitignore index 8461edfea1..a64b16b540 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -12,6 +12,9 @@ dist # Testing coverage +# persistent storage +data + # SonarQube .sonarqube diff --git a/api/.pipeline/config.js b/api/.pipeline/config.js index f52e3223a9..7d6556d3f1 100644 --- a/api/.pipeline/config.js +++ b/api/.pipeline/config.js @@ -52,7 +52,6 @@ const phases = { build: { namespace: 'af2668-tools', name: `${name}`, - dbName: `${dbName}`, phase: 'build', changeId: changeId, suffix: `-build-${changeId}`, @@ -91,7 +90,14 @@ const phases = { tz: config.timezone.api, sso: config.sso.dev, featureFlags: '', - logLevel: 'info', + logLevel: (isStaticDeployment && 'info') || 'debug', + logLevelFile: (isStaticDeployment && 'debug') || 'debug', + logFileDir: 'data/logs', + logFileName: 'sims-api-%DATE%.log', + logFileDatePattern: 'YYYY-MM-DD-HH', + logFileMaxSize: '50m', + logFileMaxFiles: (isStaticDeployment && '10') || '2', + volumeCapacity: (isStaticDeployment && '500Mi') || '100Mi', apiResponseValidationEnabled: true, databaseResponseValidationEnabled: true, nodeOptions: '--max_old_space_size=3000', // 75% of memoryLimit (bytes) @@ -126,8 +132,58 @@ const phases = { s3KeyPrefix: 'sims', tz: config.timezone.api, sso: config.sso.test, - logLevel: 'info', featureFlags: '', + logLevel: 'warn', + logLevelFile: 'debug', + logFileDir: 'data/logs', + logFileName: 'sims-api-%DATE%.log', + logFileDatePattern: 'YYYY-MM-DD-HH', + logFileMaxSize: '50m', + logFileMaxFiles: '10', + volumeCapacity: '500Mi', + apiResponseValidationEnabled: true, + databaseResponseValidationEnabled: true, + nodeOptions: '--max_old_space_size=3000', // 75% of memoryLimit (bytes) + cpuRequest: '50m', + cpuLimit: '1000m', + memoryRequest: '100Mi', + memoryLimit: '4Gi', + replicas: '2', + replicasMax: '2' + }, + 'test-spi': { + namespace: 'af2668-test', + name: `${name}-spi`, + dbName: `${dbName}-spi`, + phase: 'test-spi', + changeId: deployChangeId, + suffix: `-test-spi`, + instance: `${name}-spi-test-spi`, + version: `${version}`, + tag: `test-spi-${version}`, + host: staticUrlsAPI['test-spi'], + appHost: staticUrls['test-spi'], + backboneInternalApiHost: 'https://api-test-biohub-platform.apps.silver.devops.gov.bc.ca', + backbonePublicApiHost: 'https://api-test-biohub-platform.apps.silver.devops.gov.bc.ca', + backboneIntakePath: '/api/submission/intake', + backboneArtifactIntakePath: '/api/artifact/intake', + biohubTaxonPath: '/api/taxonomy/taxon', + biohubTaxonTsnPath: '/api/taxonomy/taxon/tsn', + bctwApiHost: 'https://moe-bctw-api-test.apps.silver.devops.gov.bc.ca', + critterbaseApiHost: 'https://moe-critterbase-api-test.apps.silver.devops.gov.bc.ca/api', + nodeEnv: 'production', + s3KeyPrefix: 'sims', + tz: config.timezone.api, + sso: config.sso['test-spi'], + featureFlags: '', + logLevel: 'warn', + logLevelFile: 'debug', + logFileDir: 'data/logs', + logFileName: 'sims-api-%DATE%.log', + logFileDatePattern: 'YYYY-MM-DD-HH', + logFileMaxSize: '50m', + logFileMaxFiles: '10', + volumeCapacity: '500Mi', apiResponseValidationEnabled: true, databaseResponseValidationEnabled: true, nodeOptions: '--max_old_space_size=3000', // 75% of memoryLimit (bytes) @@ -163,7 +219,14 @@ const phases = { tz: config.timezone.api, sso: config.sso.prod, featureFlags: 'API_FF_SUBMIT_BIOHUB', - logLevel: 'warn', + logLevel: 'silent', + logLevelFile: 'debug', + logFileDir: 'data/logs', + logFileName: 'sims-api-%DATE%.log', + logFileDatePattern: 'YYYY-MM-DD-HH', + logFileMaxSize: '50m', + logFileMaxFiles: '10', + volumeCapacity: '500Mi', apiResponseValidationEnabled: false, databaseResponseValidationEnabled: false, nodeOptions: '--max_old_space_size=6000', // 75% of memoryLimit (bytes) diff --git a/api/.pipeline/lib/api.deploy.js b/api/.pipeline/lib/api.deploy.js index 220710a02f..08a1266580 100644 --- a/api/.pipeline/lib/api.deploy.js +++ b/api/.pipeline/lib/api.deploy.js @@ -34,6 +34,8 @@ const apiDeploy = async (settings) => { // Node NODE_ENV: phases[phase].nodeEnv, NODE_OPTIONS: phases[phase].nodeOptions, + // Persistent Volume + VOLUME_CAPACITY: phases[phase].volumeCapacity, // BioHub Platform (aka: Backbone) BACKBONE_INTERNAL_API_HOST: phases[phase].backboneInternalApiHost, BACKBONE_INTAKE_PATH: phases[phase].backboneIntakePath, @@ -65,6 +67,13 @@ const apiDeploy = async (settings) => { KEYCLOAK_API_ENVIRONMENT: phases[phase].sso.cssApi.cssApiEnvironment, // Log Level LOG_LEVEL: phases[phase].logLevel, + LOG_LEVEL_FILE: phases[phase].logLevelFile, + LOG_FILE_DIR: phases[phase].logFileDir, + LOG_FILE_NAME: phases[phase].logFileName, + LOG_FILE_DATE_PATTERN: phases[phase].logFileDatePattern, + LOG_FILE_MAX_SIZE: phases[phase].logFileMaxSize, + LOG_FILE_MAX_FILES: phases[phase].logFileMaxFiles, + // Api Validation API_RESPONSE_VALIDATION_ENABLED: phases[phase].apiResponseValidationEnabled, DATABASE_RESPONSE_VALIDATION_ENABLED: phases[phase].databaseResponseValidationEnabled, // Feature Flags diff --git a/api/.pipeline/templates/api.dc.yaml b/api/.pipeline/templates/api.dc.yaml index dc2518910a..deda7421d7 100644 --- a/api/.pipeline/templates/api.dc.yaml +++ b/api/.pipeline/templates/api.dc.yaml @@ -32,6 +32,12 @@ parameters: - name: API_PORT_DEFAULT_NAME description: Api default port name value: '6100-tcp' + # Volume (for API logs and other persistent data) + - description: Volume space available for data, e.g. 512Mi, 2Gi. + displayName: Volume Capacity + name: VOLUME_CAPACITY + required: true + value: '500Mi' # Clamav - name: ENABLE_FILE_VIRUS_SCAN value: 'true' @@ -115,9 +121,29 @@ parameters: description: S3 key optional prefix required: false value: 'sims' - # Logging and Validation + # Logging - name: LOG_LEVEL - value: 'warn' + value: 'silent' + description: Log level for logs written to the console (console transport) + - name: LOG_LEVEL_FILE + value: 'debug' + description: Log level for logs written to log files (file transport) + - name: LOG_FILE_DIR + value: data/logs + description: Directory where log files are stored + - name: LOG_FILE_NAME + value: sims-api-%DATE%.log + description: Name of the log file + - name: LOG_FILE_DATE_PATTERN + value: YYYY-MM-DD-HH + description: Date pattern for log the files + - name: LOG_FILE_MAX_SIZE + value: 50m + description: Maximum size an individual log file can reach before a new file is created + - name: LOG_FILE_MAX_FILES + value: '10' + description: Either the maximum number of log files to keep (10) or the maximum number of days to keep log files (10d) + # Api Validation - name: API_RESPONSE_VALIDATION_ENABLED value: 'false' - name: DATABASE_RESPONSE_VALIDATION_ENABLED @@ -160,8 +186,8 @@ parameters: - name: REPLICAS_MAX value: '1' objects: - - apiVersion: image.openshift.io/v1 - kind: ImageStream + - kind: ImageStream + apiVersion: image.openshift.io/v1 metadata: annotations: description: Nodejs Runtime Image @@ -175,6 +201,17 @@ objects: status: dockerImageRepository: null + - kind: PersistentVolumeClaim + apiVersion: v1 + metadata: + name: ${NAME}${SUFFIX} + spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: '${VOLUME_CAPACITY}' + - kind: DeploymentConfig apiVersion: apps.openshift.io/v1 metadata: @@ -320,9 +357,22 @@ objects: secretKeyRef: key: object_store_bucket_name name: ${OBJECT_STORE_SECRETS} - # Logging and Validation + # Logging - name: LOG_LEVEL value: ${LOG_LEVEL} + - name: LOG_LEVEL_FILE + value: ${LOG_LEVEL_FILE} + - name: LOG_FILE_DIR + value: ${LOG_FILE_DIR} + - name: LOG_FILE_NAME + value: ${LOG_FILE_NAME} + - name: LOG_FILE_DATE_PATTERN + value: ${LOG_FILE_DATE_PATTERN} + - name: LOG_FILE_MAX_SIZE + value: ${LOG_FILE_MAX_SIZE} + - name: LOG_FILE_MAX_FILES + value: ${LOG_FILE_MAX_FILES} + # Api Validation - name: API_RESPONSE_VALIDATION_ENABLED value: ${API_RESPONSE_VALIDATION_ENABLED} - name: DATABASE_RESPONSE_VALIDATION_ENABLED @@ -380,16 +430,17 @@ objects: terminationMessagePath: /dev/termination-log terminationMessagePolicy: File volumeMounts: - - mountPath: /opt/app-root/app - name: ${NAME}${SUFFIX} + - name: ${NAME}${SUFFIX} + mountPath: /opt/app-root/src/data dnsPolicy: ClusterFirst restartPolicy: Always schedulerName: default-scheduler securityContext: {} terminationGracePeriodSeconds: 30 volumes: - - emptyDir: {} - name: ${NAME}${SUFFIX} + - name: ${NAME}${SUFFIX} + persistentVolumeClaim: + claimName: ${NAME}${SUFFIX} test: false triggers: - imageChangeParams: @@ -421,8 +472,8 @@ objects: name: ${NAME}${SUFFIX} type: Opaque - - apiVersion: v1 - kind: Service + - kind: Service + apiVersion: v1 metadata: annotations: null labels: {} diff --git a/api/README.md b/api/README.md index 2724f9c2de..da2287b939 100644 --- a/api/README.md +++ b/api/README.md @@ -84,12 +84,10 @@ A centralized logger has been created (see `api/utils/logger.ts`). ## Logger configuration -The loggers log level can be configured via an environment variable: `LOG_LEVEL` +The loggers log level can be configured via environment variables: `LOG_LEVEL` and `LOG_LEVEL_FILE` Set this variable to one of: `silent`, `error`, `warn`, `info`, `debug`, `silly` -Default value: `info` - ## Instantiating the logger in your class/file ``` diff --git a/api/package-lock.json b/api/package-lock.json index bd71e90473..6ac5daca69 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -15,7 +15,7 @@ "@turf/circle": "^6.5.0", "@turf/helpers": "^6.5.0", "@turf/meta": "^6.5.0", - "adm-zip": "^0.5.5", + "adm-zip": "0.5.12", "ajv": "^8.12.0", "axios": "^1.6.7", "clamscan": "^2.2.1", @@ -30,7 +30,7 @@ "form-data": "^4.0.0", "http-proxy-middleware": "^2.0.6", "jsonpath-plus": "^7.2.0", - "jsonwebtoken": "^8.5.1", + "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.1.0", "knex": "^2.4.2", "lodash": "^4.17.21", @@ -45,9 +45,9 @@ "utm": "^1.1.1", "uuid": "^8.3.2", "winston": "^3.3.3", + "winston-daily-rotate-file": "^5.0.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz", - "xml2js": "^0.4.23", - "zod": "^3.22.4" + "zod": "^3.23.8" }, "devDependencies": { "@istanbuljs/nyc-config-typescript": "^1.0.1", @@ -68,7 +68,6 @@ "@types/swagger-ui-express": "^4.1.6", "@types/utm": "^1.1.1", "@types/uuid": "^8.3.1", - "@types/xml2js": "^0.4.9", "@types/yamljs": "^0.2.31", "@typescript-eslint/eslint-plugin": "~7.6.0", "@typescript-eslint/parser": "~7.6.0", @@ -2805,15 +2804,6 @@ "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", "dev": true }, - "node_modules/@types/xml2js": { - "version": "0.4.14", - "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz", - "integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/yamljs": { "version": "0.2.34", "resolved": "https://registry.npmjs.org/@types/yamljs/-/yamljs-0.2.34.tgz", @@ -4820,6 +4810,14 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-stream-rotator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", + "integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==", + "dependencies": { + "moment": "^2.29.1" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -6112,9 +6110,9 @@ } }, "node_modules/jsonwebtoken": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", - "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", @@ -6125,19 +6123,11 @@ "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", - "semver": "^5.6.0" + "semver": "^7.5.4" }, "engines": { - "node": ">=4", - "npm": ">=1.4.28" - } - }, - "node_modules/jsonwebtoken/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "bin": { - "semver": "bin/semver" + "node": ">=12", + "npm": ">=6" } }, "node_modules/just-extend": { @@ -6732,6 +6722,14 @@ "node": ">=10" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "engines": { + "node": "*" + } + }, "node_modules/mongodb-uri": { "version": "0.9.7", "resolved": "https://registry.npmjs.org/mongodb-uri/-/mongodb-uri-0.9.7.tgz", @@ -7299,6 +7297,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -8504,11 +8510,6 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, - "node_modules/sax": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", - "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==" - }, "node_modules/semver": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", @@ -9719,6 +9720,23 @@ "node": ">= 12.0.0" } }, + "node_modules/winston-daily-rotate-file": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz", + "integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==", + "dependencies": { + "file-stream-rotator": "^0.6.1", + "object-hash": "^3.0.0", + "triple-beam": "^1.4.1", + "winston-transport": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "winston": "^3" + } + }, "node_modules/winston-transport": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.7.0.tgz", @@ -9823,26 +9841,6 @@ "resolved": "https://cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz", "integrity": "sha512-8IfgFctB7fkvqkTGF2MnrDrC6vzE28Wcc1aSbdDQ+4/WFtzfS73YuapbuaPZwGqpR2e0EeDMIrFOJubQVLWFNA==" }, - "node_modules/xml2js": { - "version": "0.4.23", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", - "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "engines": { - "node": ">=4.0" - } - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -10021,9 +10019,9 @@ } }, "node_modules/zod": { - "version": "3.22.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", - "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/api/package.json b/api/package.json index fba3cdd63d..fe2fd2dcb2 100644 --- a/api/package.json +++ b/api/package.json @@ -32,7 +32,7 @@ "@turf/circle": "^6.5.0", "@turf/helpers": "^6.5.0", "@turf/meta": "^6.5.0", - "adm-zip": "^0.5.5", + "adm-zip": "0.5.12", "ajv": "^8.12.0", "axios": "^1.6.7", "clamscan": "^2.2.1", @@ -47,7 +47,7 @@ "form-data": "^4.0.0", "http-proxy-middleware": "^2.0.6", "jsonpath-plus": "^7.2.0", - "jsonwebtoken": "^8.5.1", + "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.1.0", "knex": "^2.4.2", "lodash": "^4.17.21", @@ -62,9 +62,9 @@ "utm": "^1.1.1", "uuid": "^8.3.2", "winston": "^3.3.3", + "winston-daily-rotate-file": "^5.0.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz", - "xml2js": "^0.4.23", - "zod": "^3.22.4" + "zod": "^3.23.8" }, "devDependencies": { "@istanbuljs/nyc-config-typescript": "^1.0.1", @@ -85,7 +85,6 @@ "@types/swagger-ui-express": "^4.1.6", "@types/utm": "^1.1.1", "@types/uuid": "^8.3.1", - "@types/xml2js": "^0.4.9", "@types/yamljs": "^0.2.31", "@typescript-eslint/eslint-plugin": "~7.6.0", "@typescript-eslint/parser": "~7.6.0", diff --git a/api/src/app.ts b/api/src/app.ts index 0c22d7e5a7..00eb5b57b9 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -98,7 +98,7 @@ const openAPIFramework = initialize({ const multerFiles = req.files ?? []; req.files = multerFiles; - req.body.media = multerFiles; + req.body = { ...req.body, media: multerFiles }; return next(); }); diff --git a/api/src/constants/attachments.ts b/api/src/constants/attachments.ts index 061b01f49e..0dd2f7f738 100644 --- a/api/src/constants/attachments.ts +++ b/api/src/constants/attachments.ts @@ -1,5 +1,27 @@ +/** + * The type of project/survey attachment files. + * + * @export + * @enum {number} + */ export enum ATTACHMENT_TYPE { REPORT = 'Report', - KEYX = 'KeyX', OTHER = 'Other' } + +/** + * The type of survey telemetry credential attachment files. + * + * @export + * @enum {number} + */ +export enum TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE { + /** + * Lotek API key file type. + */ + KEYX = 'KeyX', + /** + * Vectronic API key file type. + */ + CFG = 'Cfg' +} diff --git a/api/src/models/bctw.ts b/api/src/models/bctw.ts new file mode 100644 index 0000000000..2c7931f4cf --- /dev/null +++ b/api/src/models/bctw.ts @@ -0,0 +1,99 @@ +import { z } from 'zod'; + +export const BctwDeployDevice = z.object({ + device_id: z.number(), + frequency: z.number().optional(), + frequency_unit: z.string().optional(), + device_make: z.string().optional(), + device_model: z.string().optional(), + attachment_start: z.string(), + attachment_end: z.string().nullable(), + critter_id: z.string(), + critterbase_start_capture_id: z.string().uuid(), + critterbase_end_capture_id: z.string().uuid().nullable(), + critterbase_end_mortality_id: z.string().uuid().nullable() +}); + +export type BctwDeployDevice = z.infer; + +export type BctwDevice = Omit & { + collar_id: string; +}; + +export const BctwDeploymentUpdate = z.object({ + deployment_id: z.string(), + attachment_start: z.string(), + attachment_end: z.string() +}); + +export type BctwDeploymentUpdate = z.infer; + +export const BctwUploadKeyxResponse = z.object({ + errors: z.array( + z.object({ + row: z.string(), + error: z.string(), + rownum: z.number() + }) + ), + results: z.array( + z.object({ + idcollar: z.number(), + comtype: z.string(), + idcom: z.string(), + collarkey: z.string(), + collartype: z.number(), + dtlast_fetch: z.string().nullable() + }) + ) +}); + +export type BctwUploadKeyxResponse = z.infer; + +export const BctwKeyXDetails = z.object({ + device_id: z.number(), + keyx: z + .object({ + idcom: z.string(), + comtype: z.string(), + idcollar: z.number(), + collarkey: z.string(), + collartype: z.number() + }) + .nullable() +}); + +export type BctwKeyXDetails = z.infer; + +export const IManualTelemetry = z.object({ + telemetry_manual_id: z.string().uuid(), + deployment_id: z.string().uuid(), + latitude: z.number(), + longitude: z.number(), + date: z.string() +}); + +export type IManualTelemetry = z.infer; + +export const BctwUser = z.object({ + keycloak_guid: z.string(), + username: z.string() +}); + +export interface ICodeResponse { + code_header_title: string; + code_header_name: string; + id: number; + code: string; + description: string; + long_description: string; +} + +export type BctwUser = z.infer; + +export interface ICreateManualTelemetry { + deployment_id: string; + latitude: number; + longitude: number; + acquisition_date: string; +} diff --git a/api/src/models/biohub-create.test.ts b/api/src/models/biohub-create.test.ts index 3a62f3734b..9f4ac679ee 100644 --- a/api/src/models/biohub-create.test.ts +++ b/api/src/models/biohub-create.test.ts @@ -191,8 +191,7 @@ describe('PostSurveySubmissionToBioHubObject', () => { const purpose_and_methodology: GetSurveyPurposeAndMethodologyData = { intended_outcome_ids: [], additional_details: 'A description of the purpose', - revision_count: 0, - vantage_code_ids: [] + revision_count: 0 }; const survey_geometry: FeatureCollection = { diff --git a/api/src/models/observation-analytics.ts b/api/src/models/observation-analytics.ts new file mode 100644 index 0000000000..faff4a53f8 --- /dev/null +++ b/api/src/models/observation-analytics.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; + +export const QualitativeMeasurementAnalyticsSchema = z.object({ + option: z.object({ + option_id: z.string(), + option_label: z.string() + }), + taxon_measurement_id: z.string(), + measurement_name: z.string() +}); + +export type QualitativeMeasurementAnalytics = z.infer; + +export const QuantitativeMeasurementAnalyticsSchema = z.object({ + value: z.number(), + taxon_measurement_id: z.string(), + measurement_name: z.string() +}); + +export type QuantitativeMeasurementAnalytics = z.infer; + +export const ObservationCountByGroupSchema = z.object({ + row_count: z.number(), + individual_count: z.number(), + individual_percentage: z.number() +}); + +export type ObservationCountByGroup = z.infer; + +export const ObservationCountByGroupWithNamedMeasurementsSchema = ObservationCountByGroupSchema.extend({ + qualitative_measurements: z.array(QualitativeMeasurementAnalyticsSchema), + quantitative_measurements: z.array(QuantitativeMeasurementAnalyticsSchema) +}); + +export type ObservationCountByGroupWithNamedMeasurements = z.infer< + typeof ObservationCountByGroupWithNamedMeasurementsSchema +>; + +export const ObservationCountByGroupWithMeasurementsSchema = z.object({ + quant_measurements: z.array( + z.object({ + value: z.number().nullable(), + critterbase_taxon_measurement_id: z.string() + }) + ), + qual_measurements: z.array( + z.object({ + option_id: z.string().nullable(), + critterbase_taxon_measurement_id: z.string() + }) + ) +}); + +export type ObservationCountByGroupWithMeasurements = z.infer; + +export const ObservationCountByGroupSQLResponse = z + .object({ + id: z.string(), + row_count: z.number(), + individual_count: z.number(), + individual_percentage: z.number(), + quant_measurements: z.record(z.string(), z.number().nullable()), + qual_measurements: z.record(z.string(), z.string().nullable()) + }) + // Allow additional properties + .catchall(z.any()); + +export type ObservationCountByGroupSQLResponse = z.infer; + +export const ObservationAnalyticsResponse = ObservationCountByGroupWithNamedMeasurementsSchema.merge( + ObservationCountByGroupSchema +) + // Allow additional properties + .catchall(z.any()); + +export type ObservationAnalyticsResponse = z.infer; diff --git a/api/src/models/project-view.ts b/api/src/models/project-view.ts index a6377fec11..b8aca002c0 100644 --- a/api/src/models/project-view.ts +++ b/api/src/models/project-view.ts @@ -34,7 +34,8 @@ export const FindProjectsResponse = z.object({ end_date: z.string().nullable(), regions: z.array(z.string()), focal_species: z.array(z.number()), - types: z.array(z.number()) + types: z.array(z.number()), + members: z.array(z.object({ system_user_id: z.number(), display_name: z.string() })) }); export type FindProjectsResponse = z.infer; diff --git a/api/src/models/standards-view.ts b/api/src/models/standards-view.ts new file mode 100644 index 0000000000..b611855b15 --- /dev/null +++ b/api/src/models/standards-view.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; +import { + CBQualitativeMeasurementTypeDefinition, + CBQuantitativeMeasurementTypeDefinition +} from '../services/critterbase-service'; + +const QualitativeMeasurementSchema = z.object({ + name: z.string(), + description: z.string().nullable(), + options: z.array( + z.object({ + name: z.string(), + description: z.string().nullable() + }) + ) +}); + +const QuantitativeMeasurementSchema = z.object({ + name: z.string(), + description: z.string().nullable(), + unit: z.string().nullable() +}); + +const MethodAttributesSchema = z.object({ + qualitative: z.array(QualitativeMeasurementSchema), + quantitative: z.array(QuantitativeMeasurementSchema) +}); + +export const EnvironmentStandardsSchema = z.object({ + qualitative: z.array(QualitativeMeasurementSchema), + quantitative: z.array(QuantitativeMeasurementSchema) +}); + +export type EnvironmentStandards = z.infer; + +export const MethodStandardSchema = z.object({ + method_lookup_id: z.number(), + name: z.string(), + description: z.string().nullable(), + attributes: MethodAttributesSchema +}); + +export type MethodStandard = z.infer; + +export interface ISpeciesStandards { + tsn: number; + scientificName: string; + measurements: { + quantitative: CBQuantitativeMeasurementTypeDefinition[]; + qualitative: CBQualitativeMeasurementTypeDefinition[]; + }; + markingBodyLocations: { id: string; key: string; value: string }[]; +} diff --git a/api/src/models/survey-create.test.ts b/api/src/models/survey-create.test.ts index fdd1ef06ba..c66980e1c9 100644 --- a/api/src/models/survey-create.test.ts +++ b/api/src/models/survey-create.test.ts @@ -192,18 +192,13 @@ describe('PostSpeciesData', () => { it('sets focal_species', () => { expect(data.focal_species).to.eql([]); }); - - it('sets ancillary_species', () => { - expect(data.ancillary_species).to.eql([]); - }); }); describe('All values provided', () => { let data: PostSpeciesData; const obj = { - focal_species: [1, 2], - ancillary_species: [3] + focal_species: [1, 2] }; before(() => { @@ -213,10 +208,6 @@ describe('PostSpeciesData', () => { it('sets focal_species', () => { expect(data.focal_species).to.eql([1, 2]); }); - - it('sets ancillary_species', () => { - expect(data.ancillary_species).to.eql([3]); - }); }); }); @@ -376,10 +367,6 @@ describe('PostPurposeAndMethodologyData', () => { it('sets additional_details', () => { expect(data.additional_details).to.equal(null); }); - - it('sets vantage_code_ids', () => { - expect(data.vantage_code_ids).to.eql([]); - }); }); describe('All values provided with first nations id', () => { @@ -387,8 +374,7 @@ describe('PostPurposeAndMethodologyData', () => { const obj = { intended_outcome_ids: [1], - additional_details: 'additional_detail', - vantage_code_ids: [4, 5] + additional_details: 'additional_detail' }; before(() => { @@ -402,10 +388,6 @@ describe('PostPurposeAndMethodologyData', () => { it('sets additional_details', () => { expect(data.additional_details).to.eql(obj.additional_details); }); - - it('sets vantage_code_ids', () => { - expect(data.vantage_code_ids).to.eql(obj.vantage_code_ids); - }); }); }); diff --git a/api/src/models/survey-create.ts b/api/src/models/survey-create.ts index 18e85a4f34..746fe7f5cf 100644 --- a/api/src/models/survey-create.ts +++ b/api/src/models/survey-create.ts @@ -1,6 +1,6 @@ import { SurveyStratum } from '../repositories/site-selection-strategy-repository'; import { PostSurveyBlock } from '../repositories/survey-block-repository'; -import { ITaxonomy } from '../services/platform-service'; +import { ITaxonomyWithEcologicalUnits } from '../services/platform-service'; import { PostSurveyLocationData } from './survey-update'; export class PostSurveyObject { @@ -89,12 +89,10 @@ export class PostSurveyDetailsData { } export class PostSpeciesData { - focal_species: ITaxonomy[]; - ancillary_species: ITaxonomy[]; + focal_species: ITaxonomyWithEcologicalUnits[]; constructor(obj?: any) { this.focal_species = (obj?.focal_species?.length && obj.focal_species) || []; - this.ancillary_species = (obj?.ancillary_species?.length && obj.ancillary_species) || []; } } @@ -126,12 +124,10 @@ export class PostProprietorData { export class PostPurposeAndMethodologyData { intended_outcome_ids: number[]; additional_details: string; - vantage_code_ids: number[]; constructor(obj?: any) { this.intended_outcome_ids = obj?.intended_outcome_ids || []; this.additional_details = obj?.additional_details || null; - this.vantage_code_ids = obj?.vantage_code_ids || []; } } diff --git a/api/src/models/survey-deployment.ts b/api/src/models/survey-deployment.ts new file mode 100644 index 0000000000..68d6d0841e --- /dev/null +++ b/api/src/models/survey-deployment.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const SurveyDeployment = z.object({ + deployment_id: z.number().int(), + critter_id: z.number(), + critterbase_critter_id: z.string().optional(), + bctw_deployment_id: z.string().uuid(), + critterbase_start_capture_id: z.string().uuid().nullable(), + critterbase_end_capture_id: z.string().uuid().nullable(), + critterbase_end_mortality_id: z.string().uuid().nullable() +}); + +export type SurveyDeployment = z.infer; + +export interface ICreateSurveyDeployment extends Omit {} + +export interface IUpdateSurveyDeployment + extends Omit {} diff --git a/api/src/models/survey-update.test.ts b/api/src/models/survey-update.test.ts index a4f6ef6408..d035d51fde 100644 --- a/api/src/models/survey-update.test.ts +++ b/api/src/models/survey-update.test.ts @@ -150,18 +150,13 @@ describe('PutSpeciesData', () => { it('sets focal_species', () => { expect(data.focal_species).to.eql([]); }); - - it('sets ancillary_species', () => { - expect(data.ancillary_species).to.eql([]); - }); }); describe('All values provided', () => { let data: PutSurveySpeciesData; const obj = { - focal_species: [1, 2], - ancillary_species: [3] + focal_species: [1, 2] }; before(() => { @@ -171,10 +166,6 @@ describe('PutSpeciesData', () => { it('sets focal_species', () => { expect(data.focal_species).to.eql([1, 2]); }); - - it('sets ancillary_species', () => { - expect(data.ancillary_species).to.eql([3]); - }); }); }); @@ -340,10 +331,6 @@ describe('PutPurposeAndMethodologyData', () => { expect(data.additional_details).to.equal(null); }); - it('sets vantage_code_ids', () => { - expect(data.vantage_code_ids).to.eql([]); - }); - it('sets revision_count', () => { expect(data.revision_count).to.equal(null); }); @@ -355,7 +342,6 @@ describe('PutPurposeAndMethodologyData', () => { const obj = { intended_outcome_ids: [1], additional_details: 'additional_detail', - vantage_code_ids: [4, 5], revision_count: 0 }; @@ -371,10 +357,6 @@ describe('PutPurposeAndMethodologyData', () => { expect(data.additional_details).to.equal(obj.additional_details); }); - it('sets vantage_code_ids', () => { - expect(data.vantage_code_ids).to.eql(obj.vantage_code_ids); - }); - it('sets revision_count', () => { expect(data.revision_count).to.equal(obj.revision_count); }); diff --git a/api/src/models/survey-update.ts b/api/src/models/survey-update.ts index b934badc18..5b87c118e7 100644 --- a/api/src/models/survey-update.ts +++ b/api/src/models/survey-update.ts @@ -1,7 +1,7 @@ import { Feature } from 'geojson'; import { SurveyStratum, SurveyStratumRecord } from '../repositories/site-selection-strategy-repository'; import { PostSurveyBlock } from '../repositories/survey-block-repository'; -import { ITaxonomy } from '../services/platform-service'; +import { ITaxonomyWithEcologicalUnits } from '../services/platform-service'; export class PutSurveyObject { survey_details: PutSurveyDetailsData; @@ -99,12 +99,10 @@ export class PutSurveyDetailsData { } export class PutSurveySpeciesData { - focal_species: ITaxonomy[]; - ancillary_species: ITaxonomy[]; + focal_species: ITaxonomyWithEcologicalUnits[]; constructor(obj?: any) { this.focal_species = (obj?.focal_species?.length && obj?.focal_species) || []; - this.ancillary_species = (obj?.ancillary_species?.length && obj?.ancillary_species) || []; } } @@ -136,13 +134,11 @@ export class PutSurveyProprietorData { export class PutSurveyPurposeAndMethodologyData { intended_outcome_ids: number[]; additional_details: string; - vantage_code_ids: number[]; revision_count: number; constructor(obj?: any) { this.intended_outcome_ids = (obj?.intended_outcome_ids?.length && obj?.intended_outcome_ids) || []; this.additional_details = obj?.additional_details || null; - this.vantage_code_ids = (obj?.vantage_code_ids?.length && obj.vantage_code_ids) || []; this.revision_count = obj?.revision_count ?? null; } } diff --git a/api/src/models/survey-view.test.ts b/api/src/models/survey-view.test.ts index 04245c0784..50f4f0751f 100644 --- a/api/src/models/survey-view.test.ts +++ b/api/src/models/survey-view.test.ts @@ -2,7 +2,6 @@ import { expect } from 'chai'; import { describe } from 'mocha'; import { IPermitModel } from '../repositories/permit-repository'; import { - GetAncillarySpeciesData, GetAttachmentsData, GetFocalSpeciesData, GetPermitData, @@ -101,40 +100,6 @@ describe('GetFocalSpeciesData', () => { }); }); -describe('GetAncillarySpeciesData', () => { - describe('No values provided', () => { - let data: GetAncillarySpeciesData; - - before(() => { - data = new GetAncillarySpeciesData(); - }); - - it('sets ancillary_species', () => { - expect(data.ancillary_species).to.eql([]); - }); - }); - - describe('All values provided', () => { - let data: GetAncillarySpeciesData; - - const obj = [ - { tsn: 1, commonNames: ['species1'] }, - { tsn: 2, commonNames: ['species2'] } - ]; - - before(() => { - data = new GetAncillarySpeciesData(obj); - }); - - it('sets ancillary_species', () => { - expect(data.ancillary_species).to.eql([ - { tsn: 1, commonNames: ['species1'] }, - { tsn: 2, commonNames: ['species2'] } - ]); - }); - }); -}); - describe('GetPermitData', () => { describe('No values provided', () => { let data: GetPermitData; @@ -325,10 +290,6 @@ describe('GetSurveyPurposeAndMethodologyData', () => { it('sets additional_details', () => { expect(data.additional_details).to.equal(''); }); - - it('sets vantage_code_ids', () => { - expect(data.vantage_code_ids).to.eql([]); - }); }); describe('All values provided with first nations id', () => { @@ -337,7 +298,6 @@ describe('GetSurveyPurposeAndMethodologyData', () => { const obj = { intended_outcome_ids: [1], additional_details: 'additional_detail', - vantage_ids: [4, 5], revision_count: 'count' }; @@ -353,10 +313,6 @@ describe('GetSurveyPurposeAndMethodologyData', () => { expect(data.additional_details).to.eql(obj.additional_details); }); - it('sets vantage_code_ids', () => { - expect(data.vantage_code_ids).to.eql(obj.vantage_ids); - }); - it('sets revision_count', function () { expect(data.revision_count).to.equal('count'); }); diff --git a/api/src/models/survey-view.ts b/api/src/models/survey-view.ts index 2c11556132..04148517e0 100644 --- a/api/src/models/survey-view.ts +++ b/api/src/models/survey-view.ts @@ -7,7 +7,7 @@ import { SurveyBlockRecord } from '../repositories/survey-block-repository'; import { SurveyLocationRecord } from '../repositories/survey-location-repository'; import { SurveyUser } from '../repositories/survey-participation-repository'; import { SystemUser } from '../repositories/user-repository'; -import { ITaxonomy } from '../services/platform-service'; +import { ITaxonomyWithEcologicalUnits } from '../services/platform-service'; export interface ISurveyAdvancedFilters { keyword?: string; @@ -35,7 +35,7 @@ export type FindSurveysResponse = z.infer; export type SurveyObject = { survey_details: GetSurveyData; - species: GetFocalSpeciesData & GetAncillarySpeciesData; + species: GetFocalSpeciesData; permit: GetPermitData; funding_sources: GetSurveyFundingSourceData[]; purpose_and_methodology: GetSurveyPurposeAndMethodologyData; @@ -101,7 +101,7 @@ export class GetSurveyFundingSourceData { } export class GetFocalSpeciesData { - focal_species: ITaxonomy[]; + focal_species: ITaxonomyWithEcologicalUnits[]; constructor(obj?: any[]) { this.focal_species = []; @@ -113,18 +113,6 @@ export class GetFocalSpeciesData { } } -export class GetAncillarySpeciesData { - ancillary_species: ITaxonomy[]; - - constructor(obj?: any[]) { - this.ancillary_species = []; - - obj?.length && - obj.forEach((item: any) => { - this.ancillary_species.push(item); - }); - } -} export class GetPermitData { permits: { permit_id: IPermitModel['permit_id']; @@ -146,12 +134,10 @@ export class GetSurveyPurposeAndMethodologyData { intended_outcome_ids: number[]; additional_details: string; revision_count: number; - vantage_code_ids: number[]; constructor(obj?: any) { this.intended_outcome_ids = (obj?.intended_outcome_ids?.length && obj?.intended_outcome_ids) || []; this.additional_details = obj?.additional_details || ''; - this.vantage_code_ids = (obj?.vantage_ids?.length && obj.vantage_ids) || []; this.revision_count = obj?.revision_count ?? 0; } } diff --git a/api/src/models/telemetry-view.ts b/api/src/models/telemetry-view.ts index 148579fdfa..f04c9dba5d 100644 --- a/api/src/models/telemetry-view.ts +++ b/api/src/models/telemetry-view.ts @@ -1,4 +1,4 @@ -export interface ITelemetryAdvancedFilters { +export interface IAllTelemetryAdvancedFilters { keyword?: string; itis_tsns?: number[]; itis_tsn?: number; diff --git a/api/src/openapi/schemas/critter.ts b/api/src/openapi/schemas/critter.ts index 2b5de50b79..404ad27dfd 100644 --- a/api/src/openapi/schemas/critter.ts +++ b/api/src/openapi/schemas/critter.ts @@ -1,13 +1,46 @@ import { OpenAPIV3 } from 'openapi-types'; +export const collectionUnitsSchema: OpenAPIV3.SchemaObject = { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['collection_category_id', 'collection_unit_id'], + properties: { + critter_collection_unit_id: { + type: 'string', + format: 'uuid' + }, + collection_category_id: { + type: 'string', + format: 'uuid' + }, + collection_unit_id: { + type: 'string', + format: 'uuid' + }, + unit_name: { + type: 'string' + }, + category_name: { + type: 'string' + } + } + } +}; + export const critterSchema: OpenAPIV3.SchemaObject = { type: 'object', additionalProperties: false, properties: { - critter_id: { + critterbase_critter_id: { type: 'string', format: 'uuid' }, + critter_id: { + type: 'integer', + minimum: 1 + }, animal_id: { type: 'string', nullable: true @@ -33,7 +66,24 @@ export const critterSchema: OpenAPIV3.SchemaObject = { critter_comment: { type: 'string', nullable: true - } + }, + mortality: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['mortality_id', 'mortality_timestamp'], + properties: { + mortality_id: { + type: 'string' + }, + mortality_timestamp: { + type: 'string' + } + } + } + }, + collection_units: collectionUnitsSchema } }; diff --git a/api/src/openapi/schemas/deployment.ts b/api/src/openapi/schemas/deployment.ts new file mode 100644 index 0000000000..a742ebbc3f --- /dev/null +++ b/api/src/openapi/schemas/deployment.ts @@ -0,0 +1,256 @@ +import { OpenAPIV3 } from 'openapi-types'; +import { GeoJSONFeatureCollection } from './geoJson'; + +export const getDeploymentSchema: OpenAPIV3.SchemaObject = { + type: 'object', + // TODO: REMOVE unnecessary columns from BCTW response + additionalProperties: false, + required: [ + // BCTW properties + 'assignment_id', + 'collar_id', + 'critter_id', + 'device_id', + 'attachment_start_date', + 'attachment_start_time', + 'attachment_end_date', + 'attachment_end_time', + 'bctw_deployment_id', + 'device_make', + 'device_model', + 'frequency', + 'frequency_unit', + // SIMS properties + 'deployment_id', + 'critterbase_critter_id', + 'critterbase_start_capture_id', + 'critterbase_end_capture_id', + 'critterbase_end_mortality_id' + ], + properties: { + // BCTW properties + assignment_id: { + type: 'string', + format: 'uuid' + }, + collar_id: { + type: 'string', + description: 'Id of the collar in BCTW' + }, + attachment_start_date: { + type: 'string', + description: 'start date of the deployment.' + }, + attachment_start_time: { + type: 'string', + description: 'start time of the deployment.' + }, + attachment_end_date: { + type: 'string', + description: 'End date of the deployment.', + nullable: true + }, + attachment_end_time: { + type: 'string', + description: 'End time of the deployment.', + nullable: true + }, + bctw_deployment_id: { + type: 'string', + format: 'uuid', + description: 'Id of the deployment in BCTW. May match multiple records in BCTW' + }, + device_id: { + type: 'integer', + description: 'Id of the device, as reported by users. Not unique.' + }, + device_make: { + type: 'number', + nullable: true + }, + device_model: { + type: 'string', + nullable: true + }, + frequency: { + type: 'number', + nullable: true + }, + frequency_unit: { + type: 'number', + nullable: true + }, + // SIMS properties + deployment_id: { + type: 'integer', + description: 'Id of the deployment in the Survey.' + }, + critter_id: { + type: 'integer', + minimum: 1, + description: 'Id of the critter in the Survey' + }, + critterbase_critter_id: { + type: 'string', + format: 'uuid', + description: 'Id of the critter in Critterbase.' + }, + critterbase_start_capture_id: { + type: 'string' + }, + critterbase_end_capture_id: { + type: 'string', + nullable: true + }, + critterbase_end_mortality_id: { + type: 'string', + nullable: true + } + } +}; + +const GeoJSONFeatureCollectionFeaturesItems = ( + GeoJSONFeatureCollection.properties?.features as OpenAPIV3.ArraySchemaObject +)?.items as OpenAPIV3.SchemaObject; + +export const GeoJSONTelemetryPointsAPISchema: OpenAPIV3.SchemaObject = { + ...GeoJSONFeatureCollection, + properties: { + ...GeoJSONFeatureCollection.properties, + features: { + type: 'array', + items: { + ...GeoJSONFeatureCollectionFeaturesItems, + properties: { + ...GeoJSONFeatureCollectionFeaturesItems?.properties, + properties: { + type: 'object', + additionalProperties: false, + required: ['collar_id', 'device_id', 'date_recorded', 'deployment_id', 'critter_id'], + properties: { + collar_id: { + type: 'string', + format: 'uuid' + }, + device_id: { + type: 'integer' + }, + elevation: { + type: 'number', + nullable: true + }, + frequency: { + type: 'number', + nullable: true + }, + critter_id: { + type: 'string', + format: 'uuid' + }, + date_recorded: { + type: 'string' + }, + deployment_id: { + type: 'string', + format: 'uuid' + }, + device_status: { + type: 'string', + nullable: true + }, + device_vendor: { + type: 'string', + nullable: true + }, + frequency_unit: { + type: 'number', + nullable: true + }, + wlh_id: { + type: 'string', + nullable: true + }, + animal_id: { + type: 'string', + nullable: true + }, + sex: { + type: 'string' + }, + taxon: { + type: 'string' + }, + collection_units: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + collection_unit_id: { + type: 'string', + format: 'uuid' + }, + unit_name: { + type: 'string' + }, + collection_category_id: { + type: 'string', + format: 'uuid' + }, + category_name: { + type: 'string' + } + } + } + }, + mortality_timestamp: { + type: 'string', + nullable: true + }, + _merged: { + type: 'boolean' + }, + map_colour: { + type: 'string' + } + } + } + } + } + } + } +}; + +export const GeoJSONTelemetryTracksAPISchema: OpenAPIV3.SchemaObject = { + ...GeoJSONFeatureCollection, + properties: { + ...GeoJSONFeatureCollection.properties, + features: { + type: 'array', + items: { + ...GeoJSONFeatureCollectionFeaturesItems, + properties: { + ...GeoJSONFeatureCollectionFeaturesItems?.properties, + properties: { + type: 'object', + additionalProperties: false, + required: ['critter_id', 'deployment_id'], + properties: { + critter_id: { + type: 'string', + format: 'uuid' + }, + deployment_id: { + type: 'string', + format: 'uuid' + }, + map_colour: { + type: 'string' + } + } + } + } + } + } + } +}; diff --git a/api/src/openapi/schemas/file.ts b/api/src/openapi/schemas/file.ts index e52ba63484..fba9ec9c5b 100644 --- a/api/src/openapi/schemas/file.ts +++ b/api/src/openapi/schemas/file.ts @@ -58,7 +58,17 @@ export const csvFileSchema: OpenAPIV3.SchemaObject = { mimetype: { description: 'CSV File type.', type: 'string', - enum: ['text/csv'] + // https://christianwood.net/posts/csv-file-upload-validation/ + enum: [ + 'text/csv', + 'text/x-csv', + 'application/vnd.ms-excel', + 'application/csv', + 'application/x-csv', + 'text/comma-seperated-values', + 'text/x-comma-seperated-values', + 'text/tab-seperated-values' + ] } } }; diff --git a/api/src/openapi/schemas/standards.ts b/api/src/openapi/schemas/standards.ts new file mode 100644 index 0000000000..6457fcdba7 --- /dev/null +++ b/api/src/openapi/schemas/standards.ts @@ -0,0 +1,137 @@ +import { OpenAPIV3 } from 'openapi-types'; + +export const EnvironmentStandardsSchema: OpenAPIV3.SchemaObject = { + type: 'object', + description: + 'Environment standards response object showing supported environmental variables and associated information', + additionalProperties: false, + properties: { + qualitative: { + type: 'array', + description: 'Array of qualitative environmental variables', + items: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Name of the environmental variable' + }, + description: { + type: 'string', + description: 'Description of the environmental variable', + nullable: true + }, + options: { + type: 'array', + description: 'Array of options for the qualitative variable', + items: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Description of the environmental variable option' + }, + description: { + type: 'string', + description: 'Description of the environmental variable option', + nullable: true + } + } + } + } + } + } + }, + quantitative: { + type: 'array', + description: 'Array of quantitative environmental variables', + items: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Name of the quantitative environmental variable' + }, + description: { + type: 'string', + description: 'Description of the quantitative environmental variable', + nullable: true + }, + unit: { + type: 'string', + description: 'Unit of measurement of the quantitative environmental variable', + nullable: true + } + } + } + } + } +}; + +export const MethodStandardSchema: OpenAPIV3.SchemaObject = { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + method_lookup_id: { type: 'number' }, + name: { type: 'string' }, + description: { type: 'string', nullable: true }, + attributes: { + type: 'object', + additionalProperties: false, + properties: { + qualitative: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + }, + options: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + } + } + } + } + } + } + }, + quantitative: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + }, + unit: { type: 'string', nullable: true } + } + } + } + } + } + } + } +}; diff --git a/api/src/openapi/schemas/survey.ts b/api/src/openapi/schemas/survey.ts index c7db5e593a..6e9a35a298 100644 --- a/api/src/openapi/schemas/survey.ts +++ b/api/src/openapi/schemas/survey.ts @@ -55,6 +55,30 @@ export const surveyDetailsSchema: OpenAPIV3.SchemaObject = { } }; +/** + * Schema for creating, updating and retrieving ecological units for focal species in a SIMS survey. + * Prefixed with critterbase_* to match database field names in SIMS. + * + */ +export const SurveyEcologicalUnitsSchema: OpenAPIV3.SchemaObject = { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['critterbase_collection_category_id', 'critterbase_collection_unit_id'], + properties: { + critterbase_collection_category_id: { + type: 'string', + format: 'uuid' + }, + critterbase_collection_unit_id: { + type: 'string', + format: 'uuid' + } + } + } +}; + export const surveyFundingSourceSchema: OpenAPIV3.SchemaObject = { title: 'survey funding source response object', type: 'object', @@ -112,7 +136,7 @@ export const focalSpeciesSchema: OpenAPIV3.SchemaObject = { title: 'focal species response object', type: 'object', additionalProperties: false, - required: ['tsn', 'commonNames', 'scientificName'], + required: ['tsn', 'commonNames', 'scientificName', 'ecological_units'], properties: { tsn: { description: 'Taxonomy tsn', @@ -137,40 +161,8 @@ export const focalSpeciesSchema: OpenAPIV3.SchemaObject = { kingdom: { description: 'Taxonomy kingdom name', type: 'string' - } - } -}; - -export const ancillarySpeciesSchema: OpenAPIV3.SchemaObject = { - title: 'ancillary species response object', - type: 'object', - additionalProperties: false, - required: ['tsn', 'commonNames', 'scientificName'], - properties: { - tsn: { - description: 'Taxonomy tsn', - type: 'number' }, - commonNames: { - description: 'Taxonomy common names', - type: 'array', - items: { - type: 'string' - }, - nullable: true - }, - scientificName: { - description: 'Taxonomy scientific name', - type: 'string' - }, - rank: { - description: 'Taxonomy rank name', - type: 'string' - }, - kingdom: { - description: 'Taxonomy kingdom name', - type: 'string' - } + ecological_units: SurveyEcologicalUnitsSchema } }; @@ -178,13 +170,8 @@ export const surveySpeciesSchema: OpenAPIV3.SchemaObject = { description: 'Survey Species', type: 'object', additionalProperties: false, - required: ['focal_species', 'ancillary_species'], + required: ['focal_species'], properties: { - ancillary_species: { - nullable: true, - type: 'array', - items: ancillarySpeciesSchema - }, focal_species: { type: 'array', items: focalSpeciesSchema @@ -282,7 +269,7 @@ export const surveyPurposeAndMethodologySchema: OpenAPIV3.SchemaObject = { title: 'survey purpose and methodology response object', type: 'object', additionalProperties: false, - required: ['intended_outcome_ids', 'additional_details', 'vantage_code_ids'], + required: ['intended_outcome_ids', 'additional_details'], properties: { intended_outcome_ids: { description: 'Intended outcome ids', @@ -301,14 +288,6 @@ export const surveyPurposeAndMethodologySchema: OpenAPIV3.SchemaObject = { description: 'The integer of times the record has been revised.', type: 'integer', minimum: 0 - }, - vantage_code_ids: { - description: 'Vantage code ids', - type: 'array', - items: { - type: 'integer', - minimum: 1 - } } } }; diff --git a/api/src/openapi/schemas/telemetry.ts b/api/src/openapi/schemas/telemetry.ts new file mode 100644 index 0000000000..3de5f7081f --- /dev/null +++ b/api/src/openapi/schemas/telemetry.ts @@ -0,0 +1,57 @@ +import { OpenAPIV3 } from 'openapi-types'; + +export const AllTelemetrySchema: OpenAPIV3.SchemaObject = { + type: 'object', + additionalProperties: false, + required: [ + 'id', + 'deployment_id', + 'latitude', + 'longitude', + 'acquisition_date', + 'telemetry_type', + 'telemetry_id', + 'telemetry_manual_id' + ], + properties: { + id: { + type: 'string', + format: 'uuid', + description: + 'The unique identifier for the telemetry point. Will match whichever of telemetry_id or telemetry_manual_id is not null.' + }, + deployment_id: { + type: 'string', + format: 'uuid', + description: 'The unique identifier for the deployment that the telemetry point is associated with.' + }, + latitude: { + type: 'number', + description: 'The latitude of the telemetry point.' + }, + longitude: { + type: 'number', + description: 'The longitude of the telemetry point.' + }, + acquisition_date: { + type: 'string' + }, + telemetry_type: { + type: 'string', + description: "The type of telemetry point. Will either be 'MANUAL' or the name of the vendor." + }, + telemetry_id: { + type: 'string', + format: 'uuid', + nullable: true, + description: + "The unique identifier for the telemetry point. Will only be non-null if telemetry_type is not 'MANUAL'." + }, + telemetry_manual_id: { + type: 'string', + format: 'uuid', + nullable: true, + description: "The unique identifier for the telemetry point. Will only be non-null if telemetry_type is 'MANUAL'." + } + } +}; diff --git a/api/src/paths/administrative-activities.ts b/api/src/paths/administrative-activities.ts index 81d8d9b58d..31b7c2fbd1 100644 --- a/api/src/paths/administrative-activities.ts +++ b/api/src/paths/administrative-activities.ts @@ -108,6 +108,16 @@ GET.apiDoc = { create_date: { type: 'string', description: 'ISO 8601 date string' + }, + updated_by: { + type: 'string', + description: 'Display name of the user who last updated the record', + nullable: true + }, + update_date: { + type: 'string', + description: 'Date when the record was last updated', + nullable: true } } } diff --git a/api/src/paths/analytics/observations.ts b/api/src/paths/analytics/observations.ts new file mode 100644 index 0000000000..65cbc6265e --- /dev/null +++ b/api/src/paths/analytics/observations.ts @@ -0,0 +1,243 @@ +import { Operation } from 'express-openapi'; +import { RequestHandler } from 'http-proxy-middleware'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../constants/roles'; +import { getDBConnection } from '../../database/db'; +import { authorizeRequestHandler } from '../../request-handlers/security/authorization'; +import { AnalyticsService } from '../../services/analytics-service'; +import { getLogger } from '../../utils/logger'; + +const defaultLog = getLogger('paths/analytics/observations'); + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [ + PROJECT_PERMISSION.COORDINATOR, + PROJECT_PERMISSION.COLLABORATOR, + PROJECT_PERMISSION.OBSERVER + ], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + }, + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN], + discriminator: 'SystemRole' + } + ] + }; + }), + getObservationCountByGroup() +]; + +GET.apiDoc = { + description: 'get analytics about observations for one or more surveys', + tags: ['analytics'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'surveyIds', + schema: { + type: 'array', + items: { + type: 'integer', + minimum: 1 + } + }, + required: true + }, + { + in: 'query', + name: 'groupByColumns', + schema: { + type: 'array', + items: { + type: 'string' + } + }, + description: 'An array of column names to group the observations data by' + }, + { + in: 'query', + name: 'groupByQuantitativeMeasurements', + schema: { + type: 'array', + items: { + type: 'string' + } + }, + description: 'An array of quantitative taxon_measurement_ids to group the observations data by' + }, + { + in: 'query', + name: 'groupByQualitativeMeasurements', + schema: { + type: 'array', + items: { + type: 'string' + } + }, + description: 'An array of qualitative taxon_measurement_ids to group the observations data by' + } + ], + responses: { + 200: { + description: 'Analytics calculated OK.', + content: { + 'application/json': { + schema: { + title: 'Observation analytics response object', + type: 'array', + items: { + type: 'object', + required: [ + 'id', + 'row_count', + 'individual_count', + 'individual_percentage', + 'quantitative_measurements', + 'qualitative_measurements' + ], + // Additional properties is intentionally true to allow for dynamic key-value measurement pairs + additionalProperties: true, + properties: { + id: { + type: 'string', + format: 'uuid', + description: 'Unique identifier for the group. Will not be consistent between requests.' + }, + row_count: { + type: 'number', + description: 'Number of rows in the group' + }, + individual_count: { + type: 'number', + description: 'Sum of subcount values across all rows in the group' + }, + individual_percentage: { + type: 'number', + description: + 'Sum of subcount values across the group divided by the sum of subcount values across all observations in the specified surveys' + }, + quantitative_measurements: { + type: 'array', + items: { + type: 'object', + description: 'Quantitative measurement groupings', + required: ['taxon_measurement_id', 'measurement_name', 'value'], + additionalProperties: false, + properties: { + taxon_measurement_id: { + type: 'string', + format: 'uuid' + }, + measurement_name: { + type: 'string' + }, + value: { + type: 'number', + nullable: true + } + } + } + }, + qualitative_measurements: { + type: 'array', + items: { + type: 'object', + description: 'Qualitative measurement groupings', + required: ['taxon_measurement_id', 'measurement_name', 'option'], + additionalProperties: false, + properties: { + taxon_measurement_id: { + type: 'string', + format: 'uuid' + }, + measurement_name: { + type: 'string' + }, + option: { + type: 'object', + required: ['option_id', 'option_label'], + additionalProperties: false, + properties: { + option_id: { + type: 'string', + format: 'uuid', + nullable: true + }, + option_label: { + type: 'string', + nullable: true + } + } + } + } + } + } + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function getObservationCountByGroup(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'getObservationCountByGroup' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + const { surveyIds, groupByColumns, groupByQuantitativeMeasurements, groupByQualitativeMeasurements } = req.query; + + await connection.open(); + + const analyticsService = new AnalyticsService(connection); + + const response = await analyticsService.getObservationCountByGroup( + (surveyIds as string[]).map(Number), + (groupByColumns as string[]) ?? [], + (groupByQuantitativeMeasurements as string[]) ?? [], + (groupByQualitativeMeasurements as string[]) ?? [] + ); + + await connection.commit(); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'getObservationCountByGroup', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/codes.ts b/api/src/paths/codes.ts index 8f2c6e3644..a8df6080af 100644 --- a/api/src/paths/codes.ts +++ b/api/src/paths/codes.ts @@ -34,7 +34,6 @@ GET.apiDoc = { 'project_roles', 'administrative_activity_status_type', 'intended_outcomes', - 'vantage_codes', 'site_selection_strategies', 'survey_progress', 'method_response_metrics', @@ -252,21 +251,6 @@ GET.apiDoc = { } } }, - vantage_codes: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - properties: { - id: { - type: 'number' - }, - name: { - type: 'string' - } - } - } - }, survey_jobs: { type: 'array', items: { diff --git a/api/src/paths/gcnotify/send.ts b/api/src/paths/gcnotify/send.ts index 7bc157cc57..938cf5a44c 100644 --- a/api/src/paths/gcnotify/send.ts +++ b/api/src/paths/gcnotify/send.ts @@ -145,7 +145,7 @@ POST.apiDoc = { export function sendNotification(): RequestHandler { return async (req, res) => { const recipient = req.body?.recipient || null; - const message = { ...req.body?.message, footer: `To access the site, [${APP_HOST}](${APP_HOST})` } || null; + const message = { ...req.body?.message, footer: `To access the site, [${APP_HOST}](${APP_HOST})` }; try { const gcnotifyService = new GCNotifyService(); diff --git a/api/src/paths/logger.test.ts b/api/src/paths/logger.test.ts index 02731ce09d..2367c213a8 100644 --- a/api/src/paths/logger.test.ts +++ b/api/src/paths/logger.test.ts @@ -1,34 +1,19 @@ import { expect } from 'chai'; import { describe } from 'mocha'; -import { HTTPError } from '../errors/http-error'; import { getRequestHandlerMocks } from '../__mocks__/db'; import * as logger from './logger'; describe('logger', () => { describe('updateLoggerLevel', () => { - it('should throw a 400 error when `level` query param is missing', async () => { - const requestHandler = logger.updateLoggerLevel(); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq.query = {}; - - try { - await requestHandler(mockReq, mockRes, mockNext); - - expect.fail(); - } catch (error) { - expect((error as HTTPError).status).to.equal(400); - expect((error as HTTPError).message).to.equal('Missing required query param `level`'); - } - }); - it('should return 200 on success', async () => { const requestHandler = logger.updateLoggerLevel(); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - mockReq.query = { level: 'info' }; + mockReq.query = { + logLevel: 'info', + logLevelFile: 'debug' + }; await requestHandler(mockReq, mockRes, mockNext); diff --git a/api/src/paths/logger.ts b/api/src/paths/logger.ts index f370a9501a..39576522f3 100644 --- a/api/src/paths/logger.ts +++ b/api/src/paths/logger.ts @@ -1,9 +1,8 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { SYSTEM_ROLE } from '../constants/roles'; -import { HTTP400 } from '../errors/http-error'; import { authorizeRequestHandler } from '../request-handlers/security/authorization'; -import { setLogLevel, WinstonLogLevel, WinstonLogLevels } from '../utils/logger'; +import { setLogLevel, setLogLevelFile, WinstonLogLevel, WinstonLogLevels } from '../utils/logger'; export const GET: Operation = [ authorizeRequestHandler(() => { @@ -20,7 +19,7 @@ export const GET: Operation = [ ]; GET.apiDoc = { - description: "Update the log level for the API's default logger", + description: 'Update the logging level of the winston logger.', tags: ['misc'], security: [ { @@ -30,13 +29,23 @@ GET.apiDoc = { parameters: [ { in: 'query', - name: 'level', + name: 'logLevel', + description: 'Set the log level for the console transport (non-production environments only)', schema: { description: 'Log levels, from least logging to most logging', type: 'string', enum: [...WinstonLogLevels] - }, - required: true + } + }, + { + in: 'query', + name: 'logLevelFile', + description: 'Set the log level for the file transport', + schema: { + description: 'Log levels, from least logging to most logging', + type: 'string', + enum: [...WinstonLogLevels] + } } ], responses: { @@ -59,17 +68,19 @@ GET.apiDoc = { }; /** - * Get api version information. + * Update the logging level of the winston logger. * * @returns {RequestHandler} */ export function updateLoggerLevel(): RequestHandler { return (req, res) => { - if (!req.query?.level) { - throw new HTTP400('Missing required query param `level`'); + if (req.query.logLevel) { + setLogLevel(req.query.loglevel as WinstonLogLevel); } - setLogLevel(req.query.level as WinstonLogLevel); + if (req.query.logLevelFile) { + setLogLevelFile(req.query.logLevelFile as WinstonLogLevel); + } res.status(200).send(); }; diff --git a/api/src/paths/project/index.test.ts b/api/src/paths/project/index.test.ts index d36b2c6d0f..0a80991172 100644 --- a/api/src/paths/project/index.test.ts +++ b/api/src/paths/project/index.test.ts @@ -28,7 +28,8 @@ describe('findProjects', () => { end_date: '2021-12-31', regions: ['region1'], focal_species: [123, 456], - types: [1, 2, 3] + types: [1, 2, 3], + members: [{ system_user_id: 1, display_name: 'John Doe' }] } ]; @@ -87,7 +88,8 @@ describe('findProjects', () => { end_date: '2021-12-31', regions: ['region1'], focal_species: [123, 456], - types: [1, 2, 3] + types: [1, 2, 3], + members: [{ system_user_id: 1, display_name: 'John Doe' }] } ]; diff --git a/api/src/paths/project/index.ts b/api/src/paths/project/index.ts index 8d75b4294c..bf2b66367a 100644 --- a/api/src/paths/project/index.ts +++ b/api/src/paths/project/index.ts @@ -106,7 +106,16 @@ GET.apiDoc = { items: { type: 'object', additionalProperties: false, - required: ['project_id', 'name', 'start_date', 'end_date', 'focal_species', 'regions', 'types'], + required: [ + 'project_id', + 'name', + 'start_date', + 'end_date', + 'focal_species', + 'regions', + 'types', + 'members' + ], properties: { project_id: { type: 'integer', @@ -147,6 +156,19 @@ GET.apiDoc = { items: { type: 'integer' } + }, + members: { + type: 'array', + description: 'Members of the Project', + items: { + type: 'object', + additionalProperties: false, + required: ['system_user_id', 'display_name'], + properties: { + system_user_id: { type: 'number' }, + display_name: { type: 'string' } + } + } } } } diff --git a/api/src/paths/project/{projectId}/survey/create.ts b/api/src/paths/project/{projectId}/survey/create.ts index 9fd7b420be..23fb747756 100644 --- a/api/src/paths/project/{projectId}/survey/create.ts +++ b/api/src/paths/project/{projectId}/survey/create.ts @@ -12,7 +12,6 @@ import { surveyPermitSchema, surveyProprietorSchema, surveyPurposeAndMethodologySchema, - surveySiteSelectionSchema, surveySpeciesSchema } from '../../../../openapi/schemas/survey'; import { surveyParticipationAndSystemUserSchema } from '../../../../openapi/schemas/user'; @@ -104,7 +103,56 @@ POST.apiDoc = { type: 'array', items: surveyLocationSchema }, - site_selection: surveySiteSelectionSchema, + site_selection: { + title: 'survey site selection response object', + type: 'object', + additionalProperties: false, + required: ['strategies', 'stratums'], + properties: { + strategies: { + description: 'Strategies', + type: 'array', + items: { + type: 'string' + } + }, + stratums: { + description: 'Stratums', + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['name', 'description'], + properties: { + name: { + description: 'Name', + type: 'string' + }, + description: { + description: 'Description', + type: 'string', + nullable: true + }, + survey_id: { + description: 'Survey id', + type: 'integer', + nullable: true + }, + survey_stratum_id: { + description: 'Survey stratum id', + type: 'integer', + nullable: true, + minimum: 1 + }, + revision_count: { + description: 'Revision count', + type: 'integer' + } + } + } + } + } + }, participants: { type: 'array', items: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/keyx/upload.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/keyx/upload.test.ts deleted file mode 100644 index 95377ae9ef..0000000000 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/keyx/upload.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import chai, { expect } from 'chai'; -import { describe } from 'mocha'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import * as db from '../../../../../../../database/db'; -import { HTTPError } from '../../../../../../../errors/http-error'; -import { AttachmentService } from '../../../../../../../services/attachment-service'; -import { BctwService } from '../../../../../../../services/bctw-service'; -import * as file_utils from '../../../../../../../utils/file-utils'; -import * as media_utils from '../../../../../../../utils/media/media-utils'; -import { getMockDBConnection } from '../../../../../../../__mocks__/db'; -import * as upload from './upload'; - -chai.use(sinonChai); - -describe('uploadMedia', () => { - afterEach(() => { - sinon.restore(); - }); - - const dbConnectionObj = getMockDBConnection(); - - const mockReq = { - keycloak_token: {}, - system_user: { user_guid: 'system_user_guid', user_identifier: 'system_user_identifier' }, - params: { - projectId: 1, - attachmentId: 2 - }, - files: [ - { - fieldname: 'media', - originalname: 'test.keyx', - encoding: '7bit', - mimetype: 'text/plain', - size: 340 - } - ], - body: {} - } as any; - - const mockBctwResponse = { totalKeyxFiles: 2, newRecords: 1, existingRecords: 1 }; - - it('should throw an error when file has malicious content', async () => { - sinon.stub(db, 'getDBConnection').returns({ - ...dbConnectionObj, - systemUserId: () => { - return 20; - } - }); - - sinon.stub(file_utils, 'scanFileForVirus').resolves(false); - - try { - const result = upload.uploadKeyxMedia(); - - await result(mockReq, null as unknown as any, null as unknown as any); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).status).to.equal(400); - expect((actualError as HTTPError).message).to.equal('Malicious content detected, upload cancelled'); - } - }); - - it('should throw an error when file type is invalid', async () => { - sinon.stub(db, 'getDBConnection').returns({ - ...dbConnectionObj, - systemUserId: () => { - return 20; - } - }); - - sinon.stub(file_utils, 'scanFileForVirus').resolves(true); - sinon.stub(media_utils, 'checkFileForKeyx').returns(false); - - try { - const result = upload.uploadKeyxMedia(); - - await result(mockReq, null as unknown as any, null as unknown as any); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).status).to.equal(400); - expect((actualError as HTTPError).message).to.equal( - 'The file must either be a keyx file or a zip containing only keyx files.' - ); - } - }); - - it('should throw an error if failure occurs', async () => { - sinon.stub(db, 'getDBConnection').returns({ - ...dbConnectionObj, - systemUserId: () => { - return 20; - } - }); - - sinon.stub(file_utils, 'scanFileForVirus').resolves(true); - - const expectedError = new Error('A test error'); - sinon.stub(BctwService.prototype, 'uploadKeyX').rejects(expectedError); - - try { - const result = upload.uploadKeyxMedia(); - - await result(mockReq, null as unknown as any, null as unknown as any); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).message).to.equal(expectedError.message); - } - }); - - it('should succeed with valid params', async () => { - sinon.stub(db, 'getDBConnection').returns({ - ...dbConnectionObj, - systemUserId: () => { - return 20; - } - }); - - sinon.stub(file_utils, 'scanFileForVirus').resolves(true); - const uploadKeyXStub = sinon.stub(BctwService.prototype, 'uploadKeyX').resolves(mockBctwResponse); - sinon.stub(file_utils, 'uploadFileToS3').resolves(); - - const expectedResponse = { attachmentId: 1, revision_count: 1, keyxResults: mockBctwResponse }; - - let actualResult: any = null; - const sampleRes = { - status: () => { - return { - json: (response: any) => { - actualResult = response; - } - }; - } - }; - - const upsertSurveyAttachmentStub = sinon - .stub(AttachmentService.prototype, 'upsertSurveyAttachment') - .resolves({ survey_attachment_id: 1, revision_count: 1, key: 'string' }); - - const result = upload.uploadKeyxMedia(); - - await result(mockReq, sampleRes as unknown as any, null as unknown as any); - expect(actualResult).to.eql(expectedResponse); - expect(uploadKeyXStub).to.be.calledOnce; - expect(upsertSurveyAttachmentStub).to.be.calledOnce; - }); -}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/keyx/upload.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/keyx/upload.ts deleted file mode 100644 index 6ec0a72418..0000000000 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/keyx/upload.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { RequestHandler } from 'express'; -import { Operation } from 'express-openapi'; -import { ATTACHMENT_TYPE } from '../../../../../../../constants/attachments'; -import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; -import { getDBConnection } from '../../../../../../../database/db'; -import { HTTP400 } from '../../../../../../../errors/http-error'; -import { fileSchema } from '../../../../../../../openapi/schemas/file'; -import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; -import { AttachmentService } from '../../../../../../../services/attachment-service'; -import { BctwService, IBctwUser } from '../../../../../../../services/bctw-service'; -import { scanFileForVirus, uploadFileToS3 } from '../../../../../../../utils/file-utils'; -import { getLogger } from '../../../../../../../utils/logger'; -import { checkFileForKeyx } from '../../../../../../../utils/media/media-utils'; -import { getFileFromRequest } from '../../../../../../../utils/request'; - -const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/attachments/keyx/upload'); - -export const POST: Operation = [ - authorizeRequestHandler((req) => { - return { - or: [ - { - validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], - surveyId: Number(req.params.surveyId), - discriminator: 'ProjectPermission' - }, - { - validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], - discriminator: 'SystemRole' - } - ] - }; - }), - uploadKeyxMedia() -]; - -POST.apiDoc = { - tags: ['attachment'], - description: 'Upload a survey-specific keyx attachment.', - security: [ - { - Bearer: [] - } - ], - parameters: [ - { - in: 'path', - name: 'surveyId', - schema: { - type: 'integer', - minimum: 1 - }, - required: true - }, - { - in: 'path', - name: 'projectId', - schema: { - type: 'integer', - minimum: 1 - }, - required: true - } - ], - requestBody: { - description: 'Keyx Attachment upload post request object.', - content: { - 'multipart/form-data': { - schema: { - type: 'object', - additionalProperties: false, - required: ['media'], - properties: { - media: { - description: 'Keyx import file.', - type: 'array', - minItems: 1, - maxItems: 1, - items: fileSchema - } - } - } - } - } - }, - - responses: { - 200: { - description: 'Keyx Attachment upload response.', - content: { - 'application/json': { - schema: { - type: 'object', - additionalProperties: false, - required: ['attachmentId', 'revision_count', 'keyxResults'], - properties: { - keyxResults: { - type: 'object', - additionalProperties: false, - required: ['totalKeyxFiles', 'newRecords', 'existingRecords'], - properties: { - totalKeyxFiles: { type: 'integer' }, - newRecords: { type: 'integer' }, - existingRecords: { type: 'integer' } - } - }, - attachmentId: { - type: 'integer' - }, - revision_count: { - type: 'integer' - } - } - } - } - } - }, - 401: { - $ref: '#/components/responses/400' - }, - 500: { - $ref: '#/components/responses/500' - }, - default: { - $ref: '#/components/responses/default' - } - } -}; - -/** - * Uploads any media in the request to S3, adding their keys to the request. - * Also adds the metadata to the survey_attachment DB table - * Does nothing if no media is present in the request. - * - * @returns {RequestHandler} - */ -export function uploadKeyxMedia(): RequestHandler { - return async (req, res) => { - const rawMediaFile = getFileFromRequest(req); - - defaultLog.debug({ - label: 'uploadKeyxMedia', - message: 'files', - files: { ...rawMediaFile, buffer: 'Too big to print' } - }); - - const connection = getDBConnection(req.keycloak_token); - - try { - await connection.open(); - - // Scan file for viruses using ClamAV - const virusScanResult = await scanFileForVirus(rawMediaFile); - - if (!virusScanResult) { - throw new HTTP400('Malicious content detected, upload cancelled'); - } - - // Send the file to BCTW Api - if (!checkFileForKeyx(rawMediaFile)) { - throw new HTTP400('The file must either be a keyx file or a zip containing only keyx files.'); - } - - const user: IBctwUser = { - keycloak_guid: connection.systemUserGUID(), - username: connection.systemUserIdentifier() - }; - - const bctwService = new BctwService(user); - const bctwUploadResult = await bctwService.uploadKeyX(rawMediaFile); - - // Upsert attachment - const attachmentService = new AttachmentService(connection); - - const upsertResult = await attachmentService.upsertSurveyAttachment( - rawMediaFile, - Number(req.params.projectId), - Number(req.params.surveyId), - ATTACHMENT_TYPE.KEYX - ); - - const metadata = { - filename: rawMediaFile.originalname, - username: req.keycloak_token?.preferred_username ?? '', - email: req.keycloak_token?.email ?? '' - }; - - const result = await uploadFileToS3(rawMediaFile, upsertResult.key, metadata); - - defaultLog.debug({ label: 'uploadMedia', message: 'result', result }); - - await connection.commit(); - - return res.status(200).json({ - attachmentId: upsertResult.survey_attachment_id, - revision_count: upsertResult.revision_count, - keyxResults: bctwUploadResult - }); - } catch (error) { - defaultLog.error({ label: 'uploadMedia', message: 'error', error }); - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - }; -} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/telemetry/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/telemetry/index.test.ts new file mode 100644 index 0000000000..fe0d0f1a56 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/telemetry/index.test.ts @@ -0,0 +1,318 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getSurveyTelemetryCredentialAttachments, postSurveyTelemetryCredentialAttachment } from '.'; +import * as db from '../../../../../../../database/db'; +import { HTTPError } from '../../../../../../../errors/http-error'; +import { SurveyTelemetryCredentialAttachment } from '../../../../../../../repositories/attachment-repository'; +import { AttachmentService } from '../../../../../../../services/attachment-service'; +import { BctwKeyxService } from '../../../../../../../services/bctw-service/bctw-keyx-service'; +import * as file_utils from '../../../../../../../utils/file-utils'; +import { KeycloakUserInformation } from '../../../../../../../utils/keycloak-utils'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../__mocks__/db'; + +chai.use(sinonChai); + +describe('postSurveyTelemetryCredentialAttachment', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should throw an error when file has malicious content', async () => { + const dbConnectionObj = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + sinon.stub(file_utils, 'scanFileForVirus').resolves(false); // fail virus scan + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.keycloak_token = {} as KeycloakUserInformation; + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + mockReq.files = [ + { + fieldname: 'media', + originalname: 'test.keyx', + encoding: '7bit', + mimetype: 'text/plain', + size: 340 + } + ] as Express.Multer.File[]; + + const requestHandler = postSurveyTelemetryCredentialAttachment(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect((actualError as HTTPError).status).to.equal(400); + expect((actualError as HTTPError).message).to.equal('Malicious content detected, upload cancelled'); + } + }); + + it('should throw an error when file type is invalid', async () => { + const dbConnectionObj = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + sinon.stub(file_utils, 'scanFileForVirus').resolves(true); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.keycloak_token = {} as KeycloakUserInformation; + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + mockReq.files = [ + { + fieldname: 'media', + originalname: 'test.notValid', // not a supported file type + encoding: '7bit', + mimetype: 'text/plain', + size: 340 + } + ] as Express.Multer.File[]; + + const requestHandler = postSurveyTelemetryCredentialAttachment(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect((actualError as HTTPError).status).to.equal(400); + expect((actualError as HTTPError).message).to.equal( + 'The file is neither a .keyx or .cfg file, nor is it an archive containing only files of these types.' + ); + } + }); + + it('succeeds and uploads a KeyX file to BCTW', async () => { + const dbConnectionObj = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + sinon.stub(file_utils, 'scanFileForVirus').resolves(true); + + const upsertSurveyTelemetryCredentialAttachmentStub = sinon + .stub(AttachmentService.prototype, 'upsertSurveyTelemetryCredentialAttachment') + .resolves({ survey_telemetry_credential_attachment_id: 44, key: 'path/to/file/test.keyx' }); + + const uploadFileToS3Stub = sinon.stub(file_utils, 'uploadFileToS3').resolves(); + + const uploadKeyXStub = sinon.stub(BctwKeyxService.prototype, 'uploadKeyX').resolves(); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.keycloak_token = {} as KeycloakUserInformation; + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + mockReq.files = [ + { + fieldname: 'media', + originalname: 'test.keyx', + encoding: '7bit', + mimetype: 'text/plain', + size: 340 + } + ] as Express.Multer.File[]; + + const requestHandler = postSurveyTelemetryCredentialAttachment(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql({ survey_telemetry_credential_attachment_id: 44 }); + expect(upsertSurveyTelemetryCredentialAttachmentStub).to.be.calledOnce; + expect(uploadKeyXStub).to.be.calledOnce; + expect(uploadFileToS3Stub).to.be.calledOnce; + }); + + it('succeeds and does not upload a Cfg file to BCTW', async () => { + const dbConnectionObj = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + sinon.stub(file_utils, 'scanFileForVirus').resolves(true); + + const upsertSurveyTelemetryCredentialAttachmentStub = sinon + .stub(AttachmentService.prototype, 'upsertSurveyTelemetryCredentialAttachment') + .resolves({ survey_telemetry_credential_attachment_id: 44, key: 'path/to/file/test.keyx' }); + + const uploadFileToS3Stub = sinon.stub(file_utils, 'uploadFileToS3').resolves(); + + const uploadKeyXStub = sinon.stub(BctwKeyxService.prototype, 'uploadKeyX').resolves(); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.keycloak_token = {} as KeycloakUserInformation; + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + mockReq.files = [ + { + fieldname: 'media', + originalname: 'test.cfg', + encoding: '7bit', + mimetype: 'text/plain', + size: 340 + } + ] as Express.Multer.File[]; + + const requestHandler = postSurveyTelemetryCredentialAttachment(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql({ survey_telemetry_credential_attachment_id: 44 }); + expect(upsertSurveyTelemetryCredentialAttachmentStub).to.be.calledOnce; + expect(uploadKeyXStub).not.to.be.called; // not called + expect(uploadFileToS3Stub).to.be.calledOnce; + }); + + it('should catch and re-throw an error', async () => { + const dbConnectionObj = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + sinon.stub(file_utils, 'scanFileForVirus').resolves(true); + + const upsertSurveyTelemetryCredentialAttachmentStub = sinon + .stub(AttachmentService.prototype, 'upsertSurveyTelemetryCredentialAttachment') + .resolves({ survey_telemetry_credential_attachment_id: 44, key: 'path/to/file/test.keyx' }); + + const mockError = new Error('A test error'); + const uploadKeyXStub = sinon.stub(BctwKeyxService.prototype, 'uploadKeyX').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.keycloak_token = {} as KeycloakUserInformation; + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + mockReq.files = [ + { + fieldname: 'media', + originalname: 'test.keyx', + encoding: '7bit', + mimetype: 'text/plain', + size: 340 + } + ] as Express.Multer.File[]; + + const requestHandler = postSurveyTelemetryCredentialAttachment(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect((actualError as HTTPError).message).to.equal(mockError.message); + + expect(upsertSurveyTelemetryCredentialAttachmentStub).to.have.been.calledOnce; + expect(uploadKeyXStub).to.have.been.calledOnce; + } + }); +}); + +describe('getSurveyTelemetryCredentialAttachments', () => { + afterEach(() => { + sinon.restore(); + }); + + it('returns an array of telemetry credential file records', async () => { + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const mockGetCredentialAttachmentsResponse: SurveyTelemetryCredentialAttachment[] = [ + { + survey_telemetry_credential_attachment_id: 1, + uuid: '123', + file_name: 'test.keyx', + file_type: 'keyx', + file_size: 340, + create_date: '2021-09-01T00:00:00Z', + update_date: null, + key: 'path/to/file/test.keyx', + title: null, + description: null + }, + { + survey_telemetry_credential_attachment_id: 2, + uuid: '456', + file_name: 'test.cfg', + file_type: 'cfg', + file_size: 340, + create_date: '2021-09-01T00:00:00Z', + update_date: null, + key: 'path/to/file/test.cfg', + title: null, + description: null + } + ]; + + const getSurveyTelemetryCredentialAttachmentsStub = sinon + .stub(AttachmentService.prototype, 'getSurveyTelemetryCredentialAttachments') + .resolves(mockGetCredentialAttachmentsResponse); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.keycloak_token = {} as KeycloakUserInformation; + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + + const requestHandler = getSurveyTelemetryCredentialAttachments(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql({ telemetryAttachments: mockGetCredentialAttachmentsResponse }); + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(getSurveyTelemetryCredentialAttachmentsStub).to.have.been.calledOnceWith(2); + expect(mockDBConnection.commit).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('catches and re-throws error', async () => { + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const mockError = new Error('a test error'); + + const getSurveyTelemetryCredentialAttachmentsStub = sinon + .stub(AttachmentService.prototype, 'getSurveyTelemetryCredentialAttachments') + .rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.keycloak_token = {} as KeycloakUserInformation; + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + + const requestHandler = getSurveyTelemetryCredentialAttachments(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect((actualError as HTTPError).message).to.equal('a test error'); + + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(getSurveyTelemetryCredentialAttachmentsStub).to.have.been.calledOnceWith(2); + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + } + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/telemetry/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/telemetry/index.ts new file mode 100644 index 0000000000..f798f3dfab --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/telemetry/index.ts @@ -0,0 +1,374 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE } from '../../../../../../../constants/attachments'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../database/db'; +import { HTTP400 } from '../../../../../../../errors/http-error'; +import { fileSchema } from '../../../../../../../openapi/schemas/file'; +import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; +import { AttachmentService } from '../../../../../../../services/attachment-service'; +import { BctwKeyxService } from '../../../../../../../services/bctw-service/bctw-keyx-service'; +import { getBctwUser } from '../../../../../../../services/bctw-service/bctw-service'; +import { scanFileForVirus, uploadFileToS3 } from '../../../../../../../utils/file-utils'; +import { getLogger } from '../../../../../../../utils/logger'; +import { isValidTelementryCredentialFile } from '../../../../../../../utils/media/media-utils'; +import { getFileFromRequest } from '../../../../../../../utils/request'; + +const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/attachments/keyx/upload'); + +export const POST: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + postSurveyTelemetryCredentialAttachment() +]; + +POST.apiDoc = { + description: 'Upload a survey-specific telemetry device credential file.', + tags: ['attachment'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + } + ], + requestBody: { + description: 'Telemetry device credential file upload post request object.', + content: { + 'multipart/form-data': { + schema: { + type: 'object', + additionalProperties: false, + required: ['media'], + properties: { + media: { + description: 'Telemetry device credential file.', + type: 'array', + minItems: 1, + maxItems: 1, + items: fileSchema + } + } + } + } + } + }, + responses: { + 201: { + description: 'Telemetry device credential file upload response.', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['survey_telemetry_credential_attachment_id'], + properties: { + survey_telemetry_credential_attachment_id: { + type: 'integer', + minimum: 1 + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Handles the request to upload and import telemetry device credential files (ex: keyx files). + * + * @returns {RequestHandler} + */ +export function postSurveyTelemetryCredentialAttachment(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req.keycloak_token); + + try { + const rawMediaFile = getFileFromRequest(req); + + defaultLog.debug({ + label: 'postSurveyTelemetryCredentialAttachment', + message: 'files', + files: { ...rawMediaFile, buffer: 'Too big to print' } + }); + + // Scan file for viruses using ClamAV + const virusScanResult = await scanFileForVirus(rawMediaFile); + + if (!virusScanResult) { + throw new HTTP400('Malicious content detected, upload cancelled'); + } + + const isTelemetryCredentialFile = isValidTelementryCredentialFile(rawMediaFile); + + if (isTelemetryCredentialFile.error) { + throw new HTTP400(isTelemetryCredentialFile.error); + } + + await connection.open(); + + // Insert telemetry credential file record in SIMS + const attachmentService = new AttachmentService(connection); + const upsertResult = await attachmentService.upsertSurveyTelemetryCredentialAttachment( + rawMediaFile, + Number(req.params.projectId), + Number(req.params.surveyId), + isTelemetryCredentialFile.type + ); + + // Upload telemetry credential file content to BCTW (for supported file types) + if (isTelemetryCredentialFile.type === TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.KEYX) { + const bctwKeyxService = new BctwKeyxService(getBctwUser(req)); + await bctwKeyxService.uploadKeyX(rawMediaFile); + } + + // Upload telemetry credential file to SIMS S3 Storage + const metadata = { + filename: rawMediaFile.originalname, + username: req.keycloak_token?.preferred_username ?? '', + email: req.keycloak_token?.email ?? '' + }; + await uploadFileToS3(rawMediaFile, upsertResult.key, metadata); + + await connection.commit(); + + return res.status(201).json({ + survey_telemetry_credential_attachment_id: upsertResult.survey_telemetry_credential_attachment_id + }); + } catch (error) { + defaultLog.error({ label: 'postSurveyTelemetryCredentialAttachment', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getSurveyTelemetryCredentialAttachments() +]; + +GET.apiDoc = { + description: 'Fetches a list of telemetry attachments of a survey.', + tags: ['attachments'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + } + ], + responses: { + 200: { + description: 'Survey get response file description array.', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['telemetryAttachments'], + properties: { + telemetryAttachments: { + description: 'List of telemetry attachments.', + type: 'array', + items: { + description: 'Survey attachment data with supplementary data', + type: 'object', + additionalProperties: false, + required: [ + 'survey_telemetry_credential_attachment_id', + 'uuid', + 'file_name', + 'file_type', + 'file_size', + 'create_date', + 'update_date', + 'title', + 'description', + 'key' + ], + properties: { + survey_telemetry_credential_attachment_id: { + description: 'Attachment id', + type: 'integer', + minimum: 1 + }, + uuid: { + description: 'Attachment UUID', + type: 'string', + format: 'uuid' + }, + file_name: { + description: 'Attachment file name', + type: 'string' + }, + file_type: { + description: 'Attachment file type', + type: 'string' + }, + file_size: { + description: 'Attachment file size', + type: 'number' + }, + create_date: { + description: 'Attachment create date', + type: 'string' + }, + update_date: { + description: 'Attachment update date', + type: 'string', + nullable: true + }, + title: { + description: 'Attachment title', + type: 'string', + nullable: true + }, + description: { + description: 'Attachment description', + type: 'string', + nullable: true + }, + key: { + description: 'Attachment S3 key', + type: 'string' + } + } + } + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Fetches a list of telemetry device credential attachments for a survey. + * + * @export + * @return {*} {RequestHandler} + */ +export function getSurveyTelemetryCredentialAttachments(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'getSurveyTelemetryCredentialAttachments', message: 'params', req_params: req.params }); + + const connection = getDBConnection(req.keycloak_token); + const surveyId = Number(req.params.surveyId); + + try { + await connection.open(); + + const attachmentService = new AttachmentService(connection); + + const telemetryAttachments = await attachmentService.getSurveyTelemetryCredentialAttachments(surveyId); + + await connection.commit(); + + return res.status(200).json({ telemetryAttachments }); + } catch (error) { + defaultLog.error({ label: 'getSurveyTelemetryCredentialAttachments', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/getSignedUrl.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/getSignedUrl.ts index d28ae2ab70..5bd8c215a6 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/getSignedUrl.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/getSignedUrl.ts @@ -1,6 +1,6 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; -import { ATTACHMENT_TYPE } from '../../../../../../../constants/attachments'; +import { ATTACHMENT_TYPE, TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE } from '../../../../../../../constants/attachments'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../../database/db'; import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; @@ -74,7 +74,7 @@ GET.apiDoc = { name: 'attachmentType', schema: { type: 'string', - enum: ['Report', 'KeyX', 'Other'] + enum: ['Report', 'KeyX', 'Cfg', 'Other'] }, required: true } @@ -122,6 +122,7 @@ export function getSurveyAttachmentSignedURL(): RequestHandler { try { await connection.open(); + let s3Key; const attachmentService = new AttachmentService(connection); @@ -131,6 +132,14 @@ export function getSurveyAttachmentSignedURL(): RequestHandler { Number(req.params.surveyId), Number(req.params.attachmentId) ); + } else if ( + req.query.attachmentType === TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.KEYX || + req.query.attachmentType === TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.CFG + ) { + s3Key = await attachmentService.getSurveyTelemetryCredentialAttachmentS3Key( + Number(req.params.surveyId), + Number(req.params.attachmentId) + ); } else { s3Key = await attachmentService.getSurveyAttachmentS3Key( Number(req.params.surveyId), diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/captures/import.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/captures/import.ts new file mode 100644 index 0000000000..c03e54f881 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/captures/import.ts @@ -0,0 +1,161 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../database/db'; +import { HTTP400 } from '../../../../../../../errors/http-error'; +import { csvFileSchema } from '../../../../../../../openapi/schemas/file'; +import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; +import { ImportCapturesStrategy } from '../../../../../../../services/import-services/capture/import-captures-strategy'; +import { importCSV } from '../../../../../../../services/import-services/import-csv'; +import { scanFileForVirus } from '../../../../../../../utils/file-utils'; +import { getLogger } from '../../../../../../../utils/logger'; +import { parseMulterFile } from '../../../../../../../utils/media/media-utils'; +import { getFileFromRequest } from '../../../../../../../utils/request'; + +const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/captures/import'); + +export const POST: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + importCsv() +]; + +POST.apiDoc = { + description: 'Upload Critterbase CSV Captures file', + tags: ['observations'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + description: 'SIMS survey id', + name: 'projectId', + required: true, + schema: { + type: 'integer', + minimum: 1 + } + }, + { + in: 'path', + description: 'SIMS survey id', + name: 'surveyId', + required: true, + schema: { + type: 'integer', + minimum: 1 + } + } + ], + requestBody: { + description: 'Critterbase Captures CSV import file.', + content: { + 'multipart/form-data': { + schema: { + type: 'object', + additionalProperties: false, + required: ['media'], + properties: { + media: { + description: 'Critterbase Captures CSV import file.', + type: 'array', + minItems: 1, + maxItems: 1, + items: csvFileSchema + } + } + } + } + } + }, + responses: { + 201: { + description: 'Capture import success.', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + properties: { + capturesCreated: { + description: 'Number of Critterbase captures created.', + type: 'integer' + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Imports a `Critterbase Capture CSV` which bulk adds captures to Critterbase. + * + * @return {*} {RequestHandler} + */ +export function importCsv(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + const rawFile = getFileFromRequest(req); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + // Check for viruses / malware + const virusScanResult = await scanFileForVirus(rawFile); + + if (!virusScanResult) { + throw new HTTP400('Malicious content detected, import cancelled.'); + } + + const importCsvCaptures = new ImportCapturesStrategy(connection, surveyId); + + // Pass CSV file and importer as dependencies + const capturesCreated = await importCSV(parseMulterFile(rawFile), importCsvCaptures); + + await connection.commit(); + + return res.status(201).json({ capturesCreated }); + } catch (error) { + defaultLog.error({ label: 'importCritterCsv', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/import.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/import.test.ts index 2c3dc79b62..e9fe818e6f 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/import.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/import.test.ts @@ -2,13 +2,11 @@ import { expect } from 'chai'; import sinon from 'sinon'; import * as db from '../../../../../../database/db'; import { HTTP400 } from '../../../../../../errors/http-error'; -import { ImportCrittersService } from '../../../../../../services/import-services/import-critters-service'; -import { parseMulterFile } from '../../../../../../utils/media/media-utils'; +import * as strategy from '../../../../../../services/import-services/import-csv'; +import * as fileUtils from '../../../../../../utils/file-utils'; import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; import { importCsv } from './import'; -import * as fileUtils from '../../../../../../utils/file-utils'; - describe('importCsv', () => { afterEach(() => { sinon.restore(); @@ -16,8 +14,8 @@ describe('importCsv', () => { it('returns imported critters', async () => { const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub(), release: sinon.stub() }); - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockImportCsv = sinon.stub(ImportCrittersService.prototype, 'import').resolves([1, 2]); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockImportCSV = sinon.stub(strategy, 'importCSV').resolves([1, 2]); const mockFileScan = sinon.stub(fileUtils, 'scanFileForVirus').resolves(true); const mockFile = { originalname: 'test.csv', mimetype: 'test.csv', buffer: Buffer.alloc(1) } as Express.Multer.File; @@ -35,8 +33,10 @@ describe('importCsv', () => { expect(mockFileScan).to.have.been.calledOnceWithExactly(mockFile); - expect(mockGetDBConnection.calledOnce).to.be.true; - expect(mockImportCsv).to.have.been.calledOnceWithExactly(1, parseMulterFile(mockFile)); + expect(getDBConnectionStub).to.have.been.calledOnce; + + expect(mockImportCSV).to.have.been.calledOnce; + expect(mockRes.json).to.have.been.calledOnceWithExactly({ survey_critter_ids: [1, 2] }); expect(mockDBConnection.commit).to.have.been.calledOnce; @@ -50,8 +50,7 @@ describe('importCsv', () => { release: sinon.stub(), rollback: sinon.stub() }); - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockImportCsv = sinon.stub(ImportCrittersService.prototype, 'import').resolves([1, 2]); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); const mockFileScan = sinon.stub(fileUtils, 'scanFileForVirus').resolves(false); @@ -79,8 +78,7 @@ describe('importCsv', () => { expect(mockDBConnection.open).to.have.been.calledOnce; expect(mockFileScan).to.have.been.calledOnceWithExactly(mockFile); - expect(mockGetDBConnection.calledOnce).to.be.true; - expect(mockImportCsv).to.not.have.been.called; + expect(getDBConnectionStub).to.have.been.calledOnce; expect(mockRes.json).to.not.have.been.called; expect(mockDBConnection.rollback).to.have.been.called; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/import.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/import.ts index 7c87762d40..01a71c02aa 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/import.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/import.ts @@ -5,7 +5,8 @@ import { getDBConnection } from '../../../../../../database/db'; import { HTTP400 } from '../../../../../../errors/http-error'; import { csvFileSchema } from '../../../../../../openapi/schemas/file'; import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; -import { ImportCrittersService } from '../../../../../../services/import-services/import-critters-service'; +import { ImportCrittersStrategy } from '../../../../../../services/import-services/critter/import-critters-strategy'; +import { importCSV } from '../../../../../../services/import-services/import-csv'; import { scanFileForVirus } from '../../../../../../utils/file-utils'; import { getLogger } from '../../../../../../utils/logger'; import { parseMulterFile } from '../../../../../../utils/media/media-utils'; @@ -61,7 +62,7 @@ POST.apiDoc = { } ], requestBody: { - description: 'Survey critters submission file to import', + description: 'Survey critters csv file to import', content: { 'multipart/form-data': { schema: { @@ -129,7 +130,7 @@ POST.apiDoc = { export function importCsv(): RequestHandler { return async (req, res) => { const surveyId = Number(req.params.surveyId); - const rawMediaFile = getFileFromRequest(req); + const rawFile = getFileFromRequest(req); const connection = getDBConnection(req.keycloak_token); @@ -137,16 +138,16 @@ export function importCsv(): RequestHandler { await connection.open(); // Check for viruses / malware - const virusScanResult = await scanFileForVirus(rawMediaFile); + const virusScanResult = await scanFileForVirus(rawFile); if (!virusScanResult) { throw new HTTP400('Malicious content detected, import cancelled.'); } - const csvImporter = new ImportCrittersService(connection); + // Critter CSV import strategy - child of CSVImportStrategy + const importCsvCritters = new ImportCrittersStrategy(connection, surveyId); - // Pass the survey id and the csv (MediaFile) to the importer - const surveyCritterIds = await csvImporter.import(surveyId, parseMulterFile(rawMediaFile)); + const surveyCritterIds = await importCSV(parseMulterFile(rawFile), importCsvCritters); defaultLog.info({ label: 'importCritterCsv', message: 'result', survey_critter_ids: surveyCritterIds }); @@ -154,7 +155,7 @@ export function importCsv(): RequestHandler { return res.status(200).json({ survey_critter_ids: surveyCritterIds }); } catch (error) { - defaultLog.error({ label: 'uploadMedia', message: 'error', error }); + defaultLog.error({ label: 'importCritterCsv', message: 'error', error }); await connection.rollback(); throw error; } finally { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.test.ts index 28036c38b0..f02cac77bc 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.test.ts @@ -13,8 +13,13 @@ describe('getCrittersFromSurvey', () => { it('returns critters from survey', async () => { const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockSurveyCritter = { critter_id: 123, survey_id: 123, critterbase_critter_id: 'critterbase1' }; + const mockSurveyCritter = { + critter_id: 123, + survey_id: 123, + critterbase_critter_id: 'critterbase1' + }; const mockCBCritter = { critter_id: 'critterbase1', wlh_id: 'wlh1', @@ -25,7 +30,6 @@ describe('getCrittersFromSurvey', () => { critter_comment: 'comment1' }; - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); const mockGetCrittersInSurvey = sinon .stub(SurveyCritterService.prototype, 'getCrittersInSurvey') .resolves([mockSurveyCritter]); @@ -35,45 +39,142 @@ describe('getCrittersFromSurvey', () => { const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + const requestHandler = getCrittersFromSurvey(); await requestHandler(mockReq, mockRes, mockNext); - expect(mockGetDBConnection.calledOnce).to.be.true; - expect(mockGetCrittersInSurvey.calledOnce).to.be.true; + expect(getDBConnectionStub).to.have.been.calledOnce; + expect(mockGetCrittersInSurvey).to.have.been.calledOnce; expect(mockGetMultipleCrittersByIds).to.be.calledOnceWith([mockSurveyCritter.critterbase_critter_id]); expect(mockRes.json).to.have.been.calledWith([ - { ...mockCBCritter, survey_critter_id: mockSurveyCritter.critter_id } + { + // SIMS properties + critter_id: mockSurveyCritter.critter_id, + critterbase_critter_id: mockSurveyCritter.critterbase_critter_id, + // Critterbase properties + wlh_id: mockCBCritter.wlh_id, + animal_id: mockCBCritter.animal_id, + sex: mockCBCritter.sex, + itis_tsn: mockCBCritter.itis_tsn, + itis_scientific_name: mockCBCritter.itis_scientific_name, + critter_comment: mockCBCritter.critter_comment + } ]); }); it('returns empty array if no critters in survey', async () => { const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); const mockGetCrittersInSurvey = sinon.stub(SurveyCritterService.prototype, 'getCrittersInSurvey').resolves([]); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + const requestHandler = getCrittersFromSurvey(); await requestHandler(mockReq, mockRes, mockNext); - expect(mockGetDBConnection.calledOnce).to.be.true; - expect(mockGetCrittersInSurvey.calledOnce).to.be.true; + expect(getDBConnectionStub).to.have.been.calledOnce; + expect(mockGetCrittersInSurvey).to.have.been.calledOnce; + expect(mockRes.json).to.have.been.calledWith([]); + }); + + it('returns empty array if SIMS critter has no matching Critterbase critter record', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + + const mockSurveyCritter = { + critter_id: 123, + survey_id: 123, + critterbase_critter_id: 'critterbase1' + }; + const mockCBCritter = { + critter_id: 'critterbase_no_match', + wlh_id: 'wlh1', + animal_id: 'animal1', + sex: 'unknown', + itis_tsn: 12345, + itis_scientific_name: 'species1', + critter_comment: 'comment1' + }; + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockGetCrittersInSurvey = sinon + .stub(SurveyCritterService.prototype, 'getCrittersInSurvey') + .resolves([mockSurveyCritter]); + const mockGetMultipleCrittersByIds = sinon + .stub(CritterbaseService.prototype, 'getMultipleCrittersByIds') + .resolves([mockCBCritter] as unknown as ICritter[]); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + + const requestHandler = getCrittersFromSurvey(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetCrittersInSurvey).to.have.been.calledOnce; + expect(mockGetMultipleCrittersByIds).to.be.calledOnceWith([mockSurveyCritter.critterbase_critter_id]); + expect(mockRes.json).to.have.been.calledWith([]); + }); + + it('returns empty array if SIMS critter has no matching Critterbase critter record (Critterbase response empty)', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const mockSurveyCritter = { + critter_id: 123, + survey_id: 123, + critterbase_critter_id: 'critterbase1' + }; + + const mockGetCrittersInSurvey = sinon + .stub(SurveyCritterService.prototype, 'getCrittersInSurvey') + .resolves([mockSurveyCritter]); + const mockGetMultipleCrittersByIds = sinon + .stub(CritterbaseService.prototype, 'getMultipleCrittersByIds') + .resolves([]); // Empty response + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const requestHandler = getCrittersFromSurvey(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetCrittersInSurvey).to.have.been.calledOnce; + expect(mockGetMultipleCrittersByIds).to.be.calledOnceWith([mockSurveyCritter.critterbase_critter_id]); expect(mockRes.json).to.have.been.calledWith([]); }); it('catches and re-throws errors', async () => { const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); const mockError = new Error('a test error'); - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockGetCrittersInSurvey = sinon .stub(SurveyCritterService.prototype, 'getCrittersInSurvey') .rejects(mockError); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + const requestHandler = getCrittersFromSurvey(); try { @@ -81,8 +182,8 @@ describe('getCrittersFromSurvey', () => { expect.fail(); } catch (actualError) { expect(actualError).to.equal(mockError); - expect(mockGetCrittersInSurvey.calledOnce).to.be.true; - expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockGetCrittersInSurvey).to.have.been.calledOnce; + expect(getDBConnectionStub).to.have.been.calledOnce; expect(mockDBConnection.release).to.have.been.called; } }); @@ -95,67 +196,82 @@ describe('addCritterToSurvey', () => { it('does not create a new critter', async () => { const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockSurveyCritter = { survey_critter_id: 123, critterbase_critter_id: 'critterbase1' }; + const mockSurveyCritter = { critter_id: 123, critterbase_critter_id: 'critterbase1' }; const mockCBCritter = { critter_id: 'critterbase1' }; - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); const mockAddCritterToSurvey = sinon .stub(SurveyCritterService.prototype, 'addCritterToSurvey') - .resolves(mockSurveyCritter.survey_critter_id); + .resolves(mockSurveyCritter.critter_id); const mockCreateCritter = sinon.stub(CritterbaseService.prototype, 'createCritter').resolves(); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2' + }; mockReq.body = mockCBCritter; const requestHandler = addCritterToSurvey(); await requestHandler(mockReq, mockRes, mockNext); - expect(mockGetDBConnection.calledOnce).to.be.true; - expect(mockAddCritterToSurvey.calledOnce).to.be.true; - expect(mockCreateCritter.notCalled).to.be.true; + expect(getDBConnectionStub).to.have.been.calledOnce; + expect(mockAddCritterToSurvey).to.have.been.calledOnce; + expect(mockCreateCritter).not.to.have.been.called; expect(mockRes.status).to.have.been.calledWith(201); expect(mockRes.json).to.have.been.calledWith(mockSurveyCritter); }); it('returns critters from survey', async () => { const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockSurveyCritter = { survey_critter_id: 123, critterbase_critter_id: 'critterbase1' }; + const mockSurveyCritter = { critter_id: 123, critterbase_critter_id: 'critterbase1' }; const mockCBCritter = { critter_id: 'critterbase1' }; - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); const mockAddCritterToSurvey = sinon .stub(SurveyCritterService.prototype, 'addCritterToSurvey') - .resolves(mockSurveyCritter.survey_critter_id); + .resolves(mockSurveyCritter.critter_id); const mockCreateCritter = sinon.stub(CritterbaseService.prototype, 'createCritter').resolves(mockCBCritter); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + const requestHandler = addCritterToSurvey(); await requestHandler(mockReq, mockRes, mockNext); - expect(mockGetDBConnection.calledOnce).to.be.true; - expect(mockAddCritterToSurvey.calledOnce).to.be.true; - expect(mockCreateCritter.calledOnce).to.be.true; + expect(getDBConnectionStub).to.have.been.calledOnce; + expect(mockAddCritterToSurvey).to.have.been.calledOnce; + expect(mockCreateCritter).to.have.been.calledOnce; expect(mockRes.status).to.have.been.calledWith(201); expect(mockRes.json).to.have.been.calledWith(mockSurveyCritter); }); it('catches and re-throws errors', async () => { const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); const mockCBCritter = { critter_id: 'critterbase1' }; const mockError = new Error('a test error'); - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); const mockAddCritterToSurvey = sinon.stub(SurveyCritterService.prototype, 'addCritterToSurvey').rejects(mockError); const mockCreateCritter = sinon.stub(CritterbaseService.prototype, 'createCritter').resolves(mockCBCritter); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + const requestHandler = addCritterToSurvey(); try { @@ -163,9 +279,9 @@ describe('addCritterToSurvey', () => { expect.fail(); } catch (actualError) { expect(actualError).to.equal(mockError); - expect(mockAddCritterToSurvey.calledOnce).to.be.true; - expect(mockCreateCritter.calledOnce).to.be.true; - expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockAddCritterToSurvey).to.have.been.calledOnce; + expect(mockCreateCritter).to.have.been.calledOnce; + expect(getDBConnectionStub).to.have.been.calledOnce; expect(mockDBConnection.release).to.have.been.called; } }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.ts index 28260460a1..14ff420c2b 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.ts @@ -2,12 +2,14 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../database/db'; +import { critterSchema } from '../../../../../../openapi/schemas/critter'; import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; import { CritterbaseService, ICritterbaseUser } from '../../../../../../services/critterbase-service'; import { SurveyCritterService } from '../../../../../../services/survey-critter-service'; import { getLogger } from '../../../../../../utils/logger'; const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/critters'); + export const POST: Operation = [ authorizeRequestHandler((req) => { return { @@ -61,18 +63,19 @@ GET.apiDoc = { parameters: [ { in: 'path', - name: 'surveyId', + name: 'projectId', schema: { type: 'integer' }, required: true }, { - in: 'query', - name: 'format', + in: 'path', + name: 'surveyId', schema: { - type: 'string' - } + type: 'integer' + }, + required: true } ], responses: { @@ -81,12 +84,9 @@ GET.apiDoc = { content: { 'application/json': { schema: { - title: 'Bulk creation response object', + title: 'Array of critters in survey.', type: 'array', - items: { - title: 'Default critter response', - type: 'object' - } + items: critterSchema } } } @@ -111,19 +111,29 @@ GET.apiDoc = { POST.apiDoc = { description: - 'Creates a new critter in critterbase, and if successful, adds the a link to the critter_id under this survey.', - tags: ['critterbase'], + 'Creates a new critter in CritterBase, and if successful, adds a corresponding SIMS critter record under this survey.', + tags: ['survey', 'critterbase'], security: [ { Bearer: [] } ], parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, { in: 'path', name: 'surveyId', schema: { - type: 'number' + type: 'integer', + minimum: 1 }, required: true } @@ -170,11 +180,11 @@ POST.apiDoc = { title: 'Response object for adding critter to survey', type: 'object', properties: { - survey_critter_id: { + critter_id: { type: 'number', description: 'SIMS internal ID of the critter within the survey' }, - critter_id: { + critterbase_critter_id: { type: 'string', description: 'Critterbase ID of the critter' } @@ -201,6 +211,12 @@ POST.apiDoc = { } }; +/** + * Get all critters under this survey that have a corresponding Critterbase critter record. + * + * @export + * @return {*} {RequestHandler} + */ export function getCrittersFromSurvey(): RequestHandler { return async (req, res) => { const surveyId = Number(req.params.surveyId); @@ -219,21 +235,40 @@ export function getCrittersFromSurvey(): RequestHandler { const critterIds = surveyCritters.map((critter) => String(critter.critterbase_critter_id)); - const result = await surveyService.critterbaseService.getMultipleCrittersByIds(critterIds); + const critterbaseCritters = await surveyService.critterbaseService.getMultipleCrittersByIds(critterIds); - const critterMap = new Map(); - for (const item of result) { - critterMap.set(item.critter_id, item); - } + const response = []; + // For all SIMS critters for (const surveyCritter of surveyCritters) { - if (critterMap.has(surveyCritter.critterbase_critter_id)) { - critterMap.get(surveyCritter.critterbase_critter_id).survey_critter_id = surveyCritter.critter_id; + // Find the corresponding Critterbase critter + const critterbaseCritter = critterbaseCritters.find( + (critter) => critter.critter_id === surveyCritter.critterbase_critter_id + ); + + if (!critterbaseCritter) { + // SIMS critter exists but Critterbase critter does not. As Critterbase is the source of truth for critter + // data, we should not return this critter, which SIMS cannot properly represent. + continue; } + + response.push({ + // SIMS properties + critter_id: surveyCritter.critter_id, + critterbase_critter_id: surveyCritter.critterbase_critter_id, + // Critterbase properties + wlh_id: critterbaseCritter.wlh_id, + animal_id: critterbaseCritter.animal_id, + sex: critterbaseCritter.sex, + itis_tsn: critterbaseCritter.itis_tsn, + itis_scientific_name: critterbaseCritter.itis_scientific_name, + critter_comment: critterbaseCritter.critter_comment + }); } - return res.status(200).json([...critterMap.values()]); + + return res.status(200).json(response); } catch (error) { - defaultLog.error({ label: 'createCritter', message: 'error', error }); + defaultLog.error({ label: 'getCrittersFromSurvey', message: 'error', error }); await connection.rollback(); throw error; } finally { @@ -267,10 +302,10 @@ export function addCritterToSurvey(): RequestHandler { } const surveyService = new SurveyCritterService(connection); - const response = await surveyService.addCritterToSurvey(surveyId, critterId); + const surveyCritterId = await surveyService.addCritterToSurvey(surveyId, critterId); await connection.commit(); - return res.status(201).json({ critterbase_critter_id: critterId, survey_critter_id: response }); + return res.status(201).json({ critterbase_critter_id: critterId, critter_id: surveyCritterId }); } catch (error) { defaultLog.error({ label: 'addCritterToSurvey', message: 'error', error }); await connection.rollback(); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/markings/import.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/markings/import.ts new file mode 100644 index 0000000000..4b5126bc5b --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/markings/import.ts @@ -0,0 +1,161 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../database/db'; +import { HTTP400 } from '../../../../../../../errors/http-error'; +import { csvFileSchema } from '../../../../../../../openapi/schemas/file'; +import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; +import { importCSV } from '../../../../../../../services/import-services/import-csv'; +import { ImportMarkingsStrategy } from '../../../../../../../services/import-services/marking/import-markings-strategy'; +import { scanFileForVirus } from '../../../../../../../utils/file-utils'; +import { getLogger } from '../../../../../../../utils/logger'; +import { parseMulterFile } from '../../../../../../../utils/media/media-utils'; +import { getFileFromRequest } from '../../../../../../../utils/request'; + +const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/markings/import'); + +export const POST: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + importCsv() +]; + +POST.apiDoc = { + description: 'Upload Critterbase CSV Markings file', + tags: ['observations'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + description: 'SIMS survey id', + name: 'projectId', + required: true, + schema: { + type: 'integer', + minimum: 1 + } + }, + { + in: 'path', + description: 'SIMS survey id', + name: 'surveyId', + required: true, + schema: { + type: 'integer', + minimum: 1 + } + } + ], + requestBody: { + description: 'Critterbase Markings CSV import file.', + content: { + 'multipart/form-data': { + schema: { + type: 'object', + additionalProperties: false, + required: ['media'], + properties: { + media: { + description: 'Critterbase Markings CSV import file.', + type: 'array', + minItems: 1, + maxItems: 1, + items: csvFileSchema + } + } + } + } + } + }, + responses: { + 201: { + description: 'Marking import success.', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + properties: { + markingsCreated: { + description: 'Number of Critterbase markings created.', + type: 'integer' + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Imports a `Critterbase Marking CSV` which bulk adds markings to Critterbase. + * + * @return {*} {RequestHandler} + */ +export function importCsv(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + const rawFile = getFileFromRequest(req); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + // Check for viruses / malware + const virusScanResult = await scanFileForVirus(rawFile); + + if (!virusScanResult) { + throw new HTTP400('Malicious content detected, import cancelled.'); + } + + const importCsvMarkingsStrategy = new ImportMarkingsStrategy(connection, surveyId); + + // Pass CSV file and importer as dependencies + const markingsCreated = await importCSV(parseMulterFile(rawFile), importCsvMarkingsStrategy); + + await connection.commit(); + + return res.status(201).json({ markingsCreated }); + } catch (error) { + defaultLog.error({ label: 'importMarkingsCSV', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/measurements/import.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/measurements/import.ts new file mode 100644 index 0000000000..9988b6bb00 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/measurements/import.ts @@ -0,0 +1,161 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../database/db'; +import { HTTP400 } from '../../../../../../../errors/http-error'; +import { csvFileSchema } from '../../../../../../../openapi/schemas/file'; +import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; +import { importCSV } from '../../../../../../../services/import-services/import-csv'; +import { ImportMeasurementsStrategy } from '../../../../../../../services/import-services/measurement/import-measurements-strategy'; +import { scanFileForVirus } from '../../../../../../../utils/file-utils'; +import { getLogger } from '../../../../../../../utils/logger'; +import { parseMulterFile } from '../../../../../../../utils/media/media-utils'; +import { getFileFromRequest } from '../../../../../../../utils/request'; + +const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/measurements/import'); + +export const POST: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + importCsv() +]; + +POST.apiDoc = { + description: 'Upload Critterbase CSV Measurements file', + tags: ['observations'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + description: 'SIMS survey id', + name: 'projectId', + required: true, + schema: { + type: 'integer', + minimum: 1 + } + }, + { + in: 'path', + description: 'SIMS survey id', + name: 'surveyId', + required: true, + schema: { + type: 'integer', + minimum: 1 + } + } + ], + requestBody: { + description: 'Critterbase Measurements CSV import file.', + content: { + 'multipart/form-data': { + schema: { + type: 'object', + additionalProperties: false, + required: ['media'], + properties: { + media: { + description: 'Critterbase Measurements CSV import file.', + type: 'array', + minItems: 1, + maxItems: 1, + items: csvFileSchema + } + } + } + } + } + }, + responses: { + 201: { + description: 'Measurement import success.', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + properties: { + measurementsCreated: { + description: 'Number of Critterbase measurements created.', + type: 'integer' + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Imports a `Critterbase Measurement CSV` which bulk adds measurements to Critterbase. + * + * @return {*} {RequestHandler} + */ +export function importCsv(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + const rawFile = getFileFromRequest(req); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + // Check for viruses / malware + const virusScanResult = await scanFileForVirus(rawFile); + + if (!virusScanResult) { + throw new HTTP400('Malicious content detected, import cancelled.'); + } + + const importCsvMeasurementsStrategy = new ImportMeasurementsStrategy(connection, surveyId); + + // Pass CSV file and importer as dependencies + const measurementsCreated = await importCSV(parseMulterFile(rawFile), importCsvMeasurementsStrategy); + + await connection.commit(); + + return res.status(201).json({ measurementsCreated }); + } catch (error) { + defaultLog.error({ label: 'importMeasurementsCSV', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.ts deleted file mode 100644 index d4236aa486..0000000000 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { RequestHandler } from 'express'; -import { Operation } from 'express-openapi'; -import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; -import { getDBConnection } from '../../../../../../database/db'; -import { HTTPError, HTTPErrorType } from '../../../../../../errors/http-error'; -import { bulkUpdateResponse, critterBulkRequestObject } from '../../../../../../openapi/schemas/critter'; -import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; -import { CritterbaseService, ICritterbaseUser } from '../../../../../../services/critterbase-service'; -import { SurveyCritterService } from '../../../../../../services/survey-critter-service'; -import { getLogger } from '../../../../../../utils/logger'; - -const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/critters/{critterId}'); - -export const PATCH: Operation = [ - authorizeRequestHandler((req) => { - return { - or: [ - { - validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], - surveyId: Number(req.params.surveyId), - discriminator: 'ProjectPermission' - }, - { - validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], - discriminator: 'SystemRole' - } - ] - }; - }), - updateSurveyCritter() -]; - -PATCH.apiDoc = { - description: 'Patches a critter in critterbase, also capable of deleting relevant rows if marked with _delete.', - tags: ['critterbase'], - security: [ - { - Bearer: [] - } - ], - parameters: [ - { - in: 'path', - name: 'surveyId', - schema: { - type: 'number' - }, - required: true - } - ], - requestBody: { - description: 'Critterbase bulk patch request object', - content: { - 'application/json': { - schema: critterBulkRequestObject - } - } - }, - responses: { - 200: { - description: 'Responds with counts of objects created in critterbase.', - content: { - 'application/json': { - schema: bulkUpdateResponse - } - } - }, - 400: { - $ref: '#/components/responses/400' - }, - 401: { - $ref: '#/components/responses/401' - }, - 403: { - $ref: '#/components/responses/403' - }, - 500: { - $ref: '#/components/responses/500' - }, - default: { - $ref: '#/components/responses/default' - } - } -}; - -export function updateSurveyCritter(): RequestHandler { - return async (req, res) => { - const critterbaseCritterId = req.body.update.critter_id; - - const critterId = Number(req.params.critterId); - - const connection = getDBConnection(req.keycloak_token); - try { - const user: ICritterbaseUser = { - keycloak_guid: connection.systemUserGUID(), - username: connection.systemUserIdentifier() - }; - - if (!critterbaseCritterId) { - throw new HTTPError(HTTPErrorType.BAD_REQUEST, 400, 'No external critter ID was found.'); - } - - await connection.open(); - - const surveyService = new SurveyCritterService(connection); - await surveyService.updateCritter(critterId, critterbaseCritterId); - - const critterbaseService = new CritterbaseService(user); - - let createResult, updateResult; - - if (req.body.update) { - updateResult = await critterbaseService.updateCritter(req.body.update); - } - - if (req.body.create) { - createResult = await critterbaseService.createCritter(req.body.create); - } - - await connection.commit(); - - return res.status(200).json({ ...createResult, ...updateResult }); - } catch (error) { - defaultLog.error({ label: 'updateSurveyCritter', message: 'error', error }); - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - }; -} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/index.test.ts index 564c94c488..924fc3abd5 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/index.test.ts @@ -1,100 +1,102 @@ -import Ajv from 'ajv'; import { expect } from 'chai'; import sinon from 'sinon'; +import { createDeployment } from '.'; import * as db from '../../../../../../../../database/db'; -import { BctwService } from '../../../../../../../../services/bctw-service'; -import { SurveyCritterService } from '../../../../../../../../services/survey-critter-service'; +import { + BctwDeploymentRecord, + BctwDeploymentService +} from '../../../../../../../../services/bctw-service/bctw-deployment-service'; +import { BctwService } from '../../../../../../../../services/bctw-service/bctw-service'; +import { CritterbaseService, ICapture } from '../../../../../../../../services/critterbase-service'; +import { DeploymentService } from '../../../../../../../../services/deployment-service'; import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../../__mocks__/db'; -import { deployDevice, PATCH, POST, updateDeployment } from './index'; -describe('critter deployments', () => { +describe('createDeployment', () => { afterEach(() => { sinon.restore(); }); - const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); - - describe('openapi schema', () => { - const ajv = new Ajv(); - - it('is valid openapi v3 schema', () => { - expect(ajv.validateSchema(POST.apiDoc as unknown as object)).to.be.true; - expect(ajv.validateSchema(PATCH.apiDoc as unknown as object)).to.be.true; - }); - }); - - describe('upsertDeployment', () => { - it('updates an existing deployment', async () => { - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockAddDeployment = sinon.stub(SurveyCritterService.prototype, 'upsertDeployment').resolves(); - const mockBctwService = sinon.stub(BctwService.prototype, 'updateDeployment'); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - const requestHandler = updateDeployment(); - await requestHandler(mockReq, mockRes, mockNext); - - expect(mockGetDBConnection.calledOnce).to.be.true; - expect(mockAddDeployment.calledOnce).to.be.true; - expect(mockBctwService.calledOnce).to.be.true; - expect(mockRes.status).to.have.been.calledWith(200); - }); - - it('catches and re-throws errors', async () => { - const mockError = new Error('a test error'); - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockAddDeployment = sinon.stub(SurveyCritterService.prototype, 'upsertDeployment').rejects(mockError); - const mockBctwService = sinon.stub(BctwService.prototype, 'updateDeployment').rejects(mockError); + it('creates a new deployment', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const mockCapture: ICapture = { + capture_id: '111', + critter_id: '222', + capture_method_id: null, + capture_location_id: '333', + release_location_id: null, + capture_date: '2021-01-01', + capture_time: '12:00:00', + release_date: null, + release_time: null, + capture_comment: null, + release_comment: null + }; + + const mockDeployment: BctwDeploymentRecord = { + assignment_id: '111', + collar_id: '222', + critter_id: '333', + created_at: '2021-01-01', + created_by_user_id: '444', + updated_at: '2021-01-01', + updated_by_user_id: '555', + valid_from: '2021-01-01', + valid_to: '2021-01-01', + attachment_start: '2021-01-01', + attachment_end: '2021-01-01', + deployment_id: '666', + device_id: 777 + }; + + const getCodeStub = sinon.stub(BctwService.prototype, 'getCode').resolves([ + { + code_header_title: 'device_make', + code_header_name: 'Device Make', + id: 1, + code: 'device_make_code', + description: '', + long_description: '' + } + ]); + const insertDeploymentStub = sinon.stub(DeploymentService.prototype, 'insertDeployment').resolves(); + const createDeploymentStub = sinon + .stub(BctwDeploymentService.prototype, 'createDeployment') + .resolves(mockDeployment); + const getCaptureByIdStub = sinon.stub(CritterbaseService.prototype, 'getCaptureById').resolves(mockCapture); - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - const requestHandler = updateDeployment(); - try { - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect(actualError).to.equal(mockError); - expect(mockGetDBConnection.calledOnce).to.be.true; - expect(mockAddDeployment.calledOnce).to.be.true; - expect(mockBctwService.notCalled).to.be.true; - } - }); - describe('deployDevice', () => { - it('deploys a new telemetry device', async () => { - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockAddDeployment = sinon.stub(SurveyCritterService.prototype, 'upsertDeployment').resolves(); - const mockBctwService = sinon.stub(BctwService.prototype, 'deployDevice'); + const requestHandler = createDeployment(); - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + await requestHandler(mockReq, mockRes, mockNext); - const requestHandler = deployDevice(); - await requestHandler(mockReq, mockRes, mockNext); + expect(getDBConnectionStub).to.have.been.calledOnce; + expect(getCodeStub).to.have.been.calledTwice; + expect(insertDeploymentStub).to.have.been.calledOnce; + expect(createDeploymentStub).to.have.been.calledOnce; + expect(getCaptureByIdStub).to.have.been.calledOnce; + expect(mockRes.status).to.have.been.calledWith(201); + }); - expect(mockGetDBConnection.calledOnce).to.be.true; - expect(mockAddDeployment.calledOnce).to.be.true; - expect(mockBctwService.calledOnce).to.be.true; - expect(mockRes.status).to.have.been.calledWith(201); - }); + it('catches and re-throws errors', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - it('catches and re-throws errors', async () => { - const mockError = new Error('a test error'); - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockAddDeployment = sinon.stub(SurveyCritterService.prototype, 'upsertDeployment').rejects(mockError); - const mockBctwService = sinon.stub(BctwService.prototype, 'deployDevice').rejects(mockError); + const mockError = new Error('a test error'); + const insertDeploymentStub = sinon.stub(DeploymentService.prototype, 'insertDeployment').rejects(mockError); - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - const requestHandler = deployDevice(); - try { - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect(actualError).to.equal(mockError); - expect(mockGetDBConnection.calledOnce).to.be.true; - expect(mockAddDeployment.calledOnce).to.be.true; - expect(mockBctwService.notCalled).to.be.true; - } - }); - }); + const requestHandler = createDeployment(); + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(getDBConnectionStub).to.have.been.calledOnce; + expect(insertDeploymentStub).to.have.been.calledOnce; + } }); }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/index.ts index 41c06c9629..9b8b30af5f 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/index.ts @@ -1,14 +1,18 @@ +import dayjs from 'dayjs'; import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { v4 } from 'uuid'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../../../database/db'; import { authorizeRequestHandler } from '../../../../../../../../request-handlers/security/authorization'; -import { BctwService } from '../../../../../../../../services/bctw-service'; -import { ICritterbaseUser } from '../../../../../../../../services/critterbase-service'; -import { SurveyCritterService } from '../../../../../../../../services/survey-critter-service'; +import { BctwDeploymentService } from '../../../../../../../../services/bctw-service/bctw-deployment-service'; +import { BctwService, getBctwUser } from '../../../../../../../../services/bctw-service/bctw-service'; +import { CritterbaseService } from '../../../../../../../../services/critterbase-service'; +import { DeploymentService } from '../../../../../../../../services/deployment-service'; import { getLogger } from '../../../../../../../../utils/logger'; + const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments'); + export const POST: Operation = [ authorizeRequestHandler((req) => { return { @@ -25,13 +29,13 @@ export const POST: Operation = [ ] }; }), - deployDevice() + createDeployment() ]; POST.apiDoc = { description: - 'Deploys a device, creating a record of the insertion in the SIMS deployment table. Will also upsert a collar in BCTW as well as insert a new deployment under the resultant collar_id.', - tags: ['critterbase'], + 'Creates a deployment in SIMS and BCTW. Upserts a collar in BCTW and inserts a new deployment of the resulting collar_id.', + tags: ['deployment', 'bctw', 'critterbase'], security: [ { Bearer: [] @@ -42,156 +46,87 @@ POST.apiDoc = { in: 'path', name: 'surveyId', schema: { - type: 'number' - }, - required: true + type: 'integer', + minimum: 1 + } }, { in: 'path', name: 'critterId', schema: { - type: 'number' + type: 'integer', + minimum: 1 } } ], requestBody: { - description: 'Specifies a critter, device, and timespan to complete deployment.', + description: 'Object with device information and associated captures to create a deployment', content: { 'application/json': { schema: { title: 'Deploy device request object', type: 'object', additionalProperties: false, + required: [ + 'device_id', + 'frequency', + 'frequency_unit', + 'device_make', + 'device_model', + 'critterbase_start_capture_id', + 'critterbase_end_capture_id', + 'critterbase_end_mortality_id', + 'attachment_end_date', + 'attachment_end_time' + ], properties: { - critter_id: { - type: 'string', - format: 'uuid' - }, - attachment_start: { - type: 'string' - }, - attachment_end: { - type: 'string' - }, device_id: { - type: 'integer' + type: 'integer', + minimum: 1 }, frequency: { - type: 'number' + type: 'number', + nullable: true }, frequency_unit: { - type: 'string' + type: 'number', + nullable: true, + description: 'The ID of a BCTW frequency code.' }, device_make: { - type: 'string' + type: 'number', + description: 'The ID of a BCTW device make code.' }, device_model: { - type: 'string' - } - } - } - } - } - }, - responses: { - 201: { - description: 'Responds with count of rows created in SIMS DB Deployments.', - content: { - 'application/json': { - schema: { - title: 'Deployment response object', - type: 'object', - additionalProperties: false, - properties: { - message: { - type: 'string' - } - } - } - } - } - }, - 400: { - $ref: '#/components/responses/400' - }, - 401: { - $ref: '#/components/responses/401' - }, - 403: { - $ref: '#/components/responses/403' - }, - 500: { - $ref: '#/components/responses/500' - }, - default: { - $ref: '#/components/responses/default' - } - } -}; - -export const PATCH: Operation = [ - authorizeRequestHandler((req) => { - return { - or: [ - { - validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], - surveyId: Number(req.params.surveyId), - discriminator: 'ProjectPermission' - }, - { - validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], - discriminator: 'SystemRole' - } - ] - }; - }), - updateDeployment() -]; - -PATCH.apiDoc = { - description: - 'Allows you to update the deployment timespan for a device. Effectively ends a deployment if the attachment end is filled in, but should not delete anything.', - tags: ['critterbase'], - security: [ - { - Bearer: [] - } - ], - parameters: [ - { - in: 'path', - name: 'surveyId', - schema: { - type: 'integer' - }, - required: true - }, - { - in: 'path', - name: 'critterId', - schema: { - type: 'integer' - } - } - ], - requestBody: { - description: 'Specifies a deployment id and the new timerange to update it with.', - content: { - 'application/json': { - schema: { - title: 'Deploy device request object', - type: 'object', - additionalProperties: false, - properties: { - deployment_id: { type: 'string', - format: 'uuid' + nullable: true + }, + critterbase_start_capture_id: { + type: 'string', + description: 'Critterbase capture record when the deployment started', + format: 'uuid', + nullable: true + }, + critterbase_end_capture_id: { + type: 'string', + description: 'Critterbase capture record when the deployment ended', + format: 'uuid', + nullable: true }, - attachment_start: { - type: 'string' + critterbase_end_mortality_id: { + type: 'string', + description: 'Critterbase mortality record when the deployment ended', + format: 'uuid', + nullable: true + }, + attachment_end_date: { + type: 'string', + description: 'End date of the deployment, without time.', + nullable: true }, - attachment_end: { + attachment_end_time: { type: 'string', + description: 'End time of the deployment.', nullable: true } } @@ -200,8 +135,8 @@ PATCH.apiDoc = { } }, responses: { - 200: { - description: 'Responds with count of rows created or updated in SIMS DB Deployments.', + 201: { + description: 'Responds with the created BCTW deployment uuid.', content: { 'application/json': { schema: { @@ -209,8 +144,10 @@ PATCH.apiDoc = { type: 'object', additionalProperties: false, properties: { - message: { - type: 'string' + deploymentId: { + type: 'string', + format: 'uuid', + description: 'The generated deployment Id, indicating that the deployment was succesfully created.' } } } @@ -235,68 +172,84 @@ PATCH.apiDoc = { } }; -export function deployDevice(): RequestHandler { +export function createDeployment(): RequestHandler { return async (req, res) => { - const critterId = Number(req.params.critterId); - const newDeploymentId = v4(); // New deployment ID - const newDeploymentDevice = { - ...req.body, - deploymentId: newDeploymentId - }; + const surveyCritterId = Number(req.params.critterId); + + // Create deployment Id for joining SIMS and BCTW deployment information + const newDeploymentId = v4(); + + const { + device_id, + frequency, + frequency_unit, + device_make, + device_model, + critterbase_start_capture_id, + critterbase_end_capture_id, + critterbase_end_mortality_id, + attachment_end_date, + attachment_end_time + } = req.body; const connection = getDBConnection(req.keycloak_token); try { await connection.open(); - const user: ICritterbaseUser = { - keycloak_guid: connection.systemUserGUID(), - username: connection.systemUserIdentifier() - }; - - const surveyCritterService = new SurveyCritterService(connection); - await surveyCritterService.upsertDeployment(critterId, newDeploymentId); + const user = getBctwUser(req); const bctwService = new BctwService(user); - await bctwService.deployDevice(newDeploymentDevice); + const bctwDeploymentService = new BctwDeploymentService(user); + const deploymentService = new DeploymentService(connection); + const critterbaseService = new CritterbaseService(user); - await connection.commit(); - return res.status(201).json({ message: 'Deployment created.' }); - } catch (error) { - defaultLog.error({ label: 'addDeployment', message: 'error', error }); - await connection.rollback(); + await deploymentService.insertDeployment({ + critter_id: surveyCritterId, + bctw_deployment_id: newDeploymentId, + critterbase_start_capture_id, + critterbase_end_capture_id, + critterbase_end_mortality_id + }); - throw error; - } finally { - connection.release(); - } - }; -} + // Retrieve the capture to get the capture date for BCTW + const critterbaseCritter = await critterbaseService.getCaptureById(critterbase_start_capture_id); -export function updateDeployment(): RequestHandler { - return async (req, res) => { - const critterId = Number(req.params.critterId); + // Create attachment end date from provided end date (if not null) and end time (if not null). + const attachmentEnd = attachment_end_date + ? attachment_end_time + ? dayjs(`${attachment_end_date} ${attachment_end_time}`).toISOString() + : dayjs(`${attachment_end_date}`).toISOString() + : null; - const connection = getDBConnection(req.keycloak_token); - - try { - await connection.open(); + // Get BCTW code values + const [deviceMakeCodes, frequencyUnitCodes] = await Promise.all([ + bctwService.getCode('device_make'), + bctwService.getCode('frequency_unit') + ]); + // The BCTW API expects the device make and frequency unit as codes, not IDs. + const device_make_code = deviceMakeCodes.find((code) => code.id === device_make)?.code; + const frequency_unit_code = frequencyUnitCodes.find((code) => code.id === frequency_unit)?.code; - const user: ICritterbaseUser = { - keycloak_guid: connection.systemUserGUID(), - username: connection.systemUserIdentifier() - }; + const deployment = await bctwDeploymentService.createDeployment({ + deployment_id: newDeploymentId, + device_id: device_id, + critter_id: critterbaseCritter.critter_id, + frequency: frequency, + frequency_unit: frequency_unit_code, + device_make: device_make_code, + device_model: device_model, + attachment_start: critterbaseCritter.capture_date, + attachment_end: attachmentEnd // TODO: ADD SEPARATE DATE AND TIME TO BCTW + }); - const surveyCritterService = new SurveyCritterService(connection); - const bctw = new BctwService(user); - - await surveyCritterService.upsertDeployment(critterId, req.body.deployment_id); - await bctw.updateDeployment(req.body); await connection.commit(); - return res.status(200).json({ message: 'Deployment updated.' }); + + return res.status(201).json({ deploymentId: deployment.deployment_id }); } catch (error) { - defaultLog.error({ label: 'updateDeployment', message: 'error', error }); + defaultLog.error({ label: 'createDeployment', message: 'error', error }); await connection.rollback(); + throw error; } finally { connection.release(); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{bctwDeploymentId}.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{bctwDeploymentId}.test.ts deleted file mode 100644 index 33aa5990ca..0000000000 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{bctwDeploymentId}.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import Ajv from 'ajv'; -import { expect } from 'chai'; -import sinon from 'sinon'; -import * as db from '../../../../../../../../database/db'; -import { BctwService } from '../../../../../../../../services/bctw-service'; -import { SurveyCritterService } from '../../../../../../../../services/survey-critter-service'; -import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../../__mocks__/db'; -import { DELETE, deleteDeployment } from './{bctwDeploymentId}'; - -describe('critter deployments', () => { - afterEach(() => { - sinon.restore(); - }); - - const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); - - describe('openapi schema', () => { - const ajv = new Ajv(); - - it('is valid openapi v3 schema', () => { - expect(ajv.validateSchema(DELETE.apiDoc as unknown as object)).to.be.true; - }); - }); - - describe('deleteDeployment', () => { - it('deletes an existing deployment', async () => { - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockRemoveDeployment = sinon.stub(SurveyCritterService.prototype, 'removeDeployment').resolves(); - const mockBctwService = sinon.stub(BctwService.prototype, 'deleteDeployment'); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - const requestHandler = deleteDeployment(); - await requestHandler(mockReq, mockRes, mockNext); - - expect(mockGetDBConnection.calledOnce).to.be.true; - expect(mockRemoveDeployment.calledOnce).to.be.true; - expect(mockBctwService.calledOnce).to.be.true; - expect(mockRes.status).to.have.been.calledWith(200); - }); - }); -}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{bctwDeploymentId}.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{bctwDeploymentId}.ts deleted file mode 100644 index 1538e5e5fd..0000000000 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{bctwDeploymentId}.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { AxiosError } from 'axios'; -import { RequestHandler } from 'express'; -import { Operation } from 'express-openapi'; -import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../../constants/roles'; -import { getDBConnection } from '../../../../../../../../database/db'; -import { authorizeRequestHandler } from '../../../../../../../../request-handlers/security/authorization'; -import { BctwService } from '../../../../../../../../services/bctw-service'; -import { ICritterbaseUser } from '../../../../../../../../services/critterbase-service'; -import { SurveyCritterService } from '../../../../../../../../services/survey-critter-service'; -import { getLogger } from '../../../../../../../../utils/logger'; - -const defaultLog = getLogger( - 'paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{bctwDeploymentId}' -); -export const DELETE: Operation = [ - authorizeRequestHandler((req) => { - return { - or: [ - { - validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], - surveyId: Number(req.params.surveyId), - discriminator: 'ProjectPermission' - }, - { - validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], - discriminator: 'SystemRole' - } - ] - }; - }), - deleteDeployment() -]; - -DELETE.apiDoc = { - description: 'Deletes the deployment record in SIMS, and soft deletes the record in BCTW.', - tags: ['bctw'], - security: [ - { - Bearer: [] - } - ], - parameters: [ - { - in: 'path', - name: 'surveyId', - schema: { - type: 'number' - }, - required: true - }, - { - in: 'path', - name: 'critterId', - schema: { - type: 'number' - }, - required: true - }, - { - in: 'path', - name: 'bctwDeploymentId', - schema: { - type: 'string', - format: 'uuid' - }, - required: true - } - ], - responses: { - 200: { - description: 'Removed deployment successfully.', - content: { - 'application/json': { - schema: { - title: 'Deployment response object', - type: 'object', - additionalProperties: false, - properties: { - message: { - type: 'string' - } - } - } - } - } - }, - 400: { - $ref: '#/components/responses/400' - }, - 401: { - $ref: '#/components/responses/401' - }, - 403: { - $ref: '#/components/responses/403' - }, - 500: { - $ref: '#/components/responses/500' - }, - default: { - $ref: '#/components/responses/default' - } - } -}; - -export function deleteDeployment(): RequestHandler { - return async (req, res) => { - const deploymentId = String(req.params.bctwDeploymentId); - const critterId = Number(req.params.critterId); - - const connection = getDBConnection(req.keycloak_token); - - try { - await connection.open(); - - const user: ICritterbaseUser = { - keycloak_guid: connection.systemUserGUID(), - username: connection.systemUserIdentifier() - }; - - const surveyCritterService = new SurveyCritterService(connection); - await surveyCritterService.removeDeployment(critterId, deploymentId); - - const bctwService = new BctwService(user); - await bctwService.deleteDeployment(deploymentId); - - await connection.commit(); - return res.status(200).json({ message: 'Deployment deleted.' }); - } catch (error) { - defaultLog.error({ label: 'deleteDeployment', message: 'error', error }); - await connection.rollback(); - - return res.status(500).json((error as AxiosError).response); - } finally { - connection.release(); - } - }; -} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{deploymentId}/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{deploymentId}/index.test.ts new file mode 100644 index 0000000000..7ec000876d --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{deploymentId}/index.test.ts @@ -0,0 +1,79 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { patchDeployment } from '.'; +import * as db from '../../../../../../../../../database/db'; +import { BctwDeploymentService } from '../../../../../../../../../services/bctw-service/bctw-deployment-service'; +import { CritterbaseService, ICapture } from '../../../../../../../../../services/critterbase-service'; +import { DeploymentService } from '../../../../../../../../../services/deployment-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../../../__mocks__/db'; + +describe('patchDeployment', () => { + afterEach(() => { + sinon.restore(); + }); + + it('updates an existing deployment', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const mockCapture: ICapture = { + capture_id: '111', + critter_id: '222', + capture_method_id: null, + capture_location_id: '333', + release_location_id: null, + capture_date: '2021-01-01', + capture_time: '12:00:00', + release_date: null, + release_time: null, + capture_comment: null, + release_comment: null + }; + + const updateDeploymentStub = sinon.stub(DeploymentService.prototype, 'updateDeployment').resolves(); + const updateBctwDeploymentStub = sinon.stub(BctwDeploymentService.prototype, 'updateDeployment'); + const getCaptureByIdStub = sinon.stub(CritterbaseService.prototype, 'getCaptureById').resolves(mockCapture); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.body = { + deployment_id: '111', + bctw_deployment_id: '222', + critterbase_start_capture_id: '333', + critterbase_end_capture_id: '444', + critterbase_end_mortality_id: '555', + attachment_end_date: '2021-01-01', + attachment_end_time: '12:00:00' + }; + + const requestHandler = patchDeployment(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getDBConnectionStub).to.have.been.calledOnce; + expect(updateDeploymentStub).to.have.been.calledOnce; + expect(updateBctwDeploymentStub).to.have.been.calledOnce; + expect(getCaptureByIdStub).to.have.been.calledOnce; + expect(mockRes.status).to.have.been.calledWith(200); + }); + + it('catches and re-throws errors', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const mockError = new Error('a test error'); + const updateDeploymentStub = sinon.stub(DeploymentService.prototype, 'updateDeployment').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const requestHandler = patchDeployment(); + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(getDBConnectionStub).to.have.been.calledOnce; + expect(updateDeploymentStub).to.have.been.calledOnce; + } + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{deploymentId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{deploymentId}/index.ts new file mode 100644 index 0000000000..01afb217ee --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{deploymentId}/index.ts @@ -0,0 +1,244 @@ +import dayjs from 'dayjs'; +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../../../database/db'; +import { authorizeRequestHandler } from '../../../../../../../../../request-handlers/security/authorization'; +import { BctwDeploymentService } from '../../../../../../../../../services/bctw-service/bctw-deployment-service'; +import { CritterbaseService, ICritterbaseUser } from '../../../../../../../../../services/critterbase-service'; +import { DeploymentService } from '../../../../../../../../../services/deployment-service'; +import { getLogger } from '../../../../../../../../../utils/logger'; + +const defaultLog = getLogger( + 'paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{bctwDeploymentId}' +); + +export const PATCH: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + patchDeployment() +]; + +PATCH.apiDoc = { + description: 'Updates information about the start and end of a deployment.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'critterId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'deploymentId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + } + ], + requestBody: { + description: 'Specifies a deployment id and the new timerange to update it with.', + content: { + 'application/json': { + schema: { + title: 'Deploy device request object', + type: 'object', + additionalProperties: false, + required: [ + 'bctw_deployment_id', + 'critterbase_start_capture_id', + 'critterbase_end_capture_id', + 'critterbase_end_mortality_id', + 'attachment_end_date', + 'attachment_end_time' + ], + properties: { + deployment_id: { + type: 'integer', + description: 'Id of the deployment in SIMS', + minimum: 1 + }, + bctw_deployment_id: { + type: 'string', + description: 'Id of the deployment in BCTW', + format: 'uuid' + }, + critterbase_start_capture_id: { + type: 'string', + description: 'Critterbase capture record for when the deployment start', + format: 'uuid', + nullable: true + }, + critterbase_end_capture_id: { + type: 'string', + description: 'Critterbase capture record for when the deployment ended', + format: 'uuid', + nullable: true + }, + critterbase_end_mortality_id: { + type: 'string', + description: 'Critterbase mortality record for when the deployment ended', + format: 'uuid', + nullable: true + }, + attachment_end_date: { + type: 'string', + description: 'End date of the deployment, without time.', + example: '2021-01-01', + pattern: '^[0-9]{4}-[0-9]{2}-[0-9]{2}$', + nullable: true + }, + attachment_end_time: { + type: 'string', + description: 'End time of the deployment.', + example: '12:00:00', + pattern: '^[0-9]{2}:[0-9]{2}:[0-9]{2}$', + nullable: true + } + } + } + } + } + }, + responses: { + 200: { + description: 'Responds with count of rows created or updated in SIMS DB Deployments.', + content: { + 'application/json': { + schema: { + title: 'Deployment response object', + type: 'object', + additionalProperties: false, + properties: { + message: { + type: 'string' + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function patchDeployment(): RequestHandler { + return async (req, res) => { + const critterId = Number(req.params.critterId); + const deploymentId = Number(req.params.deploymentId); + + const connection = getDBConnection(req.keycloak_token); + + const { + bctw_deployment_id, + critterbase_start_capture_id, + critterbase_end_capture_id, + critterbase_end_mortality_id, + attachment_end_date, + attachment_end_time + } = req.body; + + try { + await connection.open(); + + const user: ICritterbaseUser = { + keycloak_guid: connection.systemUserGUID(), + username: connection.systemUserIdentifier() + }; + + const bctwDeploymentService = new BctwDeploymentService(user); + const deploymentService = new DeploymentService(connection); + const critterbaseService = new CritterbaseService(user); + + await deploymentService.updateDeployment({ + deployment_id: deploymentId, + critter_id: critterId, + critterbase_start_capture_id, + critterbase_end_capture_id, + critterbase_end_mortality_id + }); + + const capture = await critterbaseService.getCaptureById(critterbase_start_capture_id); + + // Create attachment end date from provided end date (if not null) and end time (if not null). + const attachmentEnd = attachment_end_date + ? attachment_end_time + ? dayjs(`${attachment_end_date} ${attachment_end_time}`).toISOString() + : dayjs(`${attachment_end_date}`).toISOString() + : null; + + // Update the deployment in BCTW, which works by soft deleting and inserting a new deployment record + await bctwDeploymentService.updateDeployment({ + deployment_id: bctw_deployment_id, + attachment_start: capture.capture_date, + attachment_end: attachmentEnd // TODO: ADD SEPARATE DATE AND TIME TO BCTW + }); + + await connection.commit(); + + return res.status(200).send(); + } catch (error) { + defaultLog.error({ label: 'patchDeployment', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.test.ts similarity index 63% rename from api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.test.ts rename to api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.test.ts index ea88afd0d4..f25de6819c 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.test.ts @@ -1,31 +1,23 @@ -import Ajv from 'ajv'; import { expect } from 'chai'; import sinon from 'sinon'; -import * as db from '../../../../../../database/db'; -import { HTTPError } from '../../../../../../errors/http-error'; -import { CritterbaseService } from '../../../../../../services/critterbase-service'; -import { SurveyCritterService } from '../../../../../../services/survey-critter-service'; -import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; -import { PATCH, updateSurveyCritter } from './{critterId}'; - -describe('critterId openapi schema', () => { - const ajv = new Ajv(); - - it('PATCH is valid openapi v3 schema', () => { - expect(ajv.validateSchema(PATCH.apiDoc as unknown as object)).to.be.true; - }); -}); +import { updateSurveyCritter } from '.'; +import * as db from '../../../../../../../database/db'; +import { HTTPError } from '../../../../../../../errors/http-error'; +import { CritterbaseService } from '../../../../../../../services/critterbase-service'; +import { SurveyCritterService } from '../../../../../../../services/survey-critter-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../__mocks__/db'; describe('updateSurveyCritter', () => { afterEach(() => { sinon.restore(); }); - const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); - const mockCBCritter = { critter_id: 'critterbase1' }; - it('returns critters from survey', async () => { - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const mockCBCritter = { critter_id: 'critterbase1' }; + const mockSurveyUpdateCritter = sinon.stub(SurveyCritterService.prototype, 'updateCritter').resolves(); const mockCritterbaseUpdateCritter = sinon .stub(CritterbaseService.prototype, 'updateCritter') @@ -33,31 +25,38 @@ describe('updateSurveyCritter', () => { const mockCritterbaseCreateCritter = sinon .stub(CritterbaseService.prototype, 'createCritter') .resolves(mockCBCritter); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); mockReq.body = { create: {}, update: { critter_id: 'critterbase1' } }; + const requestHandler = updateSurveyCritter(); + await requestHandler(mockReq, mockRes, mockNext); - expect(mockGetDBConnection.calledOnce).to.be.true; - expect(mockSurveyUpdateCritter.calledOnce).to.be.true; - expect(mockCritterbaseUpdateCritter.calledOnce).to.be.true; - expect(mockCritterbaseCreateCritter.calledOnce).to.be.true; + expect(getDBConnectionStub).to.have.been.calledOnce; + expect(mockSurveyUpdateCritter).to.have.been.calledOnce; + expect(mockCritterbaseUpdateCritter).to.have.been.calledOnce; + expect(mockCritterbaseCreateCritter).to.have.been.calledOnce; expect(mockRes.status).to.have.been.calledWith(200); expect(mockRes.json).to.have.been.calledWith(mockCBCritter); }); it('catches and re-throws errors', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockError = new Error('a test error'); - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); const mockSurveyUpdateCritter = sinon.stub(SurveyCritterService.prototype, 'updateCritter').rejects(mockError); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); mockReq.body = { create: {}, update: { critter_id: 'critterbase1' } }; + const requestHandler = updateSurveyCritter(); try { @@ -65,20 +64,24 @@ describe('updateSurveyCritter', () => { expect.fail(); } catch (actualError) { expect(actualError).to.equal(mockError); - expect(mockSurveyUpdateCritter.calledOnce).to.be.true; - expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockSurveyUpdateCritter).to.have.been.calledOnce; + expect(getDBConnectionStub).to.have.been.calledOnce; expect(mockDBConnection.release).to.have.been.called; } }); it('catches and re-throws errors', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const errMsg = 'No external critter ID was found.'; - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); const mockSurveyUpdateCritter = sinon.stub(SurveyCritterService.prototype, 'updateCritter').resolves(); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); mockReq.body = { update: {} }; + const requestHandler = updateSurveyCritter(); try { @@ -87,8 +90,8 @@ describe('updateSurveyCritter', () => { } catch (actualError) { expect((actualError as HTTPError).message).to.equal(errMsg); expect((actualError as HTTPError).status).to.equal(400); - expect(mockSurveyUpdateCritter.calledOnce).to.be.false; - expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockSurveyUpdateCritter).not.to.have.been.called; + expect(getDBConnectionStub).to.have.been.calledOnce; expect(mockDBConnection.release).to.have.been.called; } }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.ts new file mode 100644 index 0000000000..3c22b02747 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.ts @@ -0,0 +1,252 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../database/db'; +import { HTTPError, HTTPErrorType } from '../../../../../../../errors/http-error'; +import { bulkUpdateResponse, critterBulkRequestObject } from '../../../../../../../openapi/schemas/critter'; +import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; +import { getBctwUser } from '../../../../../../../services/bctw-service/bctw-service'; +import { CritterbaseService, ICritterbaseUser } from '../../../../../../../services/critterbase-service'; +import { SurveyCritterService } from '../../../../../../../services/survey-critter-service'; +import { getLogger } from '../../../../../../../utils/logger'; + +const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/critters/{critterId}'); + +export const PATCH: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + updateSurveyCritter() +]; + +PATCH.apiDoc = { + description: 'Patches a critter in critterbase, also capable of deleting relevant rows if marked with _delete.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'surveyId', + schema: { + type: 'number' + }, + required: true + } + ], + requestBody: { + description: 'Critterbase bulk patch request object', + content: { + 'application/json': { + schema: critterBulkRequestObject + } + } + }, + responses: { + 200: { + description: 'Responds with counts of objects created in critterbase.', + content: { + 'application/json': { + schema: bulkUpdateResponse + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function updateSurveyCritter(): RequestHandler { + return async (req, res) => { + const critterbaseCritterId = req.body.update.critter_id; + + const critterId = Number(req.params.critterId); + + const connection = getDBConnection(req.keycloak_token); + try { + await connection.open(); + const user = getBctwUser(req); + + if (!critterbaseCritterId) { + throw new HTTPError(HTTPErrorType.BAD_REQUEST, 400, 'No external critter ID was found.'); + } + + const surveyService = new SurveyCritterService(connection); + await surveyService.updateCritter(critterId, critterbaseCritterId); + + const critterbaseService = new CritterbaseService(user); + + let createResult, updateResult; + + if (req.body.update) { + updateResult = await critterbaseService.updateCritter(req.body.update); + } + + if (req.body.create) { + createResult = await critterbaseService.createCritter(req.body.create); + } + + await connection.commit(); + + return res.status(200).json({ ...createResult, ...updateResult }); + } catch (error) { + defaultLog.error({ label: 'updateSurveyCritter', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [ + PROJECT_PERMISSION.COORDINATOR, + PROJECT_PERMISSION.COLLABORATOR, + PROJECT_PERMISSION.OBSERVER + ], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getCrittersFromSurvey() +]; + +GET.apiDoc = { + description: 'Gets a specific critter by its integer Critter Id', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer' + }, + required: true + }, + { + in: 'path', + name: 'critterId', + schema: { + type: 'integer' + }, + required: true + } + ], + responses: { + 200: { + description: 'Responds with a critter', + content: { + 'application/json': { + schema: { type: 'object' } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function getCrittersFromSurvey(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + const critterId = Number(req.params.critterId); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const user: ICritterbaseUser = { + keycloak_guid: connection.systemUserGUID(), + username: connection.systemUserIdentifier() + }; + + const surveyService = new SurveyCritterService(connection); + const critterbaseService = new CritterbaseService(user); + + const surveyCritter = await surveyService.getCritterById(surveyId, critterId); + + if (!surveyCritter) { + return res.status(404).json({ error: `Critter with id ${critterId} not found.` }); + } + + const critterbaseCritter = await critterbaseService.getCritter(surveyCritter.critterbase_critter_id); + + if (!critterbaseCritter || critterbaseCritter.length === 0) { + return res.status(404).json({ error: `Critter ${surveyCritter.critterbase_critter_id} not found.` }); + } + + const critterMapped = { + ...surveyCritter, + ...critterbaseCritter, + critterbase_critter_id: surveyCritter.critterbase_critter_id, + critter_id: surveyCritter.critter_id + }; + + return res.status(200).json(critterMapped); + } catch (error) { + defaultLog.error({ label: 'createCritter', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/telemetry.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/telemetry.test.ts index bc00790004..6c7b80beb6 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/telemetry.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/telemetry.test.ts @@ -1,66 +1,106 @@ -import Ajv from 'ajv'; import { expect } from 'chai'; import sinon from 'sinon'; import * as db from '../../../../../../../database/db'; -import { BctwService } from '../../../../../../../services/bctw-service'; -import { SurveyCritterService } from '../../../../../../../services/survey-critter-service'; +import { SurveyDeployment } from '../../../../../../../models/survey-deployment'; +import { BctwTelemetryService, IAllTelemetry } from '../../../../../../../services/bctw-service/bctw-telemetry-service'; +import { DeploymentService } from '../../../../../../../services/deployment-service'; import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../__mocks__/db'; -import { GET, getCritterTelemetry } from './telemetry'; +import { getCritterTelemetry } from './telemetry'; -describe('critter telemetry', () => { +describe('getCritterTelemetry', () => { afterEach(() => { sinon.restore(); }); - const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + it('fetches telemetry object', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - describe('openapi schema', () => { - const ajv = new Ajv(); + const mockSurveyDeployment: SurveyDeployment = { + deployment_id: 1, + critter_id: 123, + critterbase_critter_id: 'critter-001', + bctw_deployment_id: '111', + critterbase_start_capture_id: '222', + critterbase_end_capture_id: '333', + critterbase_end_mortality_id: '444' + }; - it('is valid openapi v3 schema', () => { - expect(ajv.validateSchema(GET.apiDoc as unknown as object)).to.be.true; - }); + const mockTelemetry: IAllTelemetry[] = [ + { + id: '123e4567-e89b-12d3-a456-426614174111', + deployment_id: '123e4567-e89b-12d3-a456-426614174222', + latitude: 37.7749, + longitude: -122.4194, + acquisition_date: '2023-10-01T12:00:00Z', + telemetry_type: 'ATS', + telemetry_id: '123e4567-e89b-12d3-a456-426614174111', + telemetry_manual_id: null + }, + { + id: '123e4567-e89b-12d3-a456-426614174333', + deployment_id: '123e4567-e89b-12d3-a456-426614174444', + latitude: 37.775, + longitude: -122.4195, + acquisition_date: '2023-10-01T12:05:00Z', + telemetry_type: 'ATS', + telemetry_id: null, + telemetry_manual_id: '123e4567-e89b-12d3-a456-426614174333' + }, + { + id: '123e4567-e89b-12d3-a456-426614174555', + deployment_id: '123e4567-e89b-12d3-a456-426614174666', + latitude: 37.7751, + longitude: -122.4196, + acquisition_date: '2023-10-01T12:10:00Z', + telemetry_type: 'MANUAL', + telemetry_id: null, + telemetry_manual_id: '123e4567-e89b-12d3-a456-426614174555' + } + ]; + + const getDeploymentForCritterIdStub = sinon + .stub(DeploymentService.prototype, 'getDeploymentForCritterId') + .resolves(mockSurveyDeployment); + + const getAllTelemetryByDeploymentIdsStub = sinon + .stub(BctwTelemetryService.prototype, 'getAllTelemetryByDeploymentIds') + .resolves(mockTelemetry); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params.critterId = '1'; + + const requestHandler = getCritterTelemetry(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql(mockTelemetry); + expect(getDBConnectionStub).to.have.been.calledOnce; + expect(getDeploymentForCritterIdStub).to.have.been.calledOnce; + expect(getAllTelemetryByDeploymentIdsStub).to.have.been.calledOnce; }); - describe('getCritterTelemetry', () => { - it('fetches telemetry object', async () => { - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockGetCrittersInSurvey = sinon - .stub(SurveyCritterService.prototype, 'getCrittersInSurvey') - .resolves([{ critter_id: 1, survey_id: 1, critterbase_critter_id: 'asdf' }]); - const mockGetCritterPoints = sinon.stub(BctwService.prototype, 'getCritterTelemetryPoints').resolves(); - const mockGetCritterTracks = sinon.stub(BctwService.prototype, 'getCritterTelemetryTracks').resolves(); + it('catches and re-throws error', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - const requestHandler = getCritterTelemetry(); + const mockError = new Error('a test error'); + const getDeploymentForCritterIdStub = sinon + .stub(DeploymentService.prototype, 'getDeploymentForCritterId') + .rejects(mockError); - mockReq.params.critterId = '1'; + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - await requestHandler(mockReq, mockRes, mockNext); + const requestHandler = getCritterTelemetry(); - expect(mockGetDBConnection.calledOnce).to.be.true; - expect(mockGetCrittersInSurvey.calledOnce).to.be.true; - expect(mockGetCritterPoints.calledOnce).to.be.true; - expect(mockGetCritterTracks.calledOnce).to.be.true; - }); - - it('catches and re-throws error', async () => { - const mockError = new Error('a test error'); - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockGetCrittersInSurvey = sinon - .stub(SurveyCritterService.prototype, 'getCrittersInSurvey') - .rejects(mockError); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - const requestHandler = getCritterTelemetry(); - try { - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect(actualError).to.eql(mockError); - expect(mockGetDBConnection.calledOnce).to.be.true; - expect(mockGetCrittersInSurvey.calledOnce).to.be.true; - } - }); + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.eql(mockError); + expect(getDBConnectionStub).to.have.been.calledOnce; + expect(getDeploymentForCritterIdStub).to.have.been.calledOnce; + } }); }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/telemetry.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/telemetry.ts index 20fcda05be..326264c6ef 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/telemetry.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/telemetry.ts @@ -1,163 +1,15 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; -import { OpenAPIV3 } from 'openapi-types'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../../database/db'; -import { HTTP400 } from '../../../../../../../errors/http-error'; -import { GeoJSONFeatureCollection } from '../../../../../../../openapi/schemas/geoJson'; +import { AllTelemetrySchema } from '../../../../../../../openapi/schemas/telemetry'; import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; -import { BctwService } from '../../../../../../../services/bctw-service'; +import { BctwTelemetryService } from '../../../../../../../services/bctw-service/bctw-telemetry-service'; import { ICritterbaseUser } from '../../../../../../../services/critterbase-service'; -import { SurveyCritterService } from '../../../../../../../services/survey-critter-service'; +import { DeploymentService } from '../../../../../../../services/deployment-service'; import { getLogger } from '../../../../../../../utils/logger'; const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/telemetry'); -const GeoJSONFeatureCollectionFeaturesItems = ( - GeoJSONFeatureCollection.properties?.features as OpenAPIV3.ArraySchemaObject -)?.items as OpenAPIV3.SchemaObject; - -const GeoJSONTelemetryPointsAPISchema: OpenAPIV3.SchemaObject = { - ...GeoJSONFeatureCollection, - properties: { - ...GeoJSONFeatureCollection.properties, - features: { - type: 'array', - items: { - ...GeoJSONFeatureCollectionFeaturesItems, - properties: { - ...GeoJSONFeatureCollectionFeaturesItems?.properties, - properties: { - type: 'object', - additionalProperties: false, - required: ['collar_id', 'device_id', 'date_recorded', 'deployment_id', 'critter_id'], - properties: { - collar_id: { - type: 'string', - format: 'uuid' - }, - device_id: { - type: 'integer' - }, - elevation: { - type: 'number', - nullable: true - }, - frequency: { - type: 'number', - nullable: true - }, - critter_id: { - type: 'string', - format: 'uuid' - }, - date_recorded: { - type: 'string' - }, - deployment_id: { - type: 'string', - format: 'uuid' - }, - device_status: { - type: 'string', - nullable: true - }, - device_vendor: { - type: 'string', - nullable: true - }, - frequency_unit: { - type: 'string', - nullable: true - }, - wlh_id: { - type: 'string', - nullable: true - }, - animal_id: { - type: 'string', - nullable: true - }, - sex: { - type: 'string' - }, - taxon: { - type: 'string' - }, - collection_units: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - properties: { - collection_unit_id: { - type: 'string', - format: 'uuid' - }, - unit_name: { - type: 'string' - }, - collection_category_id: { - type: 'string', - format: 'uuid' - }, - category_name: { - type: 'string' - } - } - } - }, - mortality_timestamp: { - type: 'string', - nullable: true - }, - _merged: { - type: 'boolean' - }, - map_colour: { - type: 'string' - } - } - } - } - } - } - } -}; - -const GeoJSONTelemetryTracksAPISchema: OpenAPIV3.SchemaObject = { - ...GeoJSONFeatureCollection, - properties: { - ...GeoJSONFeatureCollection.properties, - features: { - type: 'array', - items: { - ...GeoJSONFeatureCollectionFeaturesItems, - properties: { - ...GeoJSONFeatureCollectionFeaturesItems?.properties, - properties: { - type: 'object', - additionalProperties: false, - required: ['critter_id', 'deployment_id'], - properties: { - critter_id: { - type: 'string', - format: 'uuid' - }, - deployment_id: { - type: 'string', - format: 'uuid' - }, - map_colour: { - type: 'string' - } - } - } - } - } - } - } -}; - export const GET: Operation = [ authorizeRequestHandler((req) => { return { @@ -225,14 +77,8 @@ GET.apiDoc = { content: { 'application/json': { schema: { - title: 'Telemetry response', - type: 'object', - additionalProperties: false, - required: ['tracks', 'points'], - properties: { - points: GeoJSONTelemetryPointsAPISchema, - tracks: GeoJSONTelemetryTracksAPISchema - } + type: 'array', + items: AllTelemetrySchema } } } @@ -261,7 +107,6 @@ export function getCritterTelemetry(): RequestHandler { const surveyId = Number(req.params.surveyId); const connection = getDBConnection(req.keycloak_token); - try { await connection.open(); @@ -270,28 +115,25 @@ export function getCritterTelemetry(): RequestHandler { username: connection.systemUserIdentifier() }; - const surveyCritterService = new SurveyCritterService(connection); - const surveyCritters = await surveyCritterService.getCrittersInSurvey(surveyId); + // TODO: Telemetry data should only ever be fetched by deployment Ids. To get telemetry for an animal, first find the + // relevant deployment Id, then fetch data for that deployment Id. + const deploymentService = new DeploymentService(connection); + const bctwTelemetryService = new BctwTelemetryService(user); - const critter = surveyCritters.find((surveyCritter) => surveyCritter.critter_id === critterId); - if (!critter) { - throw new HTTP400('Specified critter was not part of this survey.'); - } + const { bctw_deployment_id } = await deploymentService.getDeploymentForCritterId(surveyId, critterId); - const startDate = new Date(String(req.query.startDate)); - const endDate = new Date(String(req.query.endDate)); + // const startDate = new Date(String(req.query.startDate)); + // const endDate = new Date(String(req.query.endDate)); - const bctwService = new BctwService(user); - const points = await bctwService.getCritterTelemetryPoints(critter.critterbase_critter_id, startDate, endDate); - const tracks = await bctwService.getCritterTelemetryTracks(critter.critterbase_critter_id, startDate, endDate); + // TODO: Add start and end date filters received in the SIMS request to the BCTW request + const points = await bctwTelemetryService.getAllTelemetryByDeploymentIds([bctw_deployment_id]); await connection.commit(); - return res.status(200).json({ points, tracks }); + return res.status(200).json(points); } catch (error) { defaultLog.error({ label: 'telemetry', message: 'error', error }); await connection.rollback(); - throw error; } finally { connection.release(); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments.test.ts deleted file mode 100644 index 9932141f89..0000000000 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { expect } from 'chai'; -import sinon from 'sinon'; -import * as db from '../../../../../database/db'; -import { BctwService, IDeploymentRecord } from '../../../../../services/bctw-service'; -import { SurveyCritterService } from '../../../../../services/survey-critter-service'; -import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../__mocks__/db'; -import { getDeploymentsInSurvey } from './deployments'; - -describe('getDeploymentsInSurvey', () => { - afterEach(() => { - sinon.restore(); - }); - - const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); - const mockDeployments: IDeploymentRecord[] = [ - { - critter_id: 'critterbase1', - assignment_id: 'assignment1', - collar_id: 'collar1', - attachment_start: '2020-01-01', - attachment_end: '2020-01-02', - deployment_id: 'deployment1', - device_id: 123, - created_at: '2020-01-01', - created_by_user_id: 'user1', - updated_at: '2020-01-01', - updated_by_user_id: 'user1', - valid_from: '2020-01-01', - valid_to: '2020-01-02' - } - ]; - const mockCritters = [{ critter_id: 123, survey_id: 123, critterbase_critter_id: 'critterbase1' }]; - - it('gets deployments in survey', async () => { - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockGetCrittersInSurvey = sinon - .stub(SurveyCritterService.prototype, 'getCrittersInSurvey') - .resolves(mockCritters); - const getDeploymentsByCritterId = sinon - .stub(BctwService.prototype, 'getDeploymentsByCritterId') - .resolves(mockDeployments); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - const requestHandler = getDeploymentsInSurvey(); - - await requestHandler(mockReq, mockRes, mockNext); - - expect(mockGetDBConnection.calledOnce).to.be.true; - expect(mockGetCrittersInSurvey.calledOnce).to.be.true; - expect(getDeploymentsByCritterId.calledOnce).to.be.true; - expect(mockRes.json.calledWith(mockDeployments)).to.be.true; - expect(mockRes.status.calledWith(200)).to.be.true; - }); - - it('catches and re-throws errors', async () => { - const mockError = new Error('a test error'); - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockGetCrittersInSurvey = sinon - .stub(SurveyCritterService.prototype, 'getCrittersInSurvey') - .rejects(mockError); - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - const requestHandler = getDeploymentsInSurvey(); - - try { - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect(actualError).to.equal(mockError); - expect(mockGetCrittersInSurvey.calledOnce).to.be.true; - expect(mockGetDBConnection.calledOnce).to.be.true; - expect(mockDBConnection.release).to.have.been.called; - } - }); -}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments.ts deleted file mode 100644 index 2c5684841a..0000000000 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { RequestHandler } from 'express'; -import { Operation } from 'express-openapi'; -import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../constants/roles'; -import { getDBConnection } from '../../../../../database/db'; -import { authorizeRequestHandler } from '../../../../../request-handlers/security/authorization'; -import { BctwService } from '../../../../../services/bctw-service'; -import { ICritterbaseUser } from '../../../../../services/critterbase-service'; -import { SurveyCritterService } from '../../../../../services/survey-critter-service'; -import { getLogger } from '../../../../../utils/logger'; - -const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/critters'); -export const GET: Operation = [ - authorizeRequestHandler((req) => { - return { - or: [ - { - validProjectPermissions: [ - PROJECT_PERMISSION.COORDINATOR, - PROJECT_PERMISSION.COLLABORATOR, - PROJECT_PERMISSION.OBSERVER - ], - surveyId: Number(req.params.surveyId), - discriminator: 'ProjectPermission' - }, - { - validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], - discriminator: 'SystemRole' - } - ] - }; - }), - getDeploymentsInSurvey() -]; - -GET.apiDoc = { - description: - 'Fetches a list of all the deployments under this survey. This is determined by the critters under this survey.', - tags: ['bctw'], - security: [ - { - Bearer: [] - } - ], - parameters: [ - { - in: 'path', - name: 'surveyId', - schema: { - type: 'number' - }, - required: true - } - ], - responses: { - 200: { - description: 'Responds with all deployments under this survey, determined by critters under the survey.', - content: { - 'application/json': { - schema: { - title: 'Deployments', - type: 'array', - items: { - type: 'object' - } - } - } - } - }, - 400: { - $ref: '#/components/responses/400' - }, - 401: { - $ref: '#/components/responses/401' - }, - 403: { - $ref: '#/components/responses/403' - }, - 500: { - $ref: '#/components/responses/500' - }, - default: { - $ref: '#/components/responses/default' - } - } -}; - -export function getDeploymentsInSurvey(): RequestHandler { - return async (req, res) => { - const surveyId = Number(req.params.surveyId); - const connection = getDBConnection(req.keycloak_token); - - try { - await connection.open(); - - const user: ICritterbaseUser = { - keycloak_guid: connection.systemUserGUID(), - username: connection.systemUserIdentifier() - }; - - const surveyCritterService = new SurveyCritterService(connection); - const bctwService = new BctwService(user); - - const critter_ids = (await surveyCritterService.getCrittersInSurvey(surveyId)).map( - (critter) => critter.critterbase_critter_id - ); - - const results = critter_ids.length ? await bctwService.getDeploymentsByCritterId(critter_ids) : []; - return res.status(200).json(results); - } catch (error) { - defaultLog.error({ label: 'getDeploymentsInSurvey', message: 'error', error }); - await connection.rollback(); - - throw error; - } finally { - connection.release(); - } - }; -} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.test.ts new file mode 100644 index 0000000000..0dfcbf2800 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.test.ts @@ -0,0 +1,355 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { getDeploymentsInSurvey } from '.'; +import * as db from '../../../../../../database/db'; +import { HTTPError } from '../../../../../../errors/http-error'; +import { SurveyDeployment } from '../../../../../../models/survey-deployment'; +import { + BctwDeploymentRecordWithDeviceMeta, + BctwDeploymentService +} from '../../../../../../services/bctw-service/bctw-deployment-service'; +import { DeploymentService } from '../../../../../../services/deployment-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; + +describe('getDeploymentsInSurvey', () => { + afterEach(() => { + sinon.restore(); + }); + + it('gets deployments in survey', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const mockSIMSDeployments = [ + { + deployment_id: 3, + critter_id: 2, + critterbase_critter_id: '333', + bctw_deployment_id: '444', + critterbase_start_capture_id: '555', + critterbase_end_capture_id: null, + critterbase_end_mortality_id: null + } + ]; + + const mockBCTWDeployments: BctwDeploymentRecordWithDeviceMeta[] = [ + { + critter_id: '333', + assignment_id: 'assignment1', + collar_id: 'collar1', + attachment_start: '2020-01-01T00:00:00', + attachment_end: '2020-01-02T12:12:12', + deployment_id: '444', + device_id: 123, + created_at: '2020-01-01', + created_by_user_id: 'user1', + updated_at: '2020-01-01', + updated_by_user_id: 'user1', + valid_from: '2020-01-01', + valid_to: null, + device_make: 17, + device_model: 'model', + frequency: 1, + frequency_unit: 2 + } + ]; + + const getDeploymentsForSurveyIdStub = sinon + .stub(DeploymentService.prototype, 'getDeploymentsForSurveyId') + .resolves(mockSIMSDeployments); + const getDeploymentsByIdsStub = sinon + .stub(BctwDeploymentService.prototype, 'getDeploymentsByIds') + .resolves(mockBCTWDeployments); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '55', + surveyId: '66' + }; + + const requestHandler = getDeploymentsInSurvey(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getDeploymentsForSurveyIdStub).calledOnceWith(66); + expect(getDeploymentsByIdsStub).calledOnceWith(['444']); + expect(mockRes.json).to.have.been.calledOnceWith([ + { + // BCTW properties + assignment_id: mockBCTWDeployments[0].assignment_id, + collar_id: mockBCTWDeployments[0].collar_id, + attachment_start_date: '2020-01-01', + attachment_start_time: '00:00:00', + attachment_end_date: '2020-01-02', + attachment_end_time: '12:12:12', + bctw_deployment_id: mockBCTWDeployments[0].deployment_id, + device_id: mockBCTWDeployments[0].device_id, + device_make: mockBCTWDeployments[0].device_make, + device_model: mockBCTWDeployments[0].device_model, + frequency: mockBCTWDeployments[0].frequency, + frequency_unit: mockBCTWDeployments[0].frequency_unit, + // SIMS properties + deployment_id: mockSIMSDeployments[0].deployment_id, + critter_id: mockSIMSDeployments[0].critter_id, + critterbase_critter_id: mockSIMSDeployments[0].critterbase_critter_id, + critterbase_start_capture_id: mockSIMSDeployments[0].critterbase_start_capture_id, + critterbase_end_capture_id: mockSIMSDeployments[0].critterbase_end_capture_id, + critterbase_end_mortality_id: mockSIMSDeployments[0].critterbase_end_mortality_id + } + ]); + expect(mockRes.status).calledOnceWith(200); + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('returns early an empty array if no SIMS deployment records for survey', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const mockSIMSDeployments: SurveyDeployment[] = []; // no SIMS deployment records + + const mockBCTWDeployments: BctwDeploymentRecordWithDeviceMeta[] = [ + { + critter_id: '333', + assignment_id: 'assignment1', + collar_id: 'collar1', + attachment_start: '2020-01-01', + attachment_end: '2020-01-02', + deployment_id: '444', + device_id: 123, + created_at: '2020-01-01', + created_by_user_id: 'user1', + updated_at: '2020-01-01', + updated_by_user_id: 'user1', + valid_from: '2020-01-01', + valid_to: null, + device_make: 17, + device_model: 'model', + frequency: 1, + frequency_unit: 2 + } + ]; + + const getDeploymentsForSurveyIdStub = sinon + .stub(DeploymentService.prototype, 'getDeploymentsForSurveyId') + .resolves(mockSIMSDeployments); + const getDeploymentsByIdsStub = sinon + .stub(BctwDeploymentService.prototype, 'getDeploymentsByIds') + .resolves(mockBCTWDeployments); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '55', + surveyId: '66' + }; + + const requestHandler = getDeploymentsInSurvey(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getDeploymentsForSurveyIdStub).calledOnceWith(66); + expect(getDeploymentsByIdsStub).not.to.have.been.called; + expect(mockRes.json).calledOnceWith([]); + expect(mockRes.status).calledOnceWith(200); + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('throws a 409 error if more than 1 active deployment found in BCTW for a single SIMS deployment record', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const mockSIMSDeployments = [ + { + deployment_id: 3, + critter_id: 2, + critterbase_critter_id: '333', + bctw_deployment_id: '444', + critterbase_start_capture_id: '555', + critterbase_end_capture_id: null, + critterbase_end_mortality_id: null + } + ]; + + const mockBCTWDeployments: BctwDeploymentRecordWithDeviceMeta[] = [ + { + critter_id: '333', + assignment_id: 'assignment1', + collar_id: 'collar1', + attachment_start: '2020-01-01', + attachment_end: '2020-01-02', + deployment_id: '444', + device_id: 123, + created_at: '2020-01-01', + created_by_user_id: 'user1', + updated_at: '2020-01-01', + updated_by_user_id: 'user1', + valid_from: '2020-01-01', + valid_to: null, + device_make: 17, + device_model: 'model', + frequency: 1, + frequency_unit: 2 + }, + { + critter_id: '333', + assignment_id: 'assignment1', + collar_id: 'collar1', + attachment_start: '2020-01-01', + attachment_end: '2020-01-02', + deployment_id: '444', + device_id: 123, + created_at: '2020-01-01', + created_by_user_id: 'user1', + updated_at: '2020-01-01', + updated_by_user_id: 'user1', + valid_from: '2020-01-01', + valid_to: null, + device_make: 17, + device_model: 'model', + frequency: 1, + frequency_unit: 2 + } + ]; + + const getDeploymentsForSurveyIdStub = sinon + .stub(DeploymentService.prototype, 'getDeploymentsForSurveyId') + .resolves(mockSIMSDeployments); + const getDeploymentsByIdsStub = sinon + .stub(BctwDeploymentService.prototype, 'getDeploymentsByIds') + .resolves(mockBCTWDeployments); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '55', + surveyId: '66' + }; + + const requestHandler = getDeploymentsInSurvey(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect((actualError as HTTPError).message).to.equal( + 'Multiple active deployments found for the same deployment ID' + ); + expect((actualError as HTTPError).status).to.equal(409); + expect(getDeploymentsForSurveyIdStub).calledOnceWith(66); + expect(getDeploymentsByIdsStub).calledOnceWith(['444']); + expect(mockDBConnection.release).to.have.been.calledOnce; + } + }); + + it('throws a 409 error if no active deployment found in BCTW for a single SIMS deployment record', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const mockSIMSDeployments = [ + { + deployment_id: 3, + critter_id: 2, + critterbase_critter_id: '333', + bctw_deployment_id: '444', + critterbase_start_capture_id: '555', + critterbase_end_capture_id: null, + critterbase_end_mortality_id: null + } + ]; + + const mockBCTWDeployments: BctwDeploymentRecordWithDeviceMeta[] = [ + { + critter_id: '333', + assignment_id: 'assignment1', + collar_id: 'collar1', + attachment_start: '2020-01-01', + attachment_end: '2020-01-02', + deployment_id: '444_no_match', // different deployment ID + device_id: 123, + created_at: '2020-01-01', + created_by_user_id: 'user1', + updated_at: '2020-01-01', + updated_by_user_id: 'user1', + valid_from: '2020-01-01', + valid_to: null, + device_make: 17, + device_model: 'model', + frequency: 1, + frequency_unit: 2 + } + ]; + + const getDeploymentsForSurveyIdStub = sinon + .stub(DeploymentService.prototype, 'getDeploymentsForSurveyId') + .resolves(mockSIMSDeployments); + const getDeploymentsByIdsStub = sinon + .stub(BctwDeploymentService.prototype, 'getDeploymentsByIds') + .resolves(mockBCTWDeployments); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '55', + surveyId: '66' + }; + + const requestHandler = getDeploymentsInSurvey(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect((actualError as HTTPError).message).to.equal('No active deployments found for deployment ID'); + expect((actualError as HTTPError).status).to.equal(409); + expect(getDeploymentsForSurveyIdStub).calledOnceWith(66); + expect(getDeploymentsByIdsStub).calledOnceWith(['444']); + expect(mockDBConnection.release).to.have.been.calledOnce; + } + }); + + it('catches and re-throws errors', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const mockSIMSDeployments = [ + { + deployment_id: 3, + critter_id: 2, + critterbase_critter_id: '333', + bctw_deployment_id: '444', + critterbase_start_capture_id: '555', + critterbase_end_capture_id: null, + critterbase_end_mortality_id: null + } + ]; + + const mockError = new Error('Test error'); + + const getDeploymentsForSurveyIdStub = sinon + .stub(DeploymentService.prototype, 'getDeploymentsForSurveyId') + .resolves(mockSIMSDeployments); + const getDeploymentsByIdsStub = sinon + .stub(BctwDeploymentService.prototype, 'getDeploymentsByIds') + .rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '55', + surveyId: '66' + }; + + const requestHandler = getDeploymentsInSurvey(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(getDeploymentsForSurveyIdStub).calledOnceWith(66); + expect(getDeploymentsByIdsStub).calledOnceWith(['444']); + expect(mockDBConnection.release).to.have.been.calledOnce; + } + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts new file mode 100644 index 0000000000..0ec5eb68e1 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts @@ -0,0 +1,191 @@ +import dayjs from 'dayjs'; +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../database/db'; +import { HTTP409 } from '../../../../../../errors/http-error'; +import { getDeploymentSchema } from '../../../../../../openapi/schemas/deployment'; +import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; +import { BctwDeploymentService } from '../../../../../../services/bctw-service/bctw-deployment-service'; +import { ICritterbaseUser } from '../../../../../../services/critterbase-service'; +import { DeploymentService } from '../../../../../../services/deployment-service'; +import { getLogger } from '../../../../../../utils/logger'; + +const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/deployments/index'); + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [ + PROJECT_PERMISSION.COORDINATOR, + PROJECT_PERMISSION.COLLABORATOR, + PROJECT_PERMISSION.OBSERVER + ], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getDeploymentsInSurvey() +]; + +GET.apiDoc = { + description: 'Returns information about all deployments under this survey.', + tags: ['deployment', 'bctw'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'surveyId', + schema: { + type: 'number' + }, + required: true + } + ], + responses: { + 200: { + description: 'Responds with information about all deployments under this survey.', + content: { + 'application/json': { + schema: { + title: 'Deployments', + type: 'array', + items: getDeploymentSchema + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 409: { + $ref: '#/components/responses/409' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function getDeploymentsInSurvey(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const user: ICritterbaseUser = { + keycloak_guid: connection.systemUserGUID(), + username: connection.systemUserIdentifier() + }; + + const deploymentService = new DeploymentService(connection); + const bctwDeploymentService = new BctwDeploymentService(user); + + // Fetch deployments from the deployment service for the given surveyId + const surveyDeployments = await deploymentService.getDeploymentsForSurveyId(surveyId); + + // Extract deployment IDs from survey deployments + const deploymentIds = surveyDeployments.map((deployment) => deployment.bctw_deployment_id); + + // Return early if there are no deployments + if (!deploymentIds.length) { + // TODO: 400 error instead? + return res.status(200).json([]); + } + + // Fetch additional deployment details from BCTW service + const bctwDeployments = await bctwDeploymentService.getDeploymentsByIds(deploymentIds); + + const surveyDeploymentsWithBctwData = []; + + // For each SIMS survey deployment record, find the matching BCTW deployment record. + // We expect exactly 1 matching record, otherwise we throw an error. + // More than 1 matching active record indicates an error in the BCTW data. + for (const surveyDeployment of surveyDeployments) { + const matchingBctwDeployments = bctwDeployments.filter( + (deployment) => deployment.deployment_id === surveyDeployment.bctw_deployment_id + ); + + if (matchingBctwDeployments.length > 1) { + throw new HTTP409('Multiple active deployments found for the same deployment ID', [ + 'This is an issue in the BC Telemetry Warehouse (BCTW) data. There should only be one active deployment record for a given deployment ID.', + `SIMS deployment ID: ${surveyDeployment.deployment_id}`, + `BCTW deployment ID: ${surveyDeployment.bctw_deployment_id}` + ]); + } + + if (matchingBctwDeployments.length === 0) { + throw new HTTP409('No active deployments found for deployment ID', [ + 'There should be no deployments recorded in SIMS that have no matching deployment record in BCTW.', + `SIMS Deployment ID: ${surveyDeployment.deployment_id}`, + `BCTW Deployment ID: ${surveyDeployment.bctw_deployment_id}` + ]); + } + + surveyDeploymentsWithBctwData.push({ + // BCTW properties + assignment_id: matchingBctwDeployments[0].assignment_id, + collar_id: matchingBctwDeployments[0].collar_id, + attachment_start_date: matchingBctwDeployments[0].attachment_start + ? dayjs(matchingBctwDeployments[0].attachment_start).format('YYYY-MM-DD') + : null, + attachment_start_time: matchingBctwDeployments[0].attachment_start + ? dayjs(matchingBctwDeployments[0].attachment_start).format('HH:mm:ss') + : null, + attachment_end_date: matchingBctwDeployments[0].attachment_end + ? dayjs(matchingBctwDeployments[0].attachment_end).format('YYYY-MM-DD') + : null, + attachment_end_time: matchingBctwDeployments[0].attachment_end + ? dayjs(matchingBctwDeployments[0].attachment_end).format('HH:mm:ss') + : null, + bctw_deployment_id: matchingBctwDeployments[0].deployment_id, + device_id: matchingBctwDeployments[0].device_id, + device_make: matchingBctwDeployments[0].device_make, + device_model: matchingBctwDeployments[0].device_model, + frequency: matchingBctwDeployments[0].frequency, + frequency_unit: matchingBctwDeployments[0].frequency_unit, + // SIMS properties + deployment_id: surveyDeployment.deployment_id, + critter_id: surveyDeployment.critter_id, + critterbase_critter_id: surveyDeployment.critterbase_critter_id, + critterbase_start_capture_id: surveyDeployment.critterbase_start_capture_id, + critterbase_end_capture_id: surveyDeployment.critterbase_end_capture_id, + critterbase_end_mortality_id: surveyDeployment.critterbase_end_mortality_id + }); + } + + return res.status(200).json(surveyDeploymentsWithBctwData); + } catch (error) { + defaultLog.error({ label: 'getDeploymentsInSurvey', message: 'error', error }); + await connection.rollback(); + + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.test.ts new file mode 100644 index 0000000000..e64d5af2b2 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.test.ts @@ -0,0 +1,182 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { deleteDeployment, getDeploymentById, updateDeployment } from '.'; +import * as db from '../../../../../../../database/db'; +import { BctwDeploymentService } from '../../../../../../../services/bctw-service/bctw-deployment-service'; +import { BctwDeviceService } from '../../../../../../../services/bctw-service/bctw-device-service'; +import { CritterbaseService, ICapture } from '../../../../../../../services/critterbase-service'; +import { DeploymentService } from '../../../../../../../services/deployment-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../__mocks__/db'; + +describe('getDeploymentById', () => { + afterEach(() => { + sinon.restore(); + }); + + it('Gets an existing deployment', async () => { + const mockDBConnection = getMockDBConnection({ commit: sinon.stub(), release: sinon.stub() }); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const mockRemoveDeployment = sinon.stub(DeploymentService.prototype, 'getDeploymentById').resolves({ + deployment_id: 3, + critter_id: 2, + critterbase_critter_id: '333', + bctw_deployment_id: '444', + critterbase_start_capture_id: '555', + critterbase_end_capture_id: null, + critterbase_end_mortality_id: null + }); + const mockBctwService = sinon.stub(BctwDeploymentService.prototype, 'getDeploymentsByIds').resolves([ + { + critter_id: '333', + assignment_id: '666', + collar_id: '777', + attachment_start: '2021-01-01', + attachment_end: '2021-01-02', + deployment_id: '444', + device_id: 888, + created_at: '2021-01-01', + created_by_user_id: '999', + updated_at: null, + updated_by_user_id: null, + valid_from: '2021-01-01', + valid_to: null, + device_make: 17, + device_model: 'model', + frequency: 1, + frequency_unit: 2 + } + ]); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2', + deploymentId: '3' + }; + + const requestHandler = getDeploymentById(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getDBConnectionStub).to.have.been.calledOnce; + expect(mockRemoveDeployment).to.have.been.calledOnce; + expect(mockBctwService).to.have.been.calledOnce; + expect(mockRes.status).to.have.been.calledWith(200); + }); +}); + +describe('updateDeployment', () => { + afterEach(() => { + sinon.restore(); + }); + + it('updates an existing deployment', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const mockCapture: ICapture = { + capture_id: '111', + critter_id: '222', + capture_method_id: null, + capture_location_id: '333', + release_location_id: null, + capture_date: '2021-01-01', + capture_time: '12:00:00', + release_date: null, + release_time: null, + capture_comment: null, + release_comment: null + }; + + const mockBctwDeploymentResponse = [ + { + assignment_id: '666', + collar_id: '777', + critter_id: '333', + created_at: '2021-01-01', + created_by_user_id: '999', + updated_at: null, + updated_by_user_id: null, + valid_from: '2021-01-01', + valid_to: null, + attachment_start: '2021-01-01', + attachment_end: '2021-01-02', + deployment_id: '444' + } + ]; + + const updateDeploymentStub = sinon.stub(DeploymentService.prototype, 'updateDeployment').resolves(); + const getCaptureByIdStub = sinon.stub(CritterbaseService.prototype, 'getCaptureById').resolves(mockCapture); + const updateBctwDeploymentStub = sinon + .stub(BctwDeploymentService.prototype, 'updateDeployment') + .resolves(mockBctwDeploymentResponse); + const updateCollarStub = sinon.stub(BctwDeviceService.prototype, 'updateCollar').resolves(); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const requestHandler = updateDeployment(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getDBConnectionStub).to.have.been.calledOnce; + expect(updateDeploymentStub).to.have.been.calledOnce; + expect(getCaptureByIdStub).to.have.been.calledOnce; + expect(updateBctwDeploymentStub).to.have.been.calledOnce; + expect(updateCollarStub).to.have.been.calledOnce; + expect(mockRes.status).to.have.been.calledWith(200); + }); + + it('catches and re-throws errors', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const mockError = new Error('a test error'); + const updateDeploymentStub = sinon.stub(DeploymentService.prototype, 'updateDeployment').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const requestHandler = updateDeployment(); + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(updateDeploymentStub).to.have.been.calledOnce; + } + }); +}); + +describe('deleteDeployment', () => { + afterEach(() => { + sinon.restore(); + }); + + it('deletes an existing deployment', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const deleteDeploymentStub = sinon + .stub(DeploymentService.prototype, 'deleteDeployment') + .resolves({ bctw_deployment_id: '444' }); + const bctwDeleteDeploymentStub = sinon.stub(BctwDeploymentService.prototype, 'deleteDeployment'); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2', + deploymentId: '3' + }; + + const requestHandler = deleteDeployment(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getDBConnectionStub).to.have.been.calledOnce; + expect(deleteDeploymentStub).to.have.been.calledOnce; + expect(bctwDeleteDeploymentStub).to.have.been.calledOnce; + expect(mockRes.status).to.have.been.calledWith(200); + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts new file mode 100644 index 0000000000..52de5e3260 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts @@ -0,0 +1,555 @@ +import { AxiosError } from 'axios'; +import dayjs from 'dayjs'; +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../database/db'; +import { HTTP409 } from '../../../../../../../errors/http-error'; +import { getDeploymentSchema } from '../../../../../../../openapi/schemas/deployment'; +import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; +import { BctwDeploymentService } from '../../../../../../../services/bctw-service/bctw-deployment-service'; +import { BctwDeviceService } from '../../../../../../../services/bctw-service/bctw-device-service'; +import { getBctwUser } from '../../../../../../../services/bctw-service/bctw-service'; +import { + CritterbaseService, + getCritterbaseUser, + ICritterbaseUser +} from '../../../../../../../services/critterbase-service'; +import { DeploymentService } from '../../../../../../../services/deployment-service'; +import { getLogger } from '../../../../../../../utils/logger'; + +const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index'); + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [ + PROJECT_PERMISSION.COORDINATOR, + PROJECT_PERMISSION.COLLABORATOR, + PROJECT_PERMISSION.OBSERVER + ], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getDeploymentById() +]; + +GET.apiDoc = { + description: 'Returns information about a specific deployment.', + tags: ['deployment', 'bctw'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'deploymentId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'deploymentId', + description: 'SIMS deployment ID', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + } + ], + responses: { + 200: { + description: 'Responds with information about a deployment under this survey.', + content: { + 'application/json': { + schema: { + oneOf: [getDeploymentSchema, { type: 'null' }] + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 409: { + $ref: '#/components/responses/409' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function getDeploymentById(): RequestHandler { + return async (req, res) => { + const deploymentId = Number(req.params.deploymentId); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const user: ICritterbaseUser = { + keycloak_guid: connection.systemUserGUID(), + username: connection.systemUserIdentifier() + }; + + const deploymentService = new DeploymentService(connection); + const bctwDeploymentService = new BctwDeploymentService(user); + + // Fetch deployments from the deployment service for the given surveyId + const surveyDeployment = await deploymentService.getDeploymentById(deploymentId); + + // Return early if there are no deployments + if (!surveyDeployment) { + // TODO: 400 error instead? + return res.status(200).send(); + } + + // Fetch additional deployment details from BCTW service + const bctwDeployments = await bctwDeploymentService.getDeploymentsByIds([surveyDeployment.bctw_deployment_id]); + + // For the SIMS survey deployment record, find the matching BCTW deployment record. + // We expect exactly 1 matching record, otherwise we throw an error. + // More than 1 matching active record indicates an error in the BCTW data. + const matchingBctwDeployments = bctwDeployments.filter( + (deployment) => deployment.deployment_id === surveyDeployment.bctw_deployment_id + ); + + if (matchingBctwDeployments.length > 1) { + throw new HTTP409('Multiple active deployments found for the same deployment ID', [ + 'This is an issue in the BC Telemetry Warehouse (BCTW) data. There should only be one active deployment record for a given deployment ID.', + `SIMS deployment ID: ${surveyDeployment.deployment_id}`, + `BCTW deployment ID: ${surveyDeployment.bctw_deployment_id}` + ]); + } + + if (matchingBctwDeployments.length === 0) { + throw new HTTP409('No active deployments found for deployment ID', [ + 'There should be no deployments recorded in SIMS that have no matching deployment record in BCTW.', + `SIMS Deployment ID: ${surveyDeployment.deployment_id}`, + `BCTW Deployment ID: ${surveyDeployment.bctw_deployment_id}` + ]); + } + + const surveyDeploymentWithBctwData = { + // BCTW properties + assignment_id: matchingBctwDeployments[0].assignment_id, + collar_id: matchingBctwDeployments[0].collar_id, + attachment_start_date: matchingBctwDeployments[0].attachment_start + ? dayjs(matchingBctwDeployments[0].attachment_start).format('YYYY-MM-DD') + : null, + attachment_start_time: matchingBctwDeployments[0].attachment_start + ? dayjs(matchingBctwDeployments[0].attachment_start).format('HH:mm:ss') + : null, + attachment_end_date: matchingBctwDeployments[0].attachment_end + ? dayjs(matchingBctwDeployments[0].attachment_end).format('YYYY-MM-DD') + : null, + attachment_end_time: matchingBctwDeployments[0].attachment_end + ? dayjs(matchingBctwDeployments[0].attachment_end).format('HH:mm:ss') + : null, + bctw_deployment_id: matchingBctwDeployments[0].deployment_id, + device_id: matchingBctwDeployments[0].device_id, + device_make: matchingBctwDeployments[0].device_make, + device_model: matchingBctwDeployments[0].device_model, + frequency: matchingBctwDeployments[0].frequency, + frequency_unit: matchingBctwDeployments[0].frequency_unit, + // SIMS properties + deployment_id: surveyDeployment.deployment_id, + critter_id: surveyDeployment.critter_id, + critterbase_critter_id: surveyDeployment.critterbase_critter_id, + critterbase_start_capture_id: surveyDeployment.critterbase_start_capture_id, + critterbase_end_capture_id: surveyDeployment.critterbase_end_capture_id, + critterbase_end_mortality_id: surveyDeployment.critterbase_end_mortality_id + }; + + return res.status(200).json(surveyDeploymentWithBctwData); + } catch (error) { + defaultLog.error({ label: 'getDeploymentById', message: 'error', error }); + await connection.rollback(); + + throw error; + } finally { + connection.release(); + } + }; +} + +export const PUT: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + updateDeployment() +]; + +PUT.apiDoc = { + description: 'Updates information about the start and end of a deployment.', + tags: ['deployment', 'bctw'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'deploymentId', + description: 'SIMS deployment ID', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + } + ], + requestBody: { + description: 'Specifies a deployment id and the new timerange to update it with.', + content: { + 'application/json': { + schema: { + title: 'Deploy device request object', + type: 'object', + additionalProperties: false, + required: [ + 'critter_id', + 'device_id', + 'attachment_end_date', + 'attachment_end_time', + 'device_make', + 'device_model', + 'frequency', + 'frequency_unit', + 'critterbase_start_capture_id', + 'critterbase_end_capture_id', + 'critterbase_end_mortality_id' + ], + properties: { + critter_id: { + type: 'integer', + minimum: 1 + }, + attachment_end_date: { + type: 'string', + description: 'End date of the deployment, without time.', + nullable: true + }, + attachment_end_time: { + type: 'string', + description: 'End time of the deployment.', + nullable: true + }, + device_id: { + type: 'integer', + minimum: 1 + }, + device_make: { + type: 'number', + nullable: true + }, + device_model: { + type: 'string', + nullable: true + }, + frequency: { + type: 'number' + }, + frequency_unit: { + type: 'number', + nullable: true + }, + critterbase_start_capture_id: { + type: 'string', + description: 'Critterbase capture record when the deployment started', + format: 'uuid', + nullable: true + }, + critterbase_end_capture_id: { + type: 'string', + description: 'Critterbase capture record when the deployment ended', + format: 'uuid', + nullable: true + }, + critterbase_end_mortality_id: { + type: 'string', + description: 'Critterbase mortality record when the deployment ended', + format: 'uuid', + nullable: true + } + } + } + } + } + }, + responses: { + 200: { + description: 'Deployment updated OK.' + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function updateDeployment(): RequestHandler { + return async (req, res) => { + const deploymentId = Number(req.params.deploymentId); + + const connection = getDBConnection(req.keycloak_token); + + const { + critter_id, + attachment_end_date, + attachment_end_time, + // device_id, // Do not allow the device_id to be updated + device_make, + device_model, + frequency, + frequency_unit, + critterbase_start_capture_id, + critterbase_end_capture_id, + critterbase_end_mortality_id + } = req.body; + + try { + await connection.open(); + + // Update the deployment in SIMS + const deploymentService = new DeploymentService(connection); + const bctw_deployment_id = await deploymentService.updateDeployment({ + deployment_id: deploymentId, + critter_id: critter_id, + critterbase_start_capture_id, + critterbase_end_capture_id, + critterbase_end_mortality_id + }); + + // TODO: Decide whether to explicitly record attachment start date, or just reference the capture. Might remove this line. + const critterbaseService = new CritterbaseService(getCritterbaseUser(req)); + const capture = await critterbaseService.getCaptureById(critterbase_start_capture_id); + + // Create attachment end date from provided end date (if not null) and end time (if not null). + const attachmentEnd = attachment_end_date + ? attachment_end_time + ? dayjs(`${attachment_end_date} ${attachment_end_time}`).toISOString() + : dayjs(`${attachment_end_date}`).toISOString() + : null; + + // Update the deployment (collar_animal_assignment) in BCTW + const bctwDeploymentService = new BctwDeploymentService(getBctwUser(req)); + // Returns an array though we only expect one record + const bctwDeploymentRecords = await bctwDeploymentService.updateDeployment({ + deployment_id: bctw_deployment_id, + attachment_start: capture.capture_date, + attachment_end: attachmentEnd // TODO: ADD SEPARATE DATE AND TIME TO BCTW + }); + + // Update the collar details in BCTW + const bctwDeviceService = new BctwDeviceService(getBctwUser(req)); + await bctwDeviceService.updateCollar({ + collar_id: bctwDeploymentRecords[0].collar_id, + device_make: device_make, + device_model: device_model, + frequency: frequency, + frequency_unit: frequency_unit + }); + + await connection.commit(); + + return res.status(200).send(); + } catch (error) { + defaultLog.error({ label: 'updateDeployment', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +export const DELETE: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + deleteDeployment() +]; + +DELETE.apiDoc = { + description: 'Deletes the deployment record in SIMS, and soft deletes the record in BCTW.', + tags: ['deploymenty', 'bctw'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'deploymentId', + description: 'SIMS deployment ID', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + } + ], + responses: { + 200: { + description: 'Delete deployment OK.' + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function deleteDeployment(): RequestHandler { + return async (req, res) => { + const deploymentId = Number(req.params.deploymentId); + const surveyId = Number(req.params.surveyId); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const user: ICritterbaseUser = { + keycloak_guid: connection.systemUserGUID(), + username: connection.systemUserIdentifier() + }; + + const deploymentService = new DeploymentService(connection); + const { bctw_deployment_id } = await deploymentService.deleteDeployment(surveyId, deploymentId); + + const bctwDeploymentService = new BctwDeploymentService(user); + await bctwDeploymentService.deleteDeployment(bctw_deployment_id); + + await connection.commit(); + return res.status(200).send(); + } catch (error) { + defaultLog.error({ label: 'deleteDeployment', message: 'error', error }); + await connection.rollback(); + + return res.status(500).json((error as AxiosError).response); + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/measurements/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/measurements/index.ts new file mode 100644 index 0000000000..c0e885ce6b --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/measurements/index.ts @@ -0,0 +1,134 @@ +import { Operation } from 'express-openapi'; +import { RequestHandler } from 'http-proxy-middleware'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../database/db'; +import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; +import { SubCountService } from '../../../../../../../services/subcount-service'; +import { getLogger } from '../../../../../../../utils/logger'; + +const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observations/measurements'); + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [ + PROJECT_PERMISSION.COORDINATOR, + PROJECT_PERMISSION.COLLABORATOR, + PROJECT_PERMISSION.OBSERVER + ], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getSurveyObservationMeasurements() +]; + +GET.apiDoc = { + description: 'Get all measurement definitions for the survey.', + tags: ['observation'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + } + ], + responses: { + 200: { + description: 'Observation measurements response', + content: { + 'application/json': { + schema: { + description: 'Qualitative and quantitative observation definitions for the survey', + type: 'object', + additionalProperties: false, + required: ['qualitative_measurements', 'quantitative_measurements'], + properties: { + qualitative_measurements: { + type: 'array', + items: {} + }, + quantitative_measurements: { + type: 'array', + items: {} + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Fetch definitions of all measured for a given survey. + * + * @export + * @return {*} {RequestHandler} + */ +export function getSurveyObservationMeasurements(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + + defaultLog.debug({ label: 'getSurveyObservationMeasurement', surveyId }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const subcountService = new SubCountService(connection); + + const observationData = await subcountService.getMeasurementTypeDefinitionsForSurvey(surveyId); + + return res.status(200).json(observationData); + } catch (error) { + defaultLog.error({ label: 'getSurveyObservationMeasurements', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.test.ts index b4b7719988..a30a08ae38 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.test.ts @@ -48,7 +48,7 @@ describe('processFile', () => { }); it('should succeed with valid params', async () => { - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns({ + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns({ ...dbConnectionObj, systemUserId: () => { return 20; @@ -60,7 +60,7 @@ describe('processFile', () => { await requestHandler(mockReq, mockRes, mockNext); - expect(mockGetDBConnection.calledOnce).to.be.true; + expect(getDBConnectionStub).to.have.been.calledOnce; expect(mockRes.status).to.be.calledWith(200); expect(mockRes.json).not.to.have.been.called; }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.test.ts new file mode 100644 index 0000000000..4b2e0888af --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.test.ts @@ -0,0 +1,86 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getSurveyObservedSpecies } from '.'; +import * as db from '../../../../../../../database/db'; +import { HTTPError } from '../../../../../../../errors/http-error'; +import { ObservationService } from '../../../../../../../services/observation-service'; +import { PlatformService } from '../../../../../../../services/platform-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../__mocks__/db'; + +chai.use(sinonChai); + +describe('getSurveyObservedSpecies', () => { + afterEach(() => { + sinon.restore(); + }); + + it('gets species observed in a survey', async () => { + const dbConnectionObj = getMockDBConnection(); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const mockSurveyId = 2; + const mockProjectId = 1; + const mockTsns = [1, 2, 3]; + const mockSpecies = mockTsns.map((tsn) => ({ itis_tsn: tsn })); + const mockItisResponse = [ + { tsn: 1, commonNames: ['common name 1'], scientificName: 'scientific name 1' }, + { tsn: 2, commonNames: ['common name 2'], scientificName: 'scientific name 2' }, + { tsn: 3, commonNames: ['common name 3'], scientificName: 'scientific name 3' } + ]; + const mockFormattedItisResponse = mockItisResponse.map((species) => ({ ...species, tsn: Number(species.tsn) })); + + const getObservedSpeciesForSurveyStub = sinon + .stub(ObservationService.prototype, 'getObservedSpeciesForSurvey') + .resolves(mockSpecies); + + const getTaxonomyByTsnsStub = sinon.stub(PlatformService.prototype, 'getTaxonomyByTsns').resolves(mockItisResponse); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: String(mockProjectId), + surveyId: String(mockSurveyId) + }; + + const requestHandler = getSurveyObservedSpecies(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getObservedSpeciesForSurveyStub).to.have.been.calledOnceWith(mockSurveyId); + expect(getTaxonomyByTsnsStub).to.have.been.calledOnceWith(mockTsns); + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.jsonValue).to.eql(mockFormattedItisResponse); + }); + + it('catches and re-throws error', async () => { + const dbConnectionObj = getMockDBConnection({ release: sinon.stub() }); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const mockSurveyId = 2; + const mockProjectId = 1; + + sinon.stub(ObservationService.prototype, 'getObservedSpeciesForSurvey').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: String(mockProjectId), + surveyId: String(mockSurveyId) + }; + + try { + const requestHandler = getSurveyObservedSpecies(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect.fail(); + } catch (actualError) { + expect(dbConnectionObj.release).to.have.been.called; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.ts new file mode 100644 index 0000000000..369be5171c --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.ts @@ -0,0 +1,140 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../database/db'; +import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; +import { ObservationService } from '../../../../../../../services/observation-service'; +import { PlatformService } from '../../../../../../../services/platform-service'; +import { getLogger } from '../../../../../../../utils/logger'; + +const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation/taxon'); + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [ + PROJECT_PERMISSION.COORDINATOR, + PROJECT_PERMISSION.COLLABORATOR, + PROJECT_PERMISSION.OBSERVER + ], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR, SYSTEM_ROLE.SYSTEM_ADMIN], + discriminator: 'SystemRole' + } + ] + }; + }), + getSurveyObservedSpecies() +]; + +GET.apiDoc = { + description: 'Get observed species for a survey', + tags: ['observation'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + } + ], + responses: { + 200: { + description: 'Survey observed species get response.', + content: { + 'application/json': { + schema: { + type: 'array', + description: 'Array of objects describing observed species in the survey', + items: { + type: 'object', + additionalProperties: false, + properties: { + tsn: { type: 'number', description: 'The TSN of the observed species' }, + commonNames: { + type: 'array', + items: { type: 'string' }, + description: 'Common names of the observed species' + }, + scientificName: { type: 'string', description: 'Scientific name of the observed species' } + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Fetch species that were observed in the survey + * + * @export + * @return {*} {RequestHandler} + */ +export function getSurveyObservedSpecies(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + defaultLog.debug({ label: 'getSurveyObservedSpecies', surveyId }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const observationService = new ObservationService(connection); + const platformService = new PlatformService(connection); + + const observedSpecies = await observationService.getObservedSpeciesForSurvey(surveyId); + + const species = await platformService.getTaxonomyByTsns(observedSpecies.flatMap((species) => species.itis_tsn)); + + const formattedResponse = species.map((taxon) => ({ ...taxon, tsn: taxon.tsn })); + + return res.status(200).json(formattedResponse); + } catch (error) { + defaultLog.error({ label: 'getSurveyObservedSpecies', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.test.ts index 2a42958220..d4212a07eb 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.test.ts @@ -2,12 +2,12 @@ import chai, { expect } from 'chai'; import { describe } from 'mocha'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; +import { createSurveySampleMethodRecord, getSurveySampleMethodRecords } from '.'; import * as db from '../../../../../../../../database/db'; import { HTTPError } from '../../../../../../../../errors/http-error'; +import { SampleLocationService } from '../../../../../../../../services/sample-location-service'; import { SampleMethodService } from '../../../../../../../../services/sample-method-service'; import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../../__mocks__/db'; -import * as create_survey_sample_method_record from './index'; -import * as get_survey_sample_method_record from './index'; chai.use(sinonChai); @@ -24,7 +24,7 @@ describe('getSurveySampleMethodRecords', () => { const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); try { - const requestHandler = get_survey_sample_method_record.getSurveySampleMethodRecords(); + const requestHandler = getSurveySampleMethodRecords(); await requestHandler(mockReq, mockRes, mockNext); expect.fail(); } catch (actualError) { @@ -47,7 +47,7 @@ describe('getSurveySampleMethodRecords', () => { sinon.stub(SampleMethodService.prototype, 'getSampleMethodsForSurveySampleSiteId').rejects(new Error('an error')); try { - const requestHandler = get_survey_sample_method_record.getSurveySampleMethodRecords(); + const requestHandler = getSurveySampleMethodRecords(); await requestHandler(mockReq, mockRes, mockNext); expect.fail(); @@ -83,7 +83,7 @@ describe('getSurveySampleMethodRecords', () => { sinon.stub(SampleMethodService.prototype, 'getSampleMethodsForSurveySampleSiteId').resolves([sampleMethod]); - const requestHandler = get_survey_sample_method_record.getSurveySampleMethodRecords(); + const requestHandler = getSurveySampleMethodRecords(); await requestHandler(mockReq, mockRes, mockNext); @@ -93,7 +93,7 @@ describe('getSurveySampleMethodRecords', () => { }); }); -describe('createSurveySampleSiteRecord', () => { +describe('createSurveySampleMethodRecord', () => { const dbConnectionObj = getMockDBConnection(); const sampleReq = { @@ -114,7 +114,7 @@ describe('createSurveySampleSiteRecord', () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); try { - const result = create_survey_sample_method_record.createSurveySampleSiteRecord(); + const result = createSurveySampleMethodRecord(); await result( { ...sampleReq, params: { ...sampleReq.params, surveySampleSiteId: null } }, null as unknown as any, @@ -130,6 +130,23 @@ describe('createSurveySampleSiteRecord', () => { it('should work', async () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + const getSurveySampleSiteByIdStub = sinon + .stub(SampleLocationService.prototype, 'getSurveySampleSiteById') + .resolves({ + survey_sample_site_id: 1, + survey_id: 1, + name: 'name', + description: 'desc', + geometry: null, + geography: 'geography', + geojson: {}, + create_date: 'date', + create_user: 1, + update_date: 'date', + update_user: 1, + revision_count: 1 + }); + const insertSurveyParticipantStub = sinon.stub(SampleMethodService.prototype, 'insertSampleMethod').resolves(); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -155,11 +172,12 @@ describe('createSurveySampleSiteRecord', () => { sampleMethod: [sampleMethod] }; - const requestHandler = create_survey_sample_method_record.createSurveySampleSiteRecord(); + const requestHandler = createSurveySampleMethodRecord(); await requestHandler(mockReq, mockRes, mockNext); expect(mockRes.status).to.have.been.calledWith(201); + expect(getSurveySampleSiteByIdStub).to.have.been.calledOnce; expect(insertSurveyParticipantStub).to.have.been.calledOnce; }); }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.ts index 2350e6d39c..fb6a28625b 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.ts @@ -216,7 +216,7 @@ export const POST: Operation = [ ] }; }), - createSurveySampleSiteRecord() + createSurveySampleMethodRecord() ]; POST.apiDoc = { @@ -302,7 +302,7 @@ POST.apiDoc = { } }; -export function createSurveySampleSiteRecord(): RequestHandler { +export function createSurveySampleMethodRecord(): RequestHandler { return async (req, res) => { if (!req.params.surveySampleSiteId) { throw new HTTP400('Missing required param `surveySampleSiteId`'); @@ -318,9 +318,10 @@ export function createSurveySampleSiteRecord(): RequestHandler { const connection = getDBConnection(req.keycloak_token); try { - const sampleSiteService = new SampleLocationService(connection); + const sampleLocationService = new SampleLocationService(connection); + + const sampleSite = await sampleLocationService.getSurveySampleSiteById(surveyId, surveySampleSiteId); - const sampleSite = sampleSiteService.getSurveySampleSiteById(surveyId, surveySampleSiteId); if (!sampleSite) { throw new HTTP400('The given sample site does not belong to the given survey'); } @@ -340,7 +341,7 @@ export function createSurveySampleSiteRecord(): RequestHandler { return res.status(201).send(); } catch (error) { - defaultLog.error({ label: 'insertProjectParticipants', message: 'error', error }); + defaultLog.error({ label: 'createSurveySampleMethodRecord', message: 'error', error }); await connection.rollback(); throw error; } finally { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/delete.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/delete.test.ts index a7c91d6fa9..a01bd67b56 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/delete.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/delete.test.ts @@ -4,7 +4,7 @@ import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import * as db from '../../../../../../database/db'; import { HTTPError } from '../../../../../../errors/http-error'; -import { ObservationService } from '../../../../../../services/observation-service'; +import { SampleMethodService } from '../../../../../../services/sample-method-service'; import { TechniqueService } from '../../../../../../services/technique-service'; import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; import { deleteSurveyTechniqueRecords } from './delete'; @@ -20,8 +20,8 @@ describe('deleteSurveyTechniqueRecords', () => { const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const getObservationsCountByTechniqueIdsStub = sinon - .stub(ObservationService.prototype, 'getObservationsCountByTechniqueIds') + const getSampleMethodsCountForTechniqueIdsStub = sinon + .stub(SampleMethodService.prototype, 'getSampleMethodsCountForTechniqueIds') .resolves(0); const deleteTechniqueStub = sinon @@ -45,8 +45,8 @@ describe('deleteSurveyTechniqueRecords', () => { await requestHandler(mockReq, mockRes, mockNext); expect.fail(); } catch (actualError) { - expect(getObservationsCountByTechniqueIdsStub).to.have.been.calledOnce; - expect(deleteTechniqueStub).to.have.been.calledOnce; + expect(getSampleMethodsCountForTechniqueIdsStub).to.have.been.calledOnce; + expect(deleteTechniqueStub).to.have.been.calledThrice; expect(mockDBConnection.rollback).to.have.been.calledOnce; expect(mockDBConnection.release).to.have.been.calledOnce; @@ -59,8 +59,8 @@ describe('deleteSurveyTechniqueRecords', () => { const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const getObservationsCountByTechniqueIdsStub = sinon - .stub(ObservationService.prototype, 'getObservationsCountByTechniqueIds') + const getSampleMethodsCountForTechniqueIdsStub = sinon + .stub(SampleMethodService.prototype, 'getSampleMethodsCountForTechniqueIds') .resolves(10); // technique records are associated to 10 observation records const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -80,13 +80,13 @@ describe('deleteSurveyTechniqueRecords', () => { await requestHandler(mockReq, mockRes, mockNext); expect.fail(); } catch (actualError) { - expect(getObservationsCountByTechniqueIdsStub).to.have.been.calledOnce; + expect(getSampleMethodsCountForTechniqueIdsStub).to.have.been.calledOnce; expect(mockDBConnection.rollback).to.have.been.calledOnce; expect(mockDBConnection.release).to.have.been.calledOnce; expect((actualError as HTTPError).message).to.equal( - 'Cannot delete a technique that is associated with an observation' + 'Cannot delete a technique that is associated with a sampling site' ); expect((actualError as HTTPError).status).to.equal(409); } @@ -96,8 +96,8 @@ describe('deleteSurveyTechniqueRecords', () => { const mockDBConnection = getMockDBConnection({ commit: sinon.stub(), release: sinon.stub() }); sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const getObservationsCountByTechniqueIdsStub = sinon - .stub(ObservationService.prototype, 'getObservationsCountByTechniqueIds') + const getSampleMethodsCountForTechniqueIdsStub = sinon + .stub(SampleMethodService.prototype, 'getSampleMethodsCountForTechniqueIds') .resolves(0); const deleteTechniqueStub = sinon.stub(TechniqueService.prototype, 'deleteTechnique').resolves(); @@ -117,7 +117,7 @@ describe('deleteSurveyTechniqueRecords', () => { await requestHandler(mockReq, mockRes, mockNext); - expect(getObservationsCountByTechniqueIdsStub).to.have.been.calledOnce; + expect(getSampleMethodsCountForTechniqueIdsStub).to.have.been.calledOnce; expect(deleteTechniqueStub).to.have.been.calledThrice; expect(mockDBConnection.commit).to.have.been.calledOnce; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/delete.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/delete.ts index 9a21fdf7b0..f1c62cd0ef 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/delete.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/delete.ts @@ -4,7 +4,7 @@ import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/rol import { getDBConnection } from '../../../../../../database/db'; import { HTTP409 } from '../../../../../../errors/http-error'; import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; -import { ObservationService } from '../../../../../../services/observation-service'; +import { SampleMethodService } from '../../../../../../services/sample-method-service'; import { TechniqueService } from '../../../../../../services/technique-service'; import { getLogger } from '../../../../../../utils/logger'; @@ -109,28 +109,25 @@ export function deleteSurveyTechniqueRecords(): RequestHandler { const surveyId = Number(req.params.surveyId); const methodTechniqueIds = req.body.methodTechniqueIds as number[]; - const connection = getDBConnection(req['keycloak_token']); + const connection = getDBConnection(req.keycloak_token); try { await connection.open(); - const observationService = new ObservationService(connection); + const sampleMethodService = new SampleMethodService(connection); - const observationCount = await observationService.getObservationsCountByTechniqueIds( - surveyId, - methodTechniqueIds - ); + const samplingMethodsCount = await sampleMethodService.getSampleMethodsCountForTechniqueIds(methodTechniqueIds); - if (observationCount > 0) { - throw new HTTP409('Cannot delete a technique that is associated with an observation'); + if (samplingMethodsCount > 0) { + throw new HTTP409('Cannot delete a technique that is associated with a sampling site'); } const techniqueService = new TechniqueService(connection); - // TODO Update to handle all deletes in one request rather than one at a time - for (const methodTechniqueId of methodTechniqueIds) { - await techniqueService.deleteTechnique(surveyId, methodTechniqueId); - } + // TODO: Update to handle all deletes in one request rather than one at a time + await Promise.all( + methodTechniqueIds.map((methodTechniqueId) => techniqueService.deleteTechnique(surveyId, methodTechniqueId)) + ); await connection.commit(); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.ts index fd664d79c3..7f08d1ba13 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.ts @@ -2,7 +2,6 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../database/db'; -import { HTTP400 } from '../../../../../../errors/http-error'; import { paginationRequestQueryParamSchema, paginationResponseSchema @@ -112,13 +111,12 @@ POST.apiDoc = { */ export function createTechniques(): RequestHandler { return async (req, res) => { - const connection = getDBConnection(req['keycloak_token']); + const surveyId = Number(req.params.surveyId); + const connection = getDBConnection(req.keycloak_token); try { await connection.open(); - const surveyId = Number(req.params.surveyId); - const techniqueService = new TechniqueService(connection); await techniqueService.insertTechniquesForSurvey(surveyId, req.body.techniques); @@ -236,16 +234,12 @@ GET.apiDoc = { */ export function getTechniques(): RequestHandler { return async (req, res) => { - if (!req.params.surveyId) { - throw new HTTP400('Missing required param `surveyId`'); - } - - const connection = getDBConnection(req['keycloak_token']); + const surveyId = Number(req.params.surveyId); + const connection = getDBConnection(req.keycloak_token); try { await connection.open(); - const surveyId = Number(req.params.surveyId); const paginationOptions = makePaginationOptionsFromRequest(req); const techniqueService = new TechniqueService(connection); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/{techniqueId}/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/{techniqueId}/index.test.ts index 6cd5ec809b..9a42471b43 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/{techniqueId}/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/{techniqueId}/index.test.ts @@ -6,7 +6,7 @@ import { deleteTechnique, updateTechnique } from '.'; import * as db from '../../../../../../../database/db'; import { HTTPError } from '../../../../../../../errors/http-error'; import { AttractantService } from '../../../../../../../services/attractants-service'; -import { ObservationService } from '../../../../../../../services/observation-service'; +import { SampleMethodService } from '../../../../../../../services/sample-method-service'; import { TechniqueAttributeService } from '../../../../../../../services/technique-attributes-service'; import { TechniqueService } from '../../../../../../../services/technique-service'; import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../__mocks__/db'; @@ -30,8 +30,8 @@ describe('deleteTechnique', () => { techniqueId: '3' }; - const getObservationsCountByTechniqueIdsStub = sinon - .stub(ObservationService.prototype, 'getObservationsCountByTechniqueIds') + const getSampleMethodsCountForTechniqueIdsStub = sinon + .stub(SampleMethodService.prototype, 'getSampleMethodsCountForTechniqueIds') .resolves(0); const deleteTechniqueStub = sinon @@ -44,14 +44,14 @@ describe('deleteTechnique', () => { await requestHandler(mockReq, mockRes, mockNext); expect.fail(); } catch (actualError) { - expect(getObservationsCountByTechniqueIdsStub).to.have.been.calledOnceWith(2, [3]); + expect(getSampleMethodsCountForTechniqueIdsStub).to.have.been.calledOnceWith([3]); expect(deleteTechniqueStub).to.have.been.calledOnceWith(2, 3); expect((actualError as HTTPError).message).to.equal('a test error'); } }); - it('throws an error if any technique records are associated to an observation record', async () => { + it('throws an error if any technique records are associated to a sampling site', async () => { const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); sinon.stub(db, 'getDBConnection').returns(mockDBConnection); @@ -63,8 +63,8 @@ describe('deleteTechnique', () => { techniqueId: '3' }; - const getObservationsCountByTechniqueIdsStub = sinon - .stub(ObservationService.prototype, 'getObservationsCountByTechniqueIds') + const getSampleMethodsCountForTechniqueIdsStub = sinon + .stub(SampleMethodService.prototype, 'getSampleMethodsCountForTechniqueIds') .resolves(10); // technique records are associated to 10 observation records const requestHandler = deleteTechnique(); @@ -73,13 +73,13 @@ describe('deleteTechnique', () => { await requestHandler(mockReq, mockRes, mockNext); expect.fail(); } catch (actualError) { - expect(getObservationsCountByTechniqueIdsStub).to.have.been.calledOnce; + expect(getSampleMethodsCountForTechniqueIdsStub).to.have.been.calledOnce; expect(mockDBConnection.rollback).to.have.been.calledOnce; expect(mockDBConnection.release).to.have.been.calledOnce; expect((actualError as HTTPError).message).to.equal( - 'Cannot delete a technique that is associated with an observation' + 'Cannot delete a technique that is associated with a sampling site' ); expect((actualError as HTTPError).status).to.equal(409); } @@ -97,8 +97,8 @@ describe('deleteTechnique', () => { techniqueId: '3' }; - const getObservationsCountByTechniqueIdsStub = sinon - .stub(ObservationService.prototype, 'getObservationsCountByTechniqueIds') + const getSampleMethodsCountForTechniqueIdsStub = sinon + .stub(SampleMethodService.prototype, 'getSampleMethodsCountForTechniqueIds') .resolves(0); const deleteTechniqueStub = sinon.stub(TechniqueService.prototype, 'deleteTechnique').resolves(); @@ -107,7 +107,7 @@ describe('deleteTechnique', () => { await requestHandler(mockReq, mockRes, mockNext); - expect(getObservationsCountByTechniqueIdsStub).to.have.been.calledOnceWith(2, [3]); + expect(getSampleMethodsCountForTechniqueIdsStub).to.have.been.calledOnceWith([3]); expect(deleteTechniqueStub).to.have.been.calledOnceWith(2, 3); expect(mockRes.statusValue).to.eql(200); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/{techniqueId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/{techniqueId}/index.ts index 11249e0e30..72454c0b40 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/{techniqueId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/{techniqueId}/index.ts @@ -7,7 +7,7 @@ import { techniqueUpdateSchema, techniqueViewSchema } from '../../../../../../.. import { ITechniquePutData } from '../../../../../../../repositories/technique-repository'; import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; import { AttractantService } from '../../../../../../../services/attractants-service'; -import { ObservationService } from '../../../../../../../services/observation-service'; +import { SampleMethodService } from '../../../../../../../services/sample-method-service'; import { TechniqueAttributeService } from '../../../../../../../services/technique-attributes-service'; import { TechniqueService } from '../../../../../../../services/technique-service'; import { getLogger } from '../../../../../../../utils/logger'; @@ -103,22 +103,19 @@ DELETE.apiDoc = { */ export function deleteTechnique(): RequestHandler { return async (req, res) => { - const connection = getDBConnection(req['keycloak_token']); + const methodTechniqueId = Number(req.params.techniqueId); + const surveyId = Number(req.params.surveyId); + const connection = getDBConnection(req.keycloak_token); try { await connection.open(); - const methodTechniqueId = Number(req.params.techniqueId); - const surveyId = Number(req.params.surveyId); + const sampleMethodService = new SampleMethodService(connection); - const observationService = new ObservationService(connection); + const samplingMethodsCount = await sampleMethodService.getSampleMethodsCountForTechniqueIds([methodTechniqueId]); - const observationCount = await observationService.getObservationsCountByTechniqueIds(surveyId, [ - methodTechniqueId - ]); - - if (observationCount > 0) { - throw new HTTP409('Cannot delete a technique that is associated with an observation'); + if (samplingMethodsCount > 0) { + throw new HTTP409('Cannot delete a technique that is associated with a sampling site'); } const techniqueService = new TechniqueService(connection); @@ -239,16 +236,14 @@ PUT.apiDoc = { */ export function updateTechnique(): RequestHandler { return async (req, res) => { - const connection = getDBConnection(req['keycloak_token']); + const surveyId = Number(req.params.surveyId); + const methodTechniqueId = Number(req.params.techniqueId); + const technique: ITechniquePutData = req.body.technique; + const connection = getDBConnection(req.keycloak_token); try { await connection.open(); - const surveyId = Number(req.params.surveyId); - const methodTechniqueId = Number(req.params.techniqueId); - - const technique: ITechniquePutData = req.body.technique; - const { attributes, attractants, ...techniqueRow } = technique; // Update the technique record @@ -383,14 +378,13 @@ GET.apiDoc = { */ export function getTechniqueById(): RequestHandler { return async (req, res) => { - const connection = getDBConnection(req['keycloak_token']); + const surveyId = Number(req.params.surveyId); + const methodTechniqueId = Number(req.params.techniqueId); + const connection = getDBConnection(req.keycloak_token); try { await connection.open(); - const surveyId = Number(req.params.surveyId); - const methodTechniqueId = Number(req.params.techniqueId); - const techniqueService = new TechniqueService(connection); const sampleSite = await techniqueService.getTechniqueById(surveyId, methodTechniqueId); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts index 6c2cb398d7..e9c43481c7 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts @@ -20,8 +20,6 @@ import { authorizeRequestHandler } from '../../../../../request-handlers/securit import { SurveyService } from '../../../../../services/survey-service'; import { getLogger } from '../../../../../utils/logger'; -('../../../../../openapi/schemas/survey'); - const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/view'); export const GET: Operation = [ diff --git a/api/src/paths/standards/environment/index.test.ts b/api/src/paths/standards/environment/index.test.ts new file mode 100644 index 0000000000..2c7a3c0568 --- /dev/null +++ b/api/src/paths/standards/environment/index.test.ts @@ -0,0 +1,89 @@ +import chai, { expect } from 'chai'; +import { afterEach, describe, it } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as db from '../../../database/db'; +import { HTTPError } from '../../../errors/http-error'; +import { StandardsService } from '../../../services/standards-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../__mocks__/db'; +import { getEnvironmentStandards } from './index'; // Adjust the import path based on your file structure + +chai.use(sinonChai); + +describe('standards/environment', () => { + describe('getEnvironmentStandards', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should retrieve environment standards successfully', async () => { + const mockResponse = { + quantitative: [ + { name: 'Quantitative Standard 1', description: 'Description 1', unit: 'Unit' }, + { name: 'Quantitative Standard 2', description: 'Description 2', unit: 'Unit' } + ], + qualitative: [ + { + name: 'Qualitative Standard 1', + description: 'Description 1', + options: [ + { name: 'Option 1', description: 'Option 1 Description' }, + { name: 'Option 2', description: 'Option 2 Description' } + ] + }, + { + name: 'Qualitative Standard 2', + description: 'Description 2', + options: [ + { name: 'Option 3', description: 'Option 3 Description' }, + { name: 'Option 4', description: 'Option 4 Description' } + ] + } + ] + }; + + const mockDBConnection = getMockDBConnection(); + + sinon.stub(db, 'getAPIUserDBConnection').returns(mockDBConnection); + + sinon.stub(StandardsService.prototype, 'getEnvironmentStandards').resolves(mockResponse); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const requestHandler = getEnvironmentStandards(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.status).to.have.been.calledWith(200); + expect(mockRes.json).to.have.been.calledWith(mockResponse); + }); + + it('catches and re-throws error', async () => { + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub() + }); + + sinon.stub(db, 'getAPIUserDBConnection').returns(mockDBConnection); + + sinon + .stub(StandardsService.prototype, 'getEnvironmentStandards') + .rejects(new Error('Failed to retrieve environment standards')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + try { + const requestHandler = getEnvironmentStandards(); + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('Failed to retrieve environment standards'); + } + }); + }); +}); diff --git a/api/src/paths/standards/environment/index.ts b/api/src/paths/standards/environment/index.ts new file mode 100644 index 0000000000..42e2e24176 --- /dev/null +++ b/api/src/paths/standards/environment/index.ts @@ -0,0 +1,83 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { getAPIUserDBConnection } from '../../../database/db'; +import { EnvironmentStandardsSchema } from '../../../openapi/schemas/standards'; +import { StandardsService } from '../../../services/standards-service'; +import { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('paths/projects'); + +export const GET: Operation = [getEnvironmentStandards()]; + +GET.apiDoc = { + description: 'Gets lookup values for environment variables', + tags: ['standards'], + parameters: [ + { + in: 'query', + name: 'keyword', + required: false, + schema: { + type: 'string', + nullable: true + } + } + ], + security: [], + responses: { + 200: { + description: 'Environment data standards response object.', + content: { + 'application/json': { + schema: EnvironmentStandardsSchema + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Get species data standards + * + * @returns {RequestHandler} + */ +export function getEnvironmentStandards(): RequestHandler { + return async (req, res) => { + const connection = getAPIUserDBConnection(); + + try { + await connection.open(); + + const standardsService = new StandardsService(connection); + + const keyword = (req.query.keyword as string) ?? ''; + + const response = await standardsService.getEnvironmentStandards(keyword); + + await connection.commit(); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'getEnvironmentStandards', message: 'error', error }); + connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/standards/methods/index.test.ts b/api/src/paths/standards/methods/index.test.ts new file mode 100644 index 0000000000..3a5814a930 --- /dev/null +++ b/api/src/paths/standards/methods/index.test.ts @@ -0,0 +1,96 @@ +import chai, { expect } from 'chai'; +import { afterEach, describe, it } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getMethodStandards } from '.'; +import * as db from '../../../database/db'; +import { HTTPError } from '../../../errors/http-error'; +import { StandardsService } from '../../../services/standards-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../__mocks__/db'; + +chai.use(sinonChai); + +describe('standards/environment', () => { + describe('getMethodStandards', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should retrieve environment standards successfully', async () => { + const mockResponse = [ + { + method_lookup_id: 1, + name: 'Name', + description: 'Description', + attributes: { + quantitative: [ + { name: 'Quantitative Standard 1', description: 'Description 1', unit: 'Unit' }, + { name: 'Quantitative Standard 2', description: 'Description 2', unit: 'Unit' } + ], + qualitative: [ + { + name: 'Qualitative Standard 1', + description: 'Description 1', + options: [ + { name: 'Option 1', description: 'Option 1 Description' }, + { name: 'Option 2', description: 'Option 2 Description' } + ] + }, + { + name: 'Qualitative Standard 2', + description: 'Description 2', + options: [ + { name: 'Option 3', description: 'Option 3 Description' }, + { name: 'Option 4', description: 'Option 4 Description' } + ] + } + ] + } + } + ]; + + const mockDBConnection = getMockDBConnection(); + + sinon.stub(db, 'getAPIUserDBConnection').returns(mockDBConnection); + + sinon.stub(StandardsService.prototype, 'getMethodStandards').resolves(mockResponse); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const requestHandler = getMethodStandards(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.status).to.have.been.calledWith(200); + expect(mockRes.json).to.have.been.calledWith(mockResponse); + }); + + it('catches and re-throws error', async () => { + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub() + }); + + sinon.stub(db, 'getAPIUserDBConnection').returns(mockDBConnection); + + sinon + .stub(StandardsService.prototype, 'getMethodStandards') + .rejects(new Error('Failed to retrieve environment standards')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + try { + const requestHandler = getMethodStandards(); + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('Failed to retrieve environment standards'); + } + }); + }); +}); diff --git a/api/src/paths/standards/methods/index.ts b/api/src/paths/standards/methods/index.ts new file mode 100644 index 0000000000..f95e4d6bca --- /dev/null +++ b/api/src/paths/standards/methods/index.ts @@ -0,0 +1,83 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { getAPIUserDBConnection } from '../../../database/db'; +import { MethodStandardSchema } from '../../../openapi/schemas/standards'; +import { StandardsService } from '../../../services/standards-service'; +import { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('paths/standards/methods'); + +export const GET: Operation = [getMethodStandards()]; + +GET.apiDoc = { + description: 'Gets lookup values for method variables', + tags: ['standards'], + parameters: [ + { + in: 'query', + name: 'keyword', + required: false, + schema: { + type: 'string', + nullable: true + } + } + ], + security: [], + responses: { + 200: { + description: 'Method data standards response object.', + content: { + 'application/json': { + schema: MethodStandardSchema + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Get species data standards + * + * @returns {RequestHandler} + */ +export function getMethodStandards(): RequestHandler { + return async (req, res) => { + const connection = getAPIUserDBConnection(); + + try { + await connection.open(); + + const standardsService = new StandardsService(connection); + + const keyword = (req.query.keyword as string) ?? ''; + + const response = await standardsService.getMethodStandards(keyword); + + await connection.commit(); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'getMethodStandards', message: 'error', error }); + connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/standards/taxon/{tsn}/index.ts b/api/src/paths/standards/taxon/{tsn}/index.ts index a46bb597a1..483233ae06 100644 --- a/api/src/paths/standards/taxon/{tsn}/index.ts +++ b/api/src/paths/standards/taxon/{tsn}/index.ts @@ -1,26 +1,12 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; -import { SYSTEM_ROLE } from '../../../../constants/roles'; import { getDBConnection } from '../../../../database/db'; -import { authorizeRequestHandler } from '../../../../request-handlers/security/authorization'; import { StandardsService } from '../../../../services/standards-service'; import { getLogger } from '../../../../utils/logger'; const defaultLog = getLogger('paths/projects'); -export const GET: Operation = [ - authorizeRequestHandler(() => { - return { - and: [ - { - validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], - discriminator: 'SystemRole' - } - ] - }; - }), - getSpeciesStandards() -]; +export const GET: Operation = [getSpeciesStandards()]; GET.apiDoc = { description: 'Gets lookup values for a tsn to describe what information can be uploaded for a given species.', @@ -35,7 +21,7 @@ GET.apiDoc = { required: true } ], - security: [{ Bearer: [] }], + security: [], responses: { 200: { description: 'Species data standards response object.', @@ -199,7 +185,8 @@ GET.apiDoc = { */ export function getSpeciesStandards(): RequestHandler { return async (req, res) => { - // TODO: const connection = getAPIUserDBConnection(); + // API user DB connection does not work, possible because user does not exist in Critterbase? + // const connection = getAPIUserDBConnection(); const connection = getDBConnection(req.keycloak_token); try { @@ -216,6 +203,7 @@ export function getSpeciesStandards(): RequestHandler { return res.status(200).json(getSpeciesStandardsResponse); } catch (error) { defaultLog.error({ label: 'getSpeciesStandards', message: 'error', error }); + connection.rollback(); throw error; } finally { connection.release(); diff --git a/api/src/paths/telemetry/code.test.ts b/api/src/paths/telemetry/code.test.ts index 442a39d752..e510c3b65f 100644 --- a/api/src/paths/telemetry/code.test.ts +++ b/api/src/paths/telemetry/code.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { SystemUser } from '../../repositories/user-repository'; -import { BctwService } from '../../services/bctw-service'; +import { BctwService } from '../../services/bctw-service/bctw-service'; import { getRequestHandlerMocks } from '../../__mocks__/db'; import { getCodeValues } from './code'; diff --git a/api/src/paths/telemetry/code.ts b/api/src/paths/telemetry/code.ts index 2386b3dc3c..a2537809a7 100644 --- a/api/src/paths/telemetry/code.ts +++ b/api/src/paths/telemetry/code.ts @@ -1,7 +1,7 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { authorizeRequestHandler } from '../../request-handlers/security/authorization'; -import { BctwService, getBctwUser } from '../../services/bctw-service'; +import { BctwService, getBctwUser } from '../../services/bctw-service/bctw-service'; import { getLogger } from '../../utils/logger'; const defaultLog = getLogger('paths/telemetry/code'); @@ -73,10 +73,12 @@ export function getCodeValues(): RequestHandler { const user = getBctwUser(req); const bctwService = new BctwService(user); + const codeHeader = String(req.query.codeHeader); try { const result = await bctwService.getCode(codeHeader); + return res.status(200).json(result); } catch (error) { defaultLog.error({ label: 'getCodeValues', message: 'error', error }); diff --git a/api/src/paths/telemetry/deployments.test.ts b/api/src/paths/telemetry/deployments.test.ts index facb4c577c..471f6a2f23 100644 --- a/api/src/paths/telemetry/deployments.test.ts +++ b/api/src/paths/telemetry/deployments.test.ts @@ -1,12 +1,13 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { SystemUser } from '../../repositories/user-repository'; -import { BctwService, IAllTelemetry } from '../../services/bctw-service'; +import { BctwTelemetryService, IAllTelemetry } from '../../services/bctw-service/bctw-telemetry-service'; import { getRequestHandlerMocks } from '../../__mocks__/db'; import { getAllTelemetryByDeploymentIds } from './deployments'; const mockTelemetry: IAllTelemetry[] = [ { + id: '123-123-123', telemetry_id: null, telemetry_manual_id: '123-123-123', deployment_id: '345-345-345', @@ -16,6 +17,7 @@ const mockTelemetry: IAllTelemetry[] = [ telemetry_type: 'manual' }, { + id: '567-567-567', telemetry_id: '567-567-567', telemetry_manual_id: null, deployment_id: '345-345-345', @@ -32,7 +34,7 @@ describe('getAllTelemetryByDeploymentIds', () => { }); it('should retrieve both manual and vendor telemetry', async () => { const mockGetTelemetry = sinon - .stub(BctwService.prototype, 'getAllTelemetryByDeploymentIds') + .stub(BctwTelemetryService.prototype, 'getAllTelemetryByDeploymentIds') .resolves(mockTelemetry); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -49,7 +51,9 @@ describe('getAllTelemetryByDeploymentIds', () => { }); it('should catch error', async () => { const mockError = new Error('test error'); - const mockGetTelemetry = sinon.stub(BctwService.prototype, 'getAllTelemetryByDeploymentIds').rejects(mockError); + const mockGetTelemetry = sinon + .stub(BctwTelemetryService.prototype, 'getAllTelemetryByDeploymentIds') + .rejects(mockError); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); diff --git a/api/src/paths/telemetry/deployments.ts b/api/src/paths/telemetry/deployments.ts index e4430dcdc3..035276ad86 100644 --- a/api/src/paths/telemetry/deployments.ts +++ b/api/src/paths/telemetry/deployments.ts @@ -1,12 +1,14 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; +import { AllTelemetrySchema } from '../../openapi/schemas/telemetry'; import { authorizeRequestHandler } from '../../request-handlers/security/authorization'; -import { BctwService, getBctwUser } from '../../services/bctw-service'; +import { getBctwUser } from '../../services/bctw-service/bctw-service'; +import { BctwTelemetryService } from '../../services/bctw-service/bctw-telemetry-service'; import { getLogger } from '../../utils/logger'; -const defaultLog = getLogger('paths/telemetry/manual'); +const defaultLog = getLogger('paths/telemetry/deployments'); -export const POST: Operation = [ +export const GET: Operation = [ authorizeRequestHandler(() => { return { and: [ @@ -19,14 +21,25 @@ export const POST: Operation = [ getAllTelemetryByDeploymentIds() ]; -POST.apiDoc = { - description: 'Get list of manual and vendor telemetry by deployment ids', +GET.apiDoc = { + description: 'Get manual and vendor telemetry for a set of deployment Ids', tags: ['telemetry'], security: [ { Bearer: [] } ], + parameters: [ + { + in: 'query', + name: 'bctwDeploymentIds', + schema: { + type: 'array', + items: { type: 'string', format: 'uuid', minimum: 1 } + }, + required: true + } + ], responses: { 200: { description: 'Manual and Vendor telemetry response object', @@ -34,20 +47,7 @@ POST.apiDoc = { 'application/json': { schema: { type: 'array', - items: { - type: 'object', - additionalProperties: false, - properties: { - id: { type: 'string' }, - deployment_id: { type: 'string', format: 'uuid' }, - telemetry_manual_id: { type: 'string', nullable: true }, - telemetry_id: { type: 'number', nullable: true }, - latitude: { type: 'number' }, - longitude: { type: 'number' }, - acquisition_date: { type: 'string' }, - telemetry_type: { type: 'string' } - } - } + items: AllTelemetrySchema } } } @@ -67,34 +67,20 @@ POST.apiDoc = { default: { $ref: '#/components/responses/default' } - }, - - requestBody: { - description: 'Request body', - required: true, - content: { - 'application/json': { - schema: { - title: 'BCTW deployment ids', - type: 'array', - minItems: 1, - items: { - title: 'BCTW deployment ids', - type: 'string', - format: 'uuid' - } - } - } - } } }; export function getAllTelemetryByDeploymentIds(): RequestHandler { return async (req, res) => { const user = getBctwUser(req); - const bctwService = new BctwService(user); + + const bctwTelemetryService = new BctwTelemetryService(user); + try { - const result = await bctwService.getAllTelemetryByDeploymentIds(req.body); + const bctwDeploymentIds = req.query.bctwDeploymentIds as string[]; + + const result = await bctwTelemetryService.getAllTelemetryByDeploymentIds(bctwDeploymentIds); + return res.status(200).json(result); } catch (error) { defaultLog.error({ label: 'getAllTelemetryByDeploymentIds', message: 'error', error }); diff --git a/api/src/paths/telemetry/device/index.test.ts b/api/src/paths/telemetry/device/index.test.ts index 9bfc486707..db121c53aa 100644 --- a/api/src/paths/telemetry/device/index.test.ts +++ b/api/src/paths/telemetry/device/index.test.ts @@ -3,7 +3,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { HTTPError } from '../../../errors/http-error'; import { SystemUser } from '../../../repositories/user-repository'; -import { BctwService } from '../../../services/bctw-service'; +import { BctwDeviceService } from '../../../services/bctw-service/bctw-device-service'; import { getRequestHandlerMocks } from '../../../__mocks__/db'; import { POST, upsertDevice } from './index'; @@ -21,7 +21,7 @@ describe('upsertDevice', () => { }); it('upsert device details', async () => { - const mockUpsertDevice = sinon.stub(BctwService.prototype, 'updateDevice'); + const mockUpsertDevice = sinon.stub(BctwDeviceService.prototype, 'updateDevice'); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -37,7 +37,7 @@ describe('upsertDevice', () => { it('catches and re-throws errors', async () => { const mockError = new Error('a test error'); - const mockBctwService = sinon.stub(BctwService.prototype, 'updateDevice').rejects(mockError); + const mockBctwService = sinon.stub(BctwDeviceService.prototype, 'updateDevice').rejects(mockError); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -49,7 +49,7 @@ describe('upsertDevice', () => { expect.fail(); } catch (actualError) { expect((mockError as HTTPError).message).to.eql('a test error'); - expect(mockBctwService.calledOnce).to.be.true; + expect(mockBctwService).to.have.been.calledOnce; } }); }); diff --git a/api/src/paths/telemetry/device/index.ts b/api/src/paths/telemetry/device/index.ts index a166f4944b..4d26d80342 100644 --- a/api/src/paths/telemetry/device/index.ts +++ b/api/src/paths/telemetry/device/index.ts @@ -1,7 +1,8 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; -import { BctwService, getBctwUser } from '../../../services/bctw-service'; +import { BctwDeviceService } from '../../../services/bctw-service/bctw-device-service'; +import { getBctwUser } from '../../../services/bctw-service/bctw-service'; import { getLogger } from '../../../utils/logger'; const defaultLog = getLogger('paths/telemetry/device/{deviceId}'); @@ -94,9 +95,9 @@ export function upsertDevice(): RequestHandler { return async (req, res) => { const user = getBctwUser(req); - const bctwService = new BctwService(user); + const bctwDeviceService = new BctwDeviceService(user); try { - const results = await bctwService.updateDevice(req.body); + const results = await bctwDeviceService.updateDevice(req.body); return res.status(200).json(results); } catch (error) { defaultLog.error({ label: 'upsertDevice', message: 'error', error }); diff --git a/api/src/paths/telemetry/device/{deviceId}.test.ts b/api/src/paths/telemetry/device/{deviceId}.test.ts deleted file mode 100644 index 6c90dff8f5..0000000000 --- a/api/src/paths/telemetry/device/{deviceId}.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import Ajv from 'ajv'; -import { expect } from 'chai'; -import sinon from 'sinon'; -import { SystemUser } from '../../../repositories/user-repository'; -import { BctwService } from '../../../services/bctw-service'; -import { getRequestHandlerMocks } from '../../../__mocks__/db'; -import { GET, getDeviceDetails } from './{deviceId}'; - -describe('getDeviceDetails', () => { - afterEach(() => { - sinon.restore(); - }); - - describe('openapi schema', () => { - const ajv = new Ajv(); - - it('is valid openapi v3 schema', () => { - expect(ajv.validateSchema(GET.apiDoc as unknown as object)).to.be.true; - }); - }); - - it('gets device details', async () => { - const mockGetDeviceDetails = sinon.stub(BctwService.prototype, 'getDeviceDetails').resolves([]); - const mockGetDeployments = sinon.stub(BctwService.prototype, 'getDeviceDeployments').resolves([]); - const mockGetKeyXDetails = sinon.stub(BctwService.prototype, 'getKeyXDetails').resolves([]); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq.system_user = { user_identifier: 'user', user_guid: 'guid' } as SystemUser; - - const requestHandler = getDeviceDetails(); - - await requestHandler(mockReq, mockRes, mockNext); - - expect(mockRes.statusValue).to.equal(200); - expect(mockGetDeviceDetails).to.have.been.calledOnce; - expect(mockGetDeployments).to.have.been.calledOnce; - expect(mockGetKeyXDetails).to.have.been.calledOnce; - }); - - it('catches and re-throws errors', async () => { - const mockError = new Error('test error'); - const mockGetDeviceDetails = sinon.stub(BctwService.prototype, 'getDeviceDetails').rejects(mockError); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq.system_user = { user_identifier: 'user', user_guid: 'guid' } as SystemUser; - - const requestHandler = getDeviceDetails(); - - try { - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (error) { - expect(error).to.equal(mockError); - expect(mockGetDeviceDetails).to.have.been.calledOnce; - expect(mockNext).not.to.have.been.called; - } - }); -}); diff --git a/api/src/paths/telemetry/device/{deviceId}.ts b/api/src/paths/telemetry/device/{deviceId}.ts deleted file mode 100644 index 7e1f4bfebe..0000000000 --- a/api/src/paths/telemetry/device/{deviceId}.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { RequestHandler } from 'express'; -import { Operation } from 'express-openapi'; -import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; -import { BctwService, getBctwUser } from '../../../services/bctw-service'; -import { getLogger } from '../../../utils/logger'; - -const defaultLog = getLogger('paths/telemetry/device/{deviceId}'); - -export const GET: Operation = [ - authorizeRequestHandler(() => { - return { - and: [ - { - discriminator: 'SystemUser' - } - ] - }; - }), - getDeviceDetails() -]; - -GET.apiDoc = { - description: 'Get a list of metadata changes to a device from the exterior telemetry system.', - tags: ['telemetry'], - security: [ - { - Bearer: [] - } - ], - responses: { - 200: { - description: 'Device change history response', - content: { - 'application/json': { - schema: { - type: 'object' - } - } - } - }, - 400: { - $ref: '#/components/responses/400' - }, - 401: { - $ref: '#/components/responses/401' - }, - 403: { - $ref: '#/components/responses/403' - }, - 500: { - $ref: '#/components/responses/500' - }, - default: { - $ref: '#/components/responses/default' - } - } -}; - -export function getDeviceDetails(): RequestHandler { - return async (req, res) => { - const user = getBctwUser(req); - - const bctwService = new BctwService(user); - const deviceId = Number(req.params.deviceId); - const deviceMake = String(req.query.make); - - try { - const results = await bctwService.getDeviceDetails(deviceId, deviceMake); - const deployments = await bctwService.getDeviceDeployments(deviceId, deviceMake); - const keyXResult = await bctwService.getKeyXDetails([deviceId]); - const keyXStatus = keyXResult?.[0]?.keyx?.idcollar === deviceId; - const retObj = { - device: results?.[0], - keyXStatus: keyXStatus, - deployments: deployments - }; - return res.status(200).json(retObj); - } catch (error) { - defaultLog.error({ label: 'getDeviceDetails', message: 'error', error }); - throw error; - } - }; -} diff --git a/api/src/paths/telemetry/index.ts b/api/src/paths/telemetry/index.ts index be9e8ae91b..1f2f46cda5 100644 --- a/api/src/paths/telemetry/index.ts +++ b/api/src/paths/telemetry/index.ts @@ -2,7 +2,7 @@ import { Request, RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { SYSTEM_ROLE } from '../../constants/roles'; import { getDBConnection } from '../../database/db'; -import { ITelemetryAdvancedFilters } from '../../models/telemetry-view'; +import { IAllTelemetryAdvancedFilters } from '../../models/telemetry-view'; import { paginationRequestQueryParamSchema, paginationResponseSchema } from '../../openapi/schemas/pagination'; import { authorizeRequestHandler, userHasValidRole } from '../../request-handlers/security/authorization'; import { TelemetryService } from '../../services/telemetry-service'; @@ -99,7 +99,7 @@ GET.apiDoc = { additionalProperties: false, properties: { telemetry_id: { - type: 'number', + type: 'string', description: 'The BCTW telemetry record ID.' }, acquisition_date: { @@ -234,12 +234,12 @@ export function findTelemetry(): RequestHandler { /** * Parse the query parameters from the request into the expected format. * - * @param {Request} req - * @return {*} {ITelemetryAdvancedFilters} + * @param {Request} req + * @return {*} {IAllTelemetryAdvancedFilters} */ function parseQueryParams( - req: Request -): ITelemetryAdvancedFilters { + req: Request +): IAllTelemetryAdvancedFilters { return { keyword: req.query.keyword ?? undefined, itis_tsns: req.query.itis_tsns ?? undefined, diff --git a/api/src/paths/telemetry/manual/delete.test.ts b/api/src/paths/telemetry/manual/delete.test.ts index 0bd0c7837c..c0ce05f141 100644 --- a/api/src/paths/telemetry/manual/delete.test.ts +++ b/api/src/paths/telemetry/manual/delete.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { SystemUser } from '../../../repositories/user-repository'; -import { BctwService, IManualTelemetry } from '../../../services/bctw-service'; +import { BctwTelemetryService, IManualTelemetry } from '../../../services/bctw-service/bctw-telemetry-service'; import { getRequestHandlerMocks } from '../../../__mocks__/db'; import { deleteManualTelemetry } from './delete'; @@ -19,7 +19,9 @@ describe('deleteManualTelemetry', () => { sinon.restore(); }); it('should retrieve all manual telemetry', async () => { - const mockGetTelemetry = sinon.stub(BctwService.prototype, 'deleteManualTelemetry').resolves(mockTelemetry); + const mockGetTelemetry = sinon + .stub(BctwTelemetryService.prototype, 'deleteManualTelemetry') + .resolves(mockTelemetry); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -35,7 +37,7 @@ describe('deleteManualTelemetry', () => { }); it('should catch error', async () => { const mockError = new Error('test error'); - const mockGetTelemetry = sinon.stub(BctwService.prototype, 'deleteManualTelemetry').rejects(mockError); + const mockGetTelemetry = sinon.stub(BctwTelemetryService.prototype, 'deleteManualTelemetry').rejects(mockError); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); diff --git a/api/src/paths/telemetry/manual/delete.ts b/api/src/paths/telemetry/manual/delete.ts index b1f57b158e..90ed481f67 100644 --- a/api/src/paths/telemetry/manual/delete.ts +++ b/api/src/paths/telemetry/manual/delete.ts @@ -2,7 +2,8 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { manual_telemetry_responses } from '.'; import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; -import { BctwService, getBctwUser } from '../../../services/bctw-service'; +import { getBctwUser } from '../../../services/bctw-service/bctw-service'; +import { BctwTelemetryService } from '../../../services/bctw-service/bctw-telemetry-service'; import { getLogger } from '../../../utils/logger'; const defaultLog = getLogger('paths/telemetry/manual/delete'); @@ -52,9 +53,9 @@ export function deleteManualTelemetry(): RequestHandler { return async (req, res) => { const user = getBctwUser(req); - const bctwService = new BctwService(user); + const bctwTelemetryService = new BctwTelemetryService(user); try { - const result = await bctwService.deleteManualTelemetry(req.body); + const result = await bctwTelemetryService.deleteManualTelemetry(req.body); return res.status(200).json(result); } catch (error) { defaultLog.error({ label: 'deleteManualTelemetry', message: 'error', error }); diff --git a/api/src/paths/telemetry/manual/deployments.test.ts b/api/src/paths/telemetry/manual/deployments.test.ts index 1120703417..601da022d8 100644 --- a/api/src/paths/telemetry/manual/deployments.test.ts +++ b/api/src/paths/telemetry/manual/deployments.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { SystemUser } from '../../../repositories/user-repository'; -import { BctwService, IManualTelemetry } from '../../../services/bctw-service'; +import { BctwTelemetryService, IManualTelemetry } from '../../../services/bctw-service/bctw-telemetry-service'; import { getRequestHandlerMocks } from '../../../__mocks__/db'; import { getManualTelemetryByDeploymentIds } from './deployments'; @@ -20,7 +20,7 @@ describe('getManualTelemetryByDeploymentIds', () => { }); it('should retrieve all manual telemetry', async () => { const mockGetTelemetry = sinon - .stub(BctwService.prototype, 'getManualTelemetryByDeploymentIds') + .stub(BctwTelemetryService.prototype, 'getManualTelemetryByDeploymentIds') .resolves(mockTelemetry); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -37,7 +37,9 @@ describe('getManualTelemetryByDeploymentIds', () => { }); it('should catch error', async () => { const mockError = new Error('test error'); - const mockGetTelemetry = sinon.stub(BctwService.prototype, 'getManualTelemetryByDeploymentIds').rejects(mockError); + const mockGetTelemetry = sinon + .stub(BctwTelemetryService.prototype, 'getManualTelemetryByDeploymentIds') + .rejects(mockError); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); diff --git a/api/src/paths/telemetry/manual/deployments.ts b/api/src/paths/telemetry/manual/deployments.ts index 8b23fec263..83c497ba7b 100644 --- a/api/src/paths/telemetry/manual/deployments.ts +++ b/api/src/paths/telemetry/manual/deployments.ts @@ -2,7 +2,8 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { manual_telemetry_responses } from '.'; import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; -import { BctwService, getBctwUser } from '../../../services/bctw-service'; +import { getBctwUser } from '../../../services/bctw-service/bctw-service'; +import { BctwTelemetryService } from '../../../services/bctw-service/bctw-telemetry-service'; import { getLogger } from '../../../utils/logger'; const defaultLog = getLogger('paths/telemetry/manual'); @@ -52,7 +53,9 @@ POST.apiDoc = { export function getManualTelemetryByDeploymentIds(): RequestHandler { return async (req, res) => { const user = getBctwUser(req); - const bctwService = new BctwService(user); + + const bctwService = new BctwTelemetryService(user); + try { const result = await bctwService.getManualTelemetryByDeploymentIds(req.body); return res.status(200).json(result); diff --git a/api/src/paths/telemetry/manual/index.test.ts b/api/src/paths/telemetry/manual/index.test.ts index 47a5c92cd7..0ada27ba2b 100644 --- a/api/src/paths/telemetry/manual/index.test.ts +++ b/api/src/paths/telemetry/manual/index.test.ts @@ -3,7 +3,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { createManualTelemetry, GET, getManualTelemetry, PATCH, POST, updateManualTelemetry } from '.'; import { SystemUser } from '../../../repositories/user-repository'; -import { BctwService, IManualTelemetry } from '../../../services/bctw-service'; +import { BctwTelemetryService, IManualTelemetry } from '../../../services/bctw-service/bctw-telemetry-service'; import { getRequestHandlerMocks } from '../../../__mocks__/db'; const mockTelemetry = [ @@ -28,7 +28,7 @@ describe('manual telemetry endpoints', () => { }); }); it('should retrieve all manual telemetry', async () => { - const mockGetTelemetry = sinon.stub(BctwService.prototype, 'getManualTelemetry').resolves(mockTelemetry); + const mockGetTelemetry = sinon.stub(BctwTelemetryService.prototype, 'getManualTelemetry').resolves(mockTelemetry); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -45,7 +45,7 @@ describe('manual telemetry endpoints', () => { it('should catch error', async () => { const mockError = new Error('test error'); - const mockGetTelemetry = sinon.stub(BctwService.prototype, 'getManualTelemetry').rejects(mockError); + const mockGetTelemetry = sinon.stub(BctwTelemetryService.prototype, 'getManualTelemetry').rejects(mockError); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -70,7 +70,9 @@ describe('manual telemetry endpoints', () => { }); }); it('should bulk create manual telemetry', async () => { - const mockCreateTelemetry = sinon.stub(BctwService.prototype, 'createManualTelemetry').resolves(mockTelemetry); + const mockCreateTelemetry = sinon + .stub(BctwTelemetryService.prototype, 'createManualTelemetry') + .resolves(mockTelemetry); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -86,7 +88,7 @@ describe('manual telemetry endpoints', () => { }); it('should catch error', async () => { const mockError = new Error('test error'); - const mockGetTelemetry = sinon.stub(BctwService.prototype, 'createManualTelemetry').rejects(mockError); + const mockGetTelemetry = sinon.stub(BctwTelemetryService.prototype, 'createManualTelemetry').rejects(mockError); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -111,7 +113,9 @@ describe('manual telemetry endpoints', () => { }); }); it('should bulk update manual telemetry', async () => { - const mockCreateTelemetry = sinon.stub(BctwService.prototype, 'updateManualTelemetry').resolves(mockTelemetry); + const mockCreateTelemetry = sinon + .stub(BctwTelemetryService.prototype, 'updateManualTelemetry') + .resolves(mockTelemetry); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -127,7 +131,7 @@ describe('manual telemetry endpoints', () => { }); it('should catch error', async () => { const mockError = new Error('test error'); - const mockGetTelemetry = sinon.stub(BctwService.prototype, 'updateManualTelemetry').rejects(mockError); + const mockGetTelemetry = sinon.stub(BctwTelemetryService.prototype, 'updateManualTelemetry').rejects(mockError); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); diff --git a/api/src/paths/telemetry/manual/index.ts b/api/src/paths/telemetry/manual/index.ts index a2a051c4c6..9156ff8576 100644 --- a/api/src/paths/telemetry/manual/index.ts +++ b/api/src/paths/telemetry/manual/index.ts @@ -1,7 +1,8 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; -import { BctwService, getBctwUser } from '../../../services/bctw-service'; +import { getBctwUser } from '../../../services/bctw-service/bctw-service'; +import { BctwTelemetryService } from '../../../services/bctw-service/bctw-telemetry-service'; import { getLogger } from '../../../utils/logger'; const defaultLog = getLogger('paths/telemetry/manual'); @@ -72,7 +73,8 @@ GET.apiDoc = { export function getManualTelemetry(): RequestHandler { return async (req, res) => { const user = getBctwUser(req); - const bctwService = new BctwService(user); + + const bctwService = new BctwTelemetryService(user); try { const result = await bctwService.getManualTelemetry(); return res.status(200).json(result); @@ -144,7 +146,7 @@ POST.apiDoc = { export function createManualTelemetry(): RequestHandler { return async (req, res) => { const user = getBctwUser(req); - const bctwService = new BctwService(user); + const bctwService = new BctwTelemetryService(user); try { const result = await bctwService.createManualTelemetry(req.body); return res.status(201).json(result); @@ -221,7 +223,7 @@ PATCH.apiDoc = { export function updateManualTelemetry(): RequestHandler { return async (req, res) => { const user = getBctwUser(req); - const bctwService = new BctwService(user); + const bctwService = new BctwTelemetryService(user); try { const result = await bctwService.updateManualTelemetry(req.body); return res.status(201).json(result); diff --git a/api/src/paths/telemetry/manual/process.ts b/api/src/paths/telemetry/manual/process.ts index cad1db8208..fcf289e604 100644 --- a/api/src/paths/telemetry/manual/process.ts +++ b/api/src/paths/telemetry/manual/process.ts @@ -3,7 +3,7 @@ import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../constants/roles'; import { getDBConnection } from '../../../database/db'; import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; -import { getBctwUser } from '../../../services/bctw-service'; +import { getBctwUser } from '../../../services/bctw-service/bctw-service'; import { TelemetryService } from '../../../services/telemetry-service'; import { getLogger } from '../../../utils/logger'; diff --git a/api/src/paths/telemetry/vendor/deployments.test.ts b/api/src/paths/telemetry/vendor/deployments.test.ts index 709101c1f5..c1a0d360cd 100644 --- a/api/src/paths/telemetry/vendor/deployments.test.ts +++ b/api/src/paths/telemetry/vendor/deployments.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { SystemUser } from '../../../repositories/user-repository'; -import { BctwService, IVendorTelemetry } from '../../../services/bctw-service'; +import { BctwTelemetryService, IVendorTelemetry } from '../../../services/bctw-service/bctw-telemetry-service'; import { getRequestHandlerMocks } from '../../../__mocks__/db'; import { getVendorTelemetryByDeploymentIds } from './deployments'; @@ -38,7 +38,7 @@ describe('getVendorTelemetryByDeploymentIds', () => { }); it('should retrieve all vendor telemetry by deployment ids', async () => { const mockGetTelemetry = sinon - .stub(BctwService.prototype, 'getVendorTelemetryByDeploymentIds') + .stub(BctwTelemetryService.prototype, 'getVendorTelemetryByDeploymentIds') .resolves(mockTelemetry); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -55,7 +55,9 @@ describe('getVendorTelemetryByDeploymentIds', () => { }); it('should catch error', async () => { const mockError = new Error('test error'); - const mockGetTelemetry = sinon.stub(BctwService.prototype, 'getVendorTelemetryByDeploymentIds').rejects(mockError); + const mockGetTelemetry = sinon + .stub(BctwTelemetryService.prototype, 'getVendorTelemetryByDeploymentIds') + .rejects(mockError); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); diff --git a/api/src/paths/telemetry/vendor/deployments.ts b/api/src/paths/telemetry/vendor/deployments.ts index e0199ba094..de4f3e3e90 100644 --- a/api/src/paths/telemetry/vendor/deployments.ts +++ b/api/src/paths/telemetry/vendor/deployments.ts @@ -1,7 +1,8 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; -import { BctwService, getBctwUser } from '../../../services/bctw-service'; +import { getBctwUser } from '../../../services/bctw-service/bctw-service'; +import { BctwTelemetryService } from '../../../services/bctw-service/bctw-telemetry-service'; import { getLogger } from '../../../utils/logger'; const defaultLog = getLogger('paths/telemetry/manual'); @@ -95,7 +96,8 @@ POST.apiDoc = { export function getVendorTelemetryByDeploymentIds(): RequestHandler { return async (req, res) => { const user = getBctwUser(req); - const bctwService = new BctwService(user); + + const bctwService = new BctwTelemetryService(user); try { const result = await bctwService.getVendorTelemetryByDeploymentIds(req.body); return res.status(200).json(result); diff --git a/api/src/paths/telemetry/vendors.test.ts b/api/src/paths/telemetry/vendors.test.ts index ce9c5acc8e..329ea891fe 100644 --- a/api/src/paths/telemetry/vendors.test.ts +++ b/api/src/paths/telemetry/vendors.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { SystemUser } from '../../repositories/user-repository'; -import { BctwService } from '../../services/bctw-service'; +import { BctwDeviceService } from '../../services/bctw-service/bctw-device-service'; import { getRequestHandlerMocks } from '../../__mocks__/db'; import { getCollarVendors } from './vendors'; @@ -12,7 +12,7 @@ describe('getCollarVendors', () => { it('gets collar vendors', async () => { const mockVendors = ['vendor1', 'vendor2']; - const mockGetCollarVendors = sinon.stub(BctwService.prototype, 'getCollarVendors').resolves(mockVendors); + const mockGetCollarVendors = sinon.stub(BctwDeviceService.prototype, 'getCollarVendors').resolves(mockVendors); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -30,7 +30,7 @@ describe('getCollarVendors', () => { it('catches and re-throws error', async () => { const mockError = new Error('a test error'); - const mockGetCollarVendors = sinon.stub(BctwService.prototype, 'getCollarVendors').rejects(mockError); + const mockGetCollarVendors = sinon.stub(BctwDeviceService.prototype, 'getCollarVendors').rejects(mockError); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); diff --git a/api/src/paths/telemetry/vendors.ts b/api/src/paths/telemetry/vendors.ts index b0901914ab..e9ea0e3561 100644 --- a/api/src/paths/telemetry/vendors.ts +++ b/api/src/paths/telemetry/vendors.ts @@ -1,7 +1,8 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { authorizeRequestHandler } from '../../request-handlers/security/authorization'; -import { BctwService, getBctwUser } from '../../services/bctw-service'; +import { BctwDeviceService } from '../../services/bctw-service/bctw-device-service'; +import { getBctwUser } from '../../services/bctw-service/bctw-service'; import { getLogger } from '../../utils/logger'; const defaultLog = getLogger('paths/telemetry/vendors'); @@ -63,9 +64,10 @@ export function getCollarVendors(): RequestHandler { return async (req, res) => { const user = getBctwUser(req); - const bctwService = new BctwService(user); + const bctwDeviceService = new BctwDeviceService(user); + try { - const result = await bctwService.getCollarVendors(); + const result = await bctwDeviceService.getCollarVendors(); return res.status(200).json(result); } catch (error) { defaultLog.error({ label: 'getCollarVendors', message: 'error', error }); diff --git a/api/src/paths/user/add.ts b/api/src/paths/user/add.ts index 13c7dfb144..add68d6994 100644 --- a/api/src/paths/user/add.ts +++ b/api/src/paths/user/add.ts @@ -148,10 +148,9 @@ export function addSystemRoleUser(): RequestHandler { const userService = new UserService(connection); - // If user already exists, do nothing and return early const user = await userService.getUserByIdentifier(userIdentifier, identitySource); - - if (user) { + if (user?.record_end_date === null) { + // User already exists and is active, do nothing and return early throw new HTTP409('Failed to add user. User with matching identifier already exists.'); } @@ -166,12 +165,16 @@ export function addSystemRoleUser(): RequestHandler { family_name ); - if (userObject) { - if (role_name) { - await userService.addUserSystemRoleByName(userObject.system_user_id, role_name); - } else { - await userService.addUserSystemRoles(userObject.system_user_id, [roleId]); - } + // Delete existin role + if (userObject.role_ids.length) { + await userService.deleteUserSystemRoles(userObject.system_user_id); + } + + // Add the new role + if (role_name) { + await userService.addUserSystemRoleByName(userObject.system_user_id, role_name); + } else { + await userService.addUserSystemRoles(userObject.system_user_id, [roleId]); } await connection.commit(); diff --git a/api/src/paths/user/self.test.ts b/api/src/paths/user/self.test.ts index 1a22331455..ee141b77d1 100644 --- a/api/src/paths/user/self.test.ts +++ b/api/src/paths/user/self.test.ts @@ -6,7 +6,7 @@ import * as db from '../../database/db'; import { HTTPError } from '../../errors/http-error'; import { UserService } from '../../services/user-service'; import { getMockDBConnection, getRequestHandlerMocks } from '../../__mocks__/db'; -import * as self from './self'; +import { getSelf } from './self'; chai.use(sinonChai); @@ -23,7 +23,7 @@ describe('getUser', () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); try { - const requestHandler = self.getUser(); + const requestHandler = getSelf(); await requestHandler(mockReq, mockRes, mockNext); expect.fail(); @@ -57,7 +57,7 @@ describe('getUser', () => { agency: null }); - const requestHandler = self.getUser(); + const requestHandler = getSelf(); await requestHandler(mockReq, mockRes, mockNext); @@ -84,7 +84,7 @@ describe('getUser', () => { }); try { - const requestHandler = self.getUser(); + const requestHandler = getSelf(); await requestHandler(mockReq, mockRes, mockNext); expect.fail(); diff --git a/api/src/paths/user/self.ts b/api/src/paths/user/self.ts index 9a1333f893..58516a72a5 100644 --- a/api/src/paths/user/self.ts +++ b/api/src/paths/user/self.ts @@ -3,24 +3,12 @@ import { Operation } from 'express-openapi'; import { getDBConnection } from '../../database/db'; import { HTTP400 } from '../../errors/http-error'; import { systemUserSchema } from '../../openapi/schemas/user'; -import { authorizeRequestHandler } from '../../request-handlers/security/authorization'; import { UserService } from '../../services/user-service'; import { getLogger } from '../../utils/logger'; -const defaultLog = getLogger('paths/user/{userId}'); +const defaultLog = getLogger('paths/user/self'); -export const GET: Operation = [ - authorizeRequestHandler(() => { - return { - and: [ - { - discriminator: 'SystemUser' - } - ] - }; - }), - getUser() -]; +export const GET: Operation = [getSelf()]; GET.apiDoc = { description: 'Get user details for the currently authenticated user.', @@ -64,29 +52,29 @@ GET.apiDoc = { * * @returns {RequestHandler} */ -export function getUser(): RequestHandler { +export function getSelf(): RequestHandler { return async (req, res) => { const connection = getDBConnection(req.keycloak_token); try { await connection.open(); - const userId = connection.systemUserId(); + const systemUserId = connection.systemUserId(); - if (!userId) { + if (!systemUserId) { throw new HTTP400('Failed to identify system user ID'); } const userService = new UserService(connection); // Fetch system user record - const userObject = await userService.getUserById(userId); + const userObject = await userService.getUserById(systemUserId); await connection.commit(); return res.status(200).json(userObject); } catch (error) { - defaultLog.error({ label: 'getUser', message: 'error', error }); + defaultLog.error({ label: 'getSelf', message: 'error', error }); await connection.rollback(); throw error; } finally { diff --git a/api/src/repositories/administrative-activity-repository.ts b/api/src/repositories/administrative-activity-repository.ts index 44f02a3a62..69a09b41b0 100644 --- a/api/src/repositories/administrative-activity-repository.ts +++ b/api/src/repositories/administrative-activity-repository.ts @@ -24,7 +24,9 @@ export const IAdministrativeActivity = z.object({ description: z.string().nullable(), data: shallowJsonSchema, notes: z.string().nullable(), - create_date: z.string() + create_date: z.string(), + updated_by: z.string().nullable(), + update_date: z.string().nullable() }); export type IAdministrativeActivity = z.infer; @@ -66,7 +68,9 @@ export class AdministrativeActivityRepository extends BaseRepository { aa.description, aa.data, aa.notes, - aa.create_date + aa.create_date, + su.display_name as updated_by, + aa.update_date FROM administrative_activity aa LEFT OUTER JOIN @@ -77,6 +81,10 @@ export class AdministrativeActivityRepository extends BaseRepository { administrative_activity_type aat ON aa.administrative_activity_type_id = aat.administrative_activity_type_id + LEFT OUTER JOIN + system_user su + ON + su.system_user_id = aa.update_user WHERE 1 = 1 `; @@ -115,7 +123,9 @@ export class AdministrativeActivityRepository extends BaseRepository { sqlStatement.append(SQL`)`); } - sqlStatement.append(`;`); + sqlStatement.append(` + ORDER BY create_date DESC; + `); const response = await this.connection.sql(sqlStatement, IAdministrativeActivity); return response.rows; diff --git a/api/src/repositories/analytics-repository.test.ts b/api/src/repositories/analytics-repository.test.ts new file mode 100644 index 0000000000..66f3decda4 --- /dev/null +++ b/api/src/repositories/analytics-repository.test.ts @@ -0,0 +1,76 @@ +import chai, { expect } from 'chai'; +import { QueryResult } from 'pg'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getMockDBConnection } from '../__mocks__/db'; +import { AnalyticsRepository } from './analytics-repository'; + +chai.use(sinonChai); + +describe('AnalyticsRepository', () => { + it('should construct', () => { + const mockDBConnection = getMockDBConnection(); + const analyticsRepository = new AnalyticsRepository(mockDBConnection); + + expect(analyticsRepository).to.be.instanceof(AnalyticsRepository); + }); + + describe('getObservationCountByGroup', () => { + it('Creates and executes sql query with empty params', async () => { + const mockRows = [ + { + row_count: 10, + individual_count: 5, + individual_percentage: 50, + quant_measurements: {}, + qual_measurements: { + critterbase_taxon_measurement_id: '1', + option_id: '2' + } + } + ]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: sinon.stub().resolves(mockQueryResponse) + }); + + const analyticsRepository = new AnalyticsRepository(mockDBConnection); + + const response = await analyticsRepository.getObservationCountByGroup([], [], [], []); + + expect(response).to.be.an('array'); + }); + + it('Creates and executes sql query with non-empty params', async () => { + const mockRows = [ + { + row_count: 10, + individual_count: 5, + individual_percentage: 50, + quant_measurements: {}, + qual_measurements: { + critterbase_taxon_measurement_id: '1', + option_id: '2' + } + } + ]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: sinon.stub().resolves(mockQueryResponse) + }); + + const analyticsRepository = new AnalyticsRepository(mockDBConnection); + + const response = await analyticsRepository.getObservationCountByGroup( + [1, 2, 3], + ['column1', 'column2'], + ['quant1', 'quant2'], + ['qual1', 'qual2'] + ); + + expect(response).to.be.an('array'); + }); + }); +}); diff --git a/api/src/repositories/analytics-repository.ts b/api/src/repositories/analytics-repository.ts new file mode 100644 index 0000000000..c9ebb232ba --- /dev/null +++ b/api/src/repositories/analytics-repository.ts @@ -0,0 +1,102 @@ +import { getKnex } from '../database/db'; +import { ObservationCountByGroupSQLResponse } from '../models/observation-analytics'; +import { BaseRepository } from './base-repository'; + +export class AnalyticsRepository extends BaseRepository { + /** + * Gets the observation count by group for given survey IDs + * + * @param {number[]} surveyIds - Array of survey IDs + * @param {string[]} groupByColumns - Columns to group by + * @param {string[]} groupByQuantitativeMeasurements - Quantitative measurements to group by + * @param {string[]} groupByQualitativeMeasurements - Qualitative measurements to group by + * @returns {Promise} - Observation count by group + * @memberof AnalyticsRepository + */ + async getObservationCountByGroup( + surveyIds: number[], + groupByColumns: string[], + groupByQuantitativeMeasurements: string[], + groupByQualitativeMeasurements: string[] + ): Promise { + const knex = getKnex(); + + const combinedColumns = [...groupByColumns, ...groupByQuantitativeMeasurements, ...groupByQualitativeMeasurements]; + + // Subquery to get the total count, used for calculating ratios + const totalCountSubquery = knex('observation_subcount as os') + .sum('os.subcount as total') + .leftJoin('survey_observation as so', 'so.survey_observation_id', 'os.survey_observation_id') + .whereIn('so.survey_id', surveyIds) + .first() + .toString(); + + // Create columns for quantitative measurements + const quantColumns = groupByQuantitativeMeasurements.map((id) => + knex.raw(`MAX(CASE WHEN quant.critterbase_taxon_measurement_id = ? THEN quant.value END) as ??`, [id, id]) + ); + + // Create columns for qualitative measurements + const qualColumns = groupByQualitativeMeasurements.map((id) => + knex.raw( + `STRING_AGG(DISTINCT CASE WHEN qual.critterbase_taxon_measurement_id = ? THEN qual.critterbase_measurement_qualitative_option_id::text END, ',') as ??`, + [id, id] + ) + ); + + const queryBuilder = knex + .with('temp_observations', (qb) => { + qb.select( + 'os.subcount', + 'os.observation_subcount_id', + 'so.survey_id', + ...groupByColumns.map((column) => knex.raw('??', [column])), + ...quantColumns, + ...qualColumns + ) + .from('observation_subcount as os') + .leftJoin('survey_observation as so', 'so.survey_observation_id', 'os.survey_observation_id') + .leftJoin( + 'observation_subcount_qualitative_measurement as qual', + 'qual.observation_subcount_id', + 'os.observation_subcount_id' + ) + .leftJoin( + 'observation_subcount_quantitative_measurement as quant', + 'quant.observation_subcount_id', + 'os.observation_subcount_id' + ) + .whereIn('so.survey_id', surveyIds) + .groupBy('os.subcount', 'os.observation_subcount_id', 'so.survey_id', ...groupByColumns); + }) + .select(knex.raw('public.gen_random_uuid() as id')) // Generate a unique ID for the row + .select(knex.raw('COUNT(subcount)::NUMERIC as row_count')) + .select(knex.raw('SUM(subcount)::NUMERIC as individual_count')) + .select(knex.raw(`ROUND(SUM(os.subcount)::NUMERIC / (${totalCountSubquery}) * 100, 2) as individual_percentage`)) + .select(groupByColumns.map((column) => knex.raw('??', [column]))) + // Measurement properties are objects of {'' : '', '' : ''} + .select( + knex.raw( + `jsonb_build_object(${groupByQuantitativeMeasurements + .map((column) => `'${column}', ??`) + .join(', ')}) as quant_measurements`, + groupByQuantitativeMeasurements + ) + ) + .select( + knex.raw( + `jsonb_build_object(${groupByQualitativeMeasurements + .map((column) => `'${column}', ??`) + .join(', ')}) as qual_measurements`, + groupByQualitativeMeasurements + ) + ) + .from('temp_observations as os') + .groupBy(combinedColumns) + .orderBy('individual_count', 'desc'); + + const response = await this.connection.knex(queryBuilder, ObservationCountByGroupSQLResponse); + + return response.rows; + } +} diff --git a/api/src/repositories/attachment-repository.ts b/api/src/repositories/attachment-repository.ts index 1baa9109de..6f797db3bd 100644 --- a/api/src/repositories/attachment-repository.ts +++ b/api/src/repositories/attachment-repository.ts @@ -1,6 +1,6 @@ import { QueryResult } from 'pg'; import SQL from 'sql-template-strings'; -import { ATTACHMENT_TYPE } from '../constants/attachments'; +import { z } from 'zod'; import { getKnex } from '../database/db'; import { ApiExecuteSQLError } from '../errors/api-error'; import { PostReportAttachmentMetadata, PutReportAttachmentMetadata } from '../models/project-survey-attachments'; @@ -70,6 +70,20 @@ export interface ISurveyReportAttachmentAuthor { revision_count: number; } +export const SurveyTelemetryCredentialAttachment = z.object({ + survey_telemetry_credential_attachment_id: z.number(), + uuid: z.string().uuid(), + file_name: z.string(), + file_type: z.string(), + file_size: z.number(), + create_date: z.string(), + update_date: z.string().nullable(), + title: z.string().nullable(), + description: z.string().nullable(), + key: z.string() +}); +export type SurveyTelemetryCredentialAttachment = z.infer; + const defaultLog = getLogger('repositories/attachment-repository'); /** @@ -461,9 +475,7 @@ export class AttachmentRepository extends BaseRepository { FROM survey_attachment WHERE - survey_id = ${surveyId} - AND - LOWER(file_type) != LOWER(${ATTACHMENT_TYPE.KEYX}); + survey_id = ${surveyId}; `; const response = await this.connection.sql(sqlStatement); @@ -644,6 +656,38 @@ export class AttachmentRepository extends BaseRepository { return response.rows; } + /** + * Get survey telemetry credential attachments. + * + * @param {number} surveyId The survey ID + * @return {Promise} Promise resolving all survey telemetry attachments + * @memberof AttachmentRepository + */ + async getSurveyTelemetryCredentialAttachments(surveyId: number): Promise { + defaultLog.debug({ label: 'getSurveyTelemetryCredentialAttachments' }); + + const sqlStatement = SQL` + SELECT + survey_telemetry_credential_attachment_id, + uuid, + file_name, + file_type, + file_size, + create_date, + update_date, + title, + description, + key + FROM + survey_telemetry_credential_attachment + WHERE + survey_id = ${surveyId}; + `; + + const response = await this.connection.sql(sqlStatement, SurveyTelemetryCredentialAttachment); + + return response.rows; + } /** * Insert new Project Attachment * @@ -1569,4 +1613,162 @@ export class AttachmentRepository extends BaseRepository { return response; } + + /** + * Update survey telemetry credential attachment record. + * + * @param {number} surveyId + * @param {string} fileName + * @param {string} fileType + * @return {*} {Promise<{ survey_telemetry_credential_attachment_id: number }>} + * @memberof AttachmentRepository + */ + async updateSurveyTelemetryCredentialAttachment( + surveyId: number, + fileName: string, + fileType: string + ): Promise<{ survey_telemetry_credential_attachment_id: number }> { + const sqlStatement = SQL` + UPDATE + survey_telemetry_credential_attachment + SET + file_name = ${fileName}, + file_type = ${fileType} + WHERE + file_name = ${fileName} + AND + survey_id = ${surveyId} + RETURNING + survey_telemetry_credential_attachment_id; + `; + + const response = await this.connection.sql( + sqlStatement, + z.object({ survey_telemetry_credential_attachment_id: z.number() }) + ); + + if (!response?.rows?.[0]) { + throw new ApiExecuteSQLError('Failed to update survey attachment data', [ + 'AttachmentRepository->updateSurveyTelemetryCredentialAttachment', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0]; + } + + /** + * Insert survey telemetry credential attachment record. + * + * @param {string} fileName + * @param {number} fileSize + * @param {string} fileType + * @param {number} surveyId + * @param {string} key + * @return {*} {Promise<{ survey_telemetry_credential_attachment_id: number }>} + * @memberof AttachmentRepository + */ + async insertSurveyTelemetryCredentialAttachment( + fileName: string, + fileSize: number, + fileType: string, + surveyId: number, + key: string + ): Promise<{ survey_telemetry_credential_attachment_id: number }> { + const sqlStatement = SQL` + INSERT INTO survey_telemetry_credential_attachment ( + survey_id, + file_name, + file_size, + file_type, + key + ) VALUES ( + ${surveyId}, + ${fileName}, + ${fileSize}, + ${fileType}, + ${key} + ) + RETURNING + survey_telemetry_credential_attachment_id; + `; + + const response = await this.connection.sql( + sqlStatement, + z.object({ survey_telemetry_credential_attachment_id: z.number() }) + ); + + if (!response?.rows?.[0]) { + throw new ApiExecuteSQLError('Failed to insert survey attachment data', [ + 'AttachmentRepository->insertSurveyTelemetryCredentialAttachment', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0]; + } + + /** + * Get Survey Telemetry Attachment By File Name + * + * @param {string} fileName + * @param {number} surveyId + * @return {*} {Promise} + * @memberof AttachmentRepository + */ + async getSurveyTelemetryCredentialAttachmentByFileName(fileName: string, surveyId: number): Promise { + const sqlStatement = SQL` + SELECT + survey_telemetry_credential_attachment_id, + uuid, + file_name, + title, + description, + update_date, + create_date, + file_size + from + survey_telemetry_credential_attachment + where + survey_id = ${surveyId} + and + file_name = ${fileName}; + `; + + const response = await this.connection.sql(sqlStatement); + + return response; + } + + /** + * Get survey telemetry credential attachment S3 key + * + * @param {number} surveyId + * @param {number} attachmentId + * @return {*} {Promise} + * @memberof AttachmentRepository + */ + async getSurveyTelemetryCredentialAttachmentS3Key(surveyId: number, attachmentId: number): Promise { + const sqlStatement = SQL` + SELECT + key + FROM + survey_telemetry_credential_attachment + WHERE + survey_telemetry_credential_attachment_id = ${attachmentId} + AND + survey_id = ${surveyId}; + `; + + const response = await this.connection.sql(sqlStatement); + + if (!response?.rows?.[0]) { + throw new ApiExecuteSQLError('Failed to get Survey Telemetry Credential Attachment S3 Key', [ + 'AttachmentRepository->getSurveyTelemetryCredentialAttachmentS3Key', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0].key; + } } diff --git a/api/src/repositories/code-repository.ts b/api/src/repositories/code-repository.ts index 27e5decc98..2ededfc12f 100644 --- a/api/src/repositories/code-repository.ts +++ b/api/src/repositories/code-repository.ts @@ -36,7 +36,6 @@ export const IAllCodeSets = z.object({ project_roles: CodeSet(), administrative_activity_status_type: CodeSet(), intended_outcomes: CodeSet(IntendedOutcomeCode.shape), - vantage_codes: CodeSet(), survey_jobs: CodeSet(), site_selection_strategies: CodeSet(), sample_methods: CodeSet(SampleMethodsCode.shape), @@ -172,26 +171,6 @@ export class CodeRepository extends BaseRepository { return response.rows; } - /** - * Fetch vantage codes. - * - * @return {*} - * @memberof CodeRepository - */ - async getVantageCodes() { - const sqlStatement = SQL` - SELECT - vantage_id as id, - name - FROM vantage - WHERE record_end_date is null; - `; - - const response = await this.connection.sql(sqlStatement, ICode); - - return response.rows; - } - /** * Fetch intended outcomes codes. * diff --git a/api/src/repositories/deployment-repository.ts b/api/src/repositories/deployment-repository.ts new file mode 100644 index 0000000000..af383837dd --- /dev/null +++ b/api/src/repositories/deployment-repository.ts @@ -0,0 +1,177 @@ +import { getKnex } from '../database/db'; +import { ICreateSurveyDeployment, IUpdateSurveyDeployment, SurveyDeployment } from '../models/survey-deployment'; +import { getLogger } from '../utils/logger'; +import { BaseRepository } from './base-repository'; + +const defaultLog = getLogger('repositories/deployment'); + +/** + * Repository layer for survey deployments + * + * @export + * @class DeploymentRepository + * @extends {BaseRepository} + */ +export class DeploymentRepository extends BaseRepository { + /** + * Returns deployments in a survey + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof DeploymentRepository + */ + async getDeploymentsForSurveyId(surveyId: number): Promise { + defaultLog.debug({ label: 'getDeploymentsForSurveyId', surveyId }); + + const queryBuilder = getKnex() + .select( + 'deployment_id', + 'd.critter_id as critter_id', + 'c.critterbase_critter_id', + 'bctw_deployment_id', + 'critterbase_start_capture_id', + 'critterbase_end_capture_id', + 'critterbase_end_mortality_id' + ) + .from('deployment as d') + .leftJoin('critter as c', 'c.critter_id', 'd.critter_id') + .where('c.survey_id', surveyId); + + const response = await this.connection.knex(queryBuilder, SurveyDeployment); + + return response.rows; + } + + /** + * Returns a specific deployment + * + * @param {number} deploymentId + * @return {*} {Promise} + * @memberof DeploymentRepository + */ + async getDeploymentById(deploymentId: number): Promise { + defaultLog.debug({ label: 'getDeploymentById', deploymentId }); + + const queryBuilder = getKnex() + .select( + 'deployment_id', + 'd.critter_id as critter_id', + 'c.critterbase_critter_id', + 'bctw_deployment_id', + 'critterbase_start_capture_id', + 'critterbase_end_capture_id', + 'critterbase_end_mortality_id' + ) + .from('deployment as d') + .leftJoin('critter as c', 'c.critter_id', 'd.critter_id') + .where('d.deployment_id', deploymentId); + + const response = await this.connection.knex(queryBuilder, SurveyDeployment); + + return response.rows[0]; + } + + /** + * Returns a specific deployment for a given critter Id + * + * @param {number} surveyId + * @param {number} critterId + * @return {*} {Promise} + * @memberof DeploymentRepository + */ + async getDeploymentForCritterId(surveyId: number, critterId: number): Promise { + defaultLog.debug({ label: 'getDeploymentById', critterId }); + + const queryBuilder = getKnex() + .select( + 'deployment_id', + 'd.critter_id as critter_id', + 'c.critterbase_critter_id', + 'bctw_deployment_id', + 'critterbase_start_capture_id', + 'critterbase_end_capture_id', + 'critterbase_end_mortality_id' + ) + .from('deployment as d') + .leftJoin('critter as c', 'c.critter_id', 'd.critter_id') + .where('d.critter_id', critterId) + .andWhere('c.survey_id', surveyId); + + const response = await this.connection.knex(queryBuilder, SurveyDeployment); + + return response.rows[0]; + } + + /** + * Insert a new deployment record. + * + * @param {ICreateSurveyDeployment} deployment + * @return {*} {Promise} + * @memberof DeploymentRepository + */ + async insertDeployment(deployment: ICreateSurveyDeployment): Promise { + defaultLog.debug({ label: 'insertDeployment', bctw_deployment_id: deployment.bctw_deployment_id }); + + const queryBuilder = getKnex().table('deployment').insert({ + critter_id: deployment.critter_id, + bctw_deployment_id: deployment.bctw_deployment_id, + critterbase_start_capture_id: deployment.critterbase_start_capture_id, + critterbase_end_capture_id: deployment.critterbase_end_capture_id, + critterbase_end_mortality_id: deployment.critterbase_end_mortality_id + }); + + await this.connection.knex(queryBuilder); + } + + /** + * Update an existing deployment record. + * + * @param {IUpdateSurveyDeployment} deployment + * @return {*} {Promise} + * @memberof DeploymentRepository + */ + async updateDeployment(deployment: IUpdateSurveyDeployment): Promise { + defaultLog.debug({ label: 'updateDeployment', deployment_id: deployment.deployment_id }); + + const queryBuilder = getKnex() + .table('deployment') + .where('deployment_id', deployment.deployment_id) + .update({ + critter_id: deployment.critter_id, + critterbase_start_capture_id: deployment.critterbase_start_capture_id, + critterbase_end_capture_id: deployment.critterbase_end_capture_id, + critterbase_end_mortality_id: deployment.critterbase_end_mortality_id + }) + .returning('bctw_deployment_id'); + + const response = await this.connection.knex(queryBuilder); + + return response.rows[0].bctw_deployment_id; + } + + /** + * Deletes a deployment row. + * + * @param {number} surveyId + * @param {number} deploymentId + * @return {*} + * @memberof DeploymentRepository + */ + async deleteDeployment(surveyId: number, deploymentId: number): Promise<{ bctw_deployment_id: string }> { + defaultLog.debug({ label: 'deleteDeployment', deploymentId }); + + const queryBuilder = getKnex() + .table('deployment') + .join('critter', 'deployment.critter_id', 'critter.critter_id') + .where({ + 'deployment.deployment_id': deploymentId, + 'critter.survey_id': surveyId + }) + .delete() + .returning('bctw_deployment_id'); + + const response = await this.connection.knex(queryBuilder); + + return response.rows[0]; + } +} diff --git a/api/src/repositories/observation-repository/observation-repository.test.ts b/api/src/repositories/observation-repository/observation-repository.test.ts index 4b3f953c5b..f4b3122b28 100644 --- a/api/src/repositories/observation-repository/observation-repository.test.ts +++ b/api/src/repositories/observation-repository/observation-repository.test.ts @@ -224,6 +224,22 @@ describe('ObservationRepository', () => { }); }); + describe('getObservedSpeciesForSurvey', () => { + it('gets observed species for a given survey', async () => { + const mockQueryResponse = { rows: [{ itis_tsn: 5 }], rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: sinon.stub().resolves(mockQueryResponse) + }); + + const repo = new ObservationRepository(mockDBConnection); + + const response = await repo.getObservedSpeciesForSurvey(1); + + expect(response).to.eql([{ itis_tsn: 5 }]); + }); + }); + describe('getObservationsCountBySampleSiteIds', () => { it('gets the observation count by sample site ids', async () => { const mockQueryResponse = { rows: [{ count: 50 }], rowCount: 1 } as unknown as QueryResult; diff --git a/api/src/repositories/observation-repository/observation-repository.ts b/api/src/repositories/observation-repository/observation-repository.ts index 80a0db0611..2402f68838 100644 --- a/api/src/repositories/observation-repository/observation-repository.ts +++ b/api/src/repositories/observation-repository/observation-repository.ts @@ -45,6 +45,12 @@ export const ObservationRecord = z.object({ export type ObservationRecord = z.infer; +export const ObservationSpecies = z.object({ + itis_tsn: z.number() +}); + +export type ObservationSpecies = z.infer; + const ObservationSamplingData = z.object({ survey_sample_site_name: z.string().nullable(), survey_sample_method_name: z.string().nullable(), @@ -416,6 +422,25 @@ export class ObservationRepository extends BaseRepository { return response.rows; } + /** + * Retrieves species observed in a given survey + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof ObservationRepository + */ + async getObservedSpeciesForSurvey(surveyId: number): Promise { + const knex = getKnex(); + const allRowsQuery = knex + .queryBuilder() + .distinct('itis_tsn') + .from('survey_observation') + .where('survey_id', surveyId); + + const response = await this.connection.knex(allRowsQuery, ObservationSpecies); + return response.rows; + } + /** * Retrieves the count of survey observations for the given survey * diff --git a/api/src/repositories/observation-repository/utils.test.ts b/api/src/repositories/observation-repository/utils.test.ts new file mode 100644 index 0000000000..8e09053fc6 --- /dev/null +++ b/api/src/repositories/observation-repository/utils.test.ts @@ -0,0 +1,193 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getKnex } from '../../database/db'; +import { getSurveyObservationsBaseQuery, makeFindObservationsQuery } from './utils'; + +chai.use(sinonChai); + +describe('Utils', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('makeFindObservationsQuery', () => { + it('should return a knex query builder when no optional fields provided', async () => { + const isUserAdmin = false; + const systemUserId = null; + const filterFields = {}; + + const queryBuilder = makeFindObservationsQuery(isUserAdmin, systemUserId, filterFields); + + const normalize = (str: string) => { + // Remove all redundant whitespace and redeuce to one line, to make string comparison easier + return str.replace(/\s+/g, ' ').trim().replace('/\n/g', ' ').trim(); + }; + + expect(normalize(queryBuilder.toSQL().toNative().sql)).to.equal( + normalize(`with "w_survey_sample_site" as (select "survey_sample_site_id", "name" as "survey_sample_site_name" from "survey_sample_site" where "survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null))), "w_survey_sample_method" as (select "survey_sample_method"."survey_sample_site_id", "survey_sample_method"."survey_sample_method_id", "method_technique"."name" as "survey_sample_method_name" from "survey_sample_method" inner join "method_technique" on "survey_sample_method"."method_technique_id" = "method_technique"."method_technique_id" inner join "w_survey_sample_site" on "survey_sample_method"."survey_sample_site_id" = "w_survey_sample_site"."survey_sample_site_id"), "w_survey_sample_period" as (select "w_survey_sample_method"."survey_sample_site_id", "survey_sample_period"."survey_sample_method_id", "survey_sample_period"."survey_sample_period_id", (survey_sample_period.start_date::date + COALESCE(survey_sample_period.start_time, '00:00:00')::time)::timestamp as survey_sample_period_start_datetime from "survey_sample_period" inner join "w_survey_sample_method" on "survey_sample_period"."survey_sample_method_id" = "w_survey_sample_method"."survey_sample_method_id"), "w_qualitative_measurements" as (select "observation_subcount_id", + json_agg(json_build_object( + 'critterbase_taxon_measurement_id', critterbase_taxon_measurement_id, + 'critterbase_measurement_qualitative_option_id', critterbase_measurement_qualitative_option_id + )) as qualitative_measurements + from "observation_subcount_qualitative_measurement" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null)))) group by "observation_subcount_id"), "w_quantitative_measurements" as (select "observation_subcount_id", + json_agg(json_build_object( + 'critterbase_taxon_measurement_id', critterbase_taxon_measurement_id, + 'value', value + )) as quantitative_measurements + from "observation_subcount_quantitative_measurement" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null)))) group by "observation_subcount_id"), "w_qualitative_environments" as (select "observation_subcount_id", + json_agg(json_build_object( + 'observation_subcount_qualitative_environment_id', observation_subcount_qualitative_environment_id, + 'environment_qualitative_id', environment_qualitative_id, + 'environment_qualitative_option_id', environment_qualitative_option_id + )) as qualitative_environments + from "observation_subcount_qualitative_environment" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null)))) group by "observation_subcount_id"), "w_quantitative_environments" as (select "observation_subcount_id", + json_agg(json_build_object( + 'observation_subcount_quantitative_environment_id', observation_subcount_quantitative_environment_id, + 'environment_quantitative_id', environment_quantitative_id, + 'value', value + )) as quantitative_environments + from "observation_subcount_quantitative_environment" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null)))) group by "observation_subcount_id"), "w_subcounts" as (select "survey_observation_id", + json_agg(json_build_object( + 'observation_subcount_id', observation_subcount.observation_subcount_id, + 'subcount', subcount, + 'qualitative_measurements', COALESCE(w_qualitative_measurements.qualitative_measurements, '[]'::json), + 'quantitative_measurements', COALESCE(w_quantitative_measurements.quantitative_measurements, '[]'::json), + 'qualitative_environments', COALESCE(w_qualitative_environments.qualitative_environments, '[]'::json), + 'quantitative_environments', COALESCE(w_quantitative_environments.quantitative_environments, '[]'::json) + )) as subcounts + from "observation_subcount" left join "w_qualitative_measurements" on "observation_subcount"."observation_subcount_id" = "w_qualitative_measurements"."observation_subcount_id" left join "w_quantitative_measurements" on "observation_subcount"."observation_subcount_id" = "w_quantitative_measurements"."observation_subcount_id" left join "w_qualitative_environments" on "observation_subcount"."observation_subcount_id" = "w_qualitative_environments"."observation_subcount_id" left join "w_quantitative_environments" on "observation_subcount"."observation_subcount_id" = "w_quantitative_environments"."observation_subcount_id" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null))) group by "survey_observation_id") select "survey_observation"."survey_observation_id", "survey_observation"."survey_id", "survey_observation"."itis_tsn", "survey_observation"."itis_scientific_name", "survey_observation"."survey_sample_site_id", "survey_observation"."survey_sample_method_id", "survey_observation"."survey_sample_period_id", "survey_observation"."latitude", "survey_observation"."longitude", "survey_observation"."count", "survey_observation"."observation_date", "survey_observation"."observation_time", "w_survey_sample_site"."survey_sample_site_name", "w_survey_sample_method"."survey_sample_method_name", "w_survey_sample_period"."survey_sample_period_start_datetime", COALESCE(w_subcounts.subcounts, '[]'::json) as subcounts from "survey_observation" left join "w_survey_sample_site" on "survey_observation"."survey_sample_site_id" = "w_survey_sample_site"."survey_sample_site_id" left join "w_survey_sample_method" on "survey_observation"."survey_sample_method_id" = "w_survey_sample_method"."survey_sample_method_id" left join "w_survey_sample_period" on "survey_observation"."survey_sample_period_id" = "w_survey_sample_period"."survey_sample_period_id" inner join "w_subcounts" on "w_subcounts"."survey_observation_id" = "survey_observation"."survey_observation_id" where "survey_observation"."survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null))`) + ); + expect(queryBuilder.toSQL().toNative().bindings).to.eql([]); + }); + + it('should return a knex query builder when all optional fields provided', async () => { + const isUserAdmin = true; + const systemUserId = 11; + const filterFields = { + keyword: 'caribou', + itis_tsns: [123456], + itis_tsn: 123456, + start_date: '2021-01-01', + end_date: '2024-01-01', + start_time: '00:00:00', + end_time: '23:59:59', + min_count: 0, + system_user_id: 22 + }; + + const queryBuilder = makeFindObservationsQuery(isUserAdmin, systemUserId, filterFields); + + const normalize = (str: string) => { + // Remove all redundant whitespace and redeuce to one line, to make string comparison easier + return str.replace(/\s+/g, ' ').trim().replace('/\n/g', ' ').trim(); + }; + + expect(normalize(queryBuilder.toSQL().toNative().sql)).to.equal( + normalize(`with "w_survey_sample_site" as (select "survey_sample_site_id", "name" as "survey_sample_site_name" from "survey_sample_site" where "survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $1))), "w_survey_sample_method" as (select "survey_sample_method"."survey_sample_site_id", "survey_sample_method"."survey_sample_method_id", "method_technique"."name" as "survey_sample_method_name" from "survey_sample_method" inner join "method_technique" on "survey_sample_method"."method_technique_id" = "method_technique"."method_technique_id" inner join "w_survey_sample_site" on "survey_sample_method"."survey_sample_site_id" = "w_survey_sample_site"."survey_sample_site_id"), "w_survey_sample_period" as (select "w_survey_sample_method"."survey_sample_site_id", "survey_sample_period"."survey_sample_method_id", "survey_sample_period"."survey_sample_period_id", (survey_sample_period.start_date::date + COALESCE(survey_sample_period.start_time, '00:00:00')::time)::timestamp as survey_sample_period_start_datetime from "survey_sample_period" inner join "w_survey_sample_method" on "survey_sample_period"."survey_sample_method_id" = "w_survey_sample_method"."survey_sample_method_id"), "w_qualitative_measurements" as (select "observation_subcount_id", + json_agg(json_build_object( + 'critterbase_taxon_measurement_id', critterbase_taxon_measurement_id, + 'critterbase_measurement_qualitative_option_id', critterbase_measurement_qualitative_option_id + )) as qualitative_measurements + from "observation_subcount_qualitative_measurement" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $2)))) group by "observation_subcount_id"), "w_quantitative_measurements" as (select "observation_subcount_id", + json_agg(json_build_object( + 'critterbase_taxon_measurement_id', critterbase_taxon_measurement_id, + 'value', value + )) as quantitative_measurements + from "observation_subcount_quantitative_measurement" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $3)))) group by "observation_subcount_id"), "w_qualitative_environments" as (select "observation_subcount_id", + json_agg(json_build_object( + 'observation_subcount_qualitative_environment_id', observation_subcount_qualitative_environment_id, + 'environment_qualitative_id', environment_qualitative_id, + 'environment_qualitative_option_id', environment_qualitative_option_id + )) as qualitative_environments + from "observation_subcount_qualitative_environment" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $4)))) group by "observation_subcount_id"), "w_quantitative_environments" as (select "observation_subcount_id", + json_agg(json_build_object( + 'observation_subcount_quantitative_environment_id', observation_subcount_quantitative_environment_id, + 'environment_quantitative_id', environment_quantitative_id, + 'value', value + )) as quantitative_environments + from "observation_subcount_quantitative_environment" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $5)))) group by "observation_subcount_id"), "w_subcounts" as (select "survey_observation_id", + json_agg(json_build_object( + 'observation_subcount_id', observation_subcount.observation_subcount_id, + 'subcount', subcount, + 'qualitative_measurements', COALESCE(w_qualitative_measurements.qualitative_measurements, '[]'::json), + 'quantitative_measurements', COALESCE(w_quantitative_measurements.quantitative_measurements, '[]'::json), + 'qualitative_environments', COALESCE(w_qualitative_environments.qualitative_environments, '[]'::json), + 'quantitative_environments', COALESCE(w_quantitative_environments.quantitative_environments, '[]'::json) + )) as subcounts + from "observation_subcount" left join "w_qualitative_measurements" on "observation_subcount"."observation_subcount_id" = "w_qualitative_measurements"."observation_subcount_id" left join "w_quantitative_measurements" on "observation_subcount"."observation_subcount_id" = "w_quantitative_measurements"."observation_subcount_id" left join "w_qualitative_environments" on "observation_subcount"."observation_subcount_id" = "w_qualitative_environments"."observation_subcount_id" left join "w_quantitative_environments" on "observation_subcount"."observation_subcount_id" = "w_quantitative_environments"."observation_subcount_id" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $6))) group by "survey_observation_id") select "survey_observation"."survey_observation_id", "survey_observation"."survey_id", "survey_observation"."itis_tsn", "survey_observation"."itis_scientific_name", "survey_observation"."survey_sample_site_id", "survey_observation"."survey_sample_method_id", "survey_observation"."survey_sample_period_id", "survey_observation"."latitude", "survey_observation"."longitude", "survey_observation"."count", "survey_observation"."observation_date", "survey_observation"."observation_time", "w_survey_sample_site"."survey_sample_site_name", "w_survey_sample_method"."survey_sample_method_name", "w_survey_sample_period"."survey_sample_period_start_datetime", COALESCE(w_subcounts.subcounts, '[]'::json) as subcounts from "survey_observation" left join "w_survey_sample_site" on "survey_observation"."survey_sample_site_id" = "w_survey_sample_site"."survey_sample_site_id" left join "w_survey_sample_method" on "survey_observation"."survey_sample_method_id" = "w_survey_sample_method"."survey_sample_method_id" left join "w_survey_sample_period" on "survey_observation"."survey_sample_period_id" = "w_survey_sample_period"."survey_sample_period_id" inner join "w_subcounts" on "w_subcounts"."survey_observation_id" = "survey_observation"."survey_observation_id" where "survey_observation"."survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $7)) and "observation_date" >= $8 and "observation_date" <= $9 and ("itis_scientific_name" ilike $10) and "time" >= $11 and "time" <= $12 and "itis_tsn" in ($13)`) + ); + expect(queryBuilder.toSQL().toNative().bindings).to.eql([ + 22, + 22, + 22, + 22, + 22, + 22, + 22, + '2021-01-01', + '2024-01-01', + '%caribou%', + '00:00:00', + '23:59:59', + 123456 + ]); + }); + }); + + describe('getSurveyObservationsBaseQuery', () => { + it('should return a knex query builder', async () => { + const surveyId = 1; + + const knex = getKnex(); + const getSurveyIdsQuery = knex + .select('survey_id') + .from('survey') + .where('survey_id', surveyId); + + const queryBuilder = getSurveyObservationsBaseQuery(knex, getSurveyIdsQuery); + + const normalize = (str: string) => { + // Remove all whitespace and trim, to make string comparison easier + return str.replace(/\s+/g, ' ').trim(); + }; + + expect(normalize(queryBuilder.toSQL().toNative().sql)).to.equal( + normalize(`with "w_survey_sample_site" as (select "survey_sample_site_id", "name" as "survey_sample_site_name" from "survey_sample_site" where "survey_id" in (select "survey_id" from "survey" where "survey_id" = $1)), "w_survey_sample_method" as (select "survey_sample_method"."survey_sample_site_id", "survey_sample_method"."survey_sample_method_id", "method_technique"."name" as "survey_sample_method_name" from "survey_sample_method" inner join "method_technique" on "survey_sample_method"."method_technique_id" = "method_technique"."method_technique_id" inner join "w_survey_sample_site" on "survey_sample_method"."survey_sample_site_id" = "w_survey_sample_site"."survey_sample_site_id"), "w_survey_sample_period" as (select "w_survey_sample_method"."survey_sample_site_id", "survey_sample_period"."survey_sample_method_id", "survey_sample_period"."survey_sample_period_id", (survey_sample_period.start_date::date + COALESCE(survey_sample_period.start_time, '00:00:00')::time)::timestamp as survey_sample_period_start_datetime from "survey_sample_period" inner join "w_survey_sample_method" on "survey_sample_period"."survey_sample_method_id" = "w_survey_sample_method"."survey_sample_method_id"), "w_qualitative_measurements" as (select "observation_subcount_id", + json_agg(json_build_object( + 'critterbase_taxon_measurement_id', critterbase_taxon_measurement_id, + 'critterbase_measurement_qualitative_option_id', critterbase_measurement_qualitative_option_id + )) as qualitative_measurements + from "observation_subcount_qualitative_measurement" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey_id" = $2))) group by "observation_subcount_id"), "w_quantitative_measurements" as (select "observation_subcount_id", + json_agg(json_build_object( + 'critterbase_taxon_measurement_id', critterbase_taxon_measurement_id, + 'value', value + )) as quantitative_measurements + from "observation_subcount_quantitative_measurement" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey_id" = $3))) group by "observation_subcount_id"), "w_qualitative_environments" as (select "observation_subcount_id", + json_agg(json_build_object( + 'observation_subcount_qualitative_environment_id', observation_subcount_qualitative_environment_id, + 'environment_qualitative_id', environment_qualitative_id, + 'environment_qualitative_option_id', environment_qualitative_option_id + )) as qualitative_environments + from "observation_subcount_qualitative_environment" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey_id" = $4))) group by "observation_subcount_id"), "w_quantitative_environments" as (select "observation_subcount_id", + json_agg(json_build_object( + 'observation_subcount_quantitative_environment_id', observation_subcount_quantitative_environment_id, + 'environment_quantitative_id', environment_quantitative_id, + 'value', value + )) as quantitative_environments + from "observation_subcount_quantitative_environment" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey_id" = $5))) group by "observation_subcount_id"), "w_subcounts" as (select "survey_observation_id", + json_agg(json_build_object( + 'observation_subcount_id', observation_subcount.observation_subcount_id, + 'subcount', subcount, + 'qualitative_measurements', COALESCE(w_qualitative_measurements.qualitative_measurements, '[]'::json), + 'quantitative_measurements', COALESCE(w_quantitative_measurements.quantitative_measurements, '[]'::json), + 'qualitative_environments', COALESCE(w_qualitative_environments.qualitative_environments, '[]'::json), + 'quantitative_environments', COALESCE(w_quantitative_environments.quantitative_environments, '[]'::json) + )) as subcounts + from "observation_subcount" left join "w_qualitative_measurements" on "observation_subcount"."observation_subcount_id" = "w_qualitative_measurements"."observation_subcount_id" left join "w_quantitative_measurements" on "observation_subcount"."observation_subcount_id" = "w_quantitative_measurements"."observation_subcount_id" left join "w_qualitative_environments" on "observation_subcount"."observation_subcount_id" = "w_qualitative_environments"."observation_subcount_id" left join "w_quantitative_environments" on "observation_subcount"."observation_subcount_id" = "w_quantitative_environments"."observation_subcount_id" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey_id" = $6)) group by "survey_observation_id") select "survey_observation"."survey_observation_id", "survey_observation"."survey_id", "survey_observation"."itis_tsn", "survey_observation"."itis_scientific_name", "survey_observation"."survey_sample_site_id", "survey_observation"."survey_sample_method_id", "survey_observation"."survey_sample_period_id", "survey_observation"."latitude", "survey_observation"."longitude", "survey_observation"."count", "survey_observation"."observation_date", "survey_observation"."observation_time", "w_survey_sample_site"."survey_sample_site_name", "w_survey_sample_method"."survey_sample_method_name", "w_survey_sample_period"."survey_sample_period_start_datetime", COALESCE(w_subcounts.subcounts, '[]'::json) as subcounts from "survey_observation" left join "w_survey_sample_site" on "survey_observation"."survey_sample_site_id" = "w_survey_sample_site"."survey_sample_site_id" left join "w_survey_sample_method" on "survey_observation"."survey_sample_method_id" = "w_survey_sample_method"."survey_sample_method_id" left join "w_survey_sample_period" on "survey_observation"."survey_sample_period_id" = "w_survey_sample_period"."survey_sample_period_id" inner join "w_subcounts" on "w_subcounts"."survey_observation_id" = "survey_observation"."survey_observation_id" where "survey_observation"."survey_id" in (select "survey_id" from "survey" where "survey_id" = $7)`) + ); + expect(queryBuilder.toSQL().toNative().bindings).to.eql([1, 1, 1, 1, 1, 1, 1]); + }); + }); +}); diff --git a/api/src/repositories/project-repository.ts b/api/src/repositories/project-repository.ts index ab587d3b43..5e1ae63074 100644 --- a/api/src/repositories/project-repository.ts +++ b/api/src/repositories/project-repository.ts @@ -48,8 +48,16 @@ export class ProjectRepository extends BaseRepository { knex.raw(`MIN(s.start_date) as start_date`), knex.raw('MAX(s.end_date) as end_date'), knex.raw(`COALESCE(array_remove(array_agg(DISTINCT rl.region_name), null), '{}') as regions`), - knex.raw('array_remove(array_agg(distinct sp.itis_tsn), null) as focal_species'), - knex.raw('array_remove(array_agg(distinct st.type_id), null) as types') + knex.raw('array_remove(array_agg(DISTINCT sp.itis_tsn), null) as focal_species'), + knex.raw('array_remove(array_agg(DISTINCT st.type_id), null) as types'), + knex.raw(` + array_agg( + DISTINCT jsonb_build_object( + 'system_user_id', su.system_user_id, + 'display_name', su.display_name + ) + ) as members + `) ]) .from('project as p') .leftJoin('survey as s', 's.project_id', 'p.project_id') @@ -58,7 +66,8 @@ export class ProjectRepository extends BaseRepository { .leftJoin('survey_region as sr', 'sr.survey_id', 's.survey_id') .leftJoin('region_lookup as rl', 'sr.region_id', 'rl.region_id') .leftJoin('project_participation as ppa', 'p.project_id', 'ppa.project_id') - .groupBy(['p.project_id', 'p.name', 'p.objectives']); + .leftJoin('system_user as su', 'ppa.system_user_id', 'su.system_user_id') + .groupBy(['p.project_id', 'p.name']); // Ensure that users can only see projects that they are participating in, unless they are an administrator. if (!isUserAdmin) { diff --git a/api/src/repositories/sample-method-repository.test.ts b/api/src/repositories/sample-method-repository.test.ts index d95ead8b13..59bade8618 100644 --- a/api/src/repositories/sample-method-repository.test.ts +++ b/api/src/repositories/sample-method-repository.test.ts @@ -44,6 +44,22 @@ describe('SampleMethodRepository', () => { }); }); + describe('getSampleMethodsCountForTechniqueId', () => { + it('should return a non-zero count', async () => { + const count = 2; + const mockRows: any[] = [{ count }]; + const mockResponse = { rows: mockRows, rowCount: 1 } as any as Promise>; + const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().resolves(mockResponse) }); + + const techniqueIds = [1, 2]; + const repo = new SampleMethodRepository(dbConnectionObj); + const response = await repo.getSampleMethodsCountForTechniqueIds(techniqueIds); + + expect(dbConnectionObj.knex).to.have.been.calledOnce; + expect(response).to.eql(count); + }); + }); + describe('updateSampleMethod', () => { it('should update the record and return a single row', async () => { const mockRow = {}; diff --git a/api/src/repositories/sample-method-repository.ts b/api/src/repositories/sample-method-repository.ts index 608e0ed177..882044c70b 100644 --- a/api/src/repositories/sample-method-repository.ts +++ b/api/src/repositories/sample-method-repository.ts @@ -1,5 +1,6 @@ import SQL from 'sql-template-strings'; import { z } from 'zod'; +import { getKnex } from '../database/db'; import { ApiExecuteSQLError } from '../errors/api-error'; import { BaseRepository } from './base-repository'; import { InsertSamplePeriodRecord, UpdateSamplePeriodRecord } from './sample-period-repository'; @@ -97,6 +98,26 @@ export class SampleMethodRepository extends BaseRepository { return response.rows; } + /** + * Gets count of sample methods associated with one or more method technique Ids + * + * @param {number[]} techniqueIds + * @return {*} {Promise} + * @memberof SampleMethodRepository + */ + async getSampleMethodsCountForTechniqueIds(techniqueIds: number[]): Promise { + const knex = getKnex(); + const queryBuilder = knex + .queryBuilder() + .select(knex.raw('COUNT(*)::integer AS count')) + .from('survey_sample_method') + .whereIn('method_technique_id', techniqueIds); + + const response = await this.connection.knex(queryBuilder, z.object({ count: z.number() })); + + return response.rows[0].count; + } + /** * updates a survey Sample method. * diff --git a/api/src/repositories/standards-repository.test.ts b/api/src/repositories/standards-repository.test.ts new file mode 100644 index 0000000000..c37c5a0b91 --- /dev/null +++ b/api/src/repositories/standards-repository.test.ts @@ -0,0 +1,110 @@ +import chai, { expect } from 'chai'; +import { afterEach, describe, it } from 'mocha'; +import { QueryResult } from 'pg'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getMockDBConnection } from '../__mocks__/db'; +import { StandardsRepository } from './standards-repository'; + +chai.use(sinonChai); + +describe('StandardsRepository', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('getEnvironmentStandards', () => { + it('should successfully retrieve environment standards', async () => { + const mockData = { + quantitative: [ + { name: 'Quantitative Standard 1', description: 'Description 1' }, + { name: 'Quantitative Standard 2', description: 'Description 2' } + ], + qualitative: [ + { + name: 'Qualitative Standard 1', + description: 'Description 1', + options: [ + { name: 'Option 1', description: 'Option 1 Description' }, + { name: 'Option 2', description: 'Option 2 Description' } + ] + }, + { + name: 'Qualitative Standard 2', + description: 'Description 2', + options: [ + { name: 'Option 3', description: 'Option 3 Description' }, + { name: 'Option 4', description: 'Option 4 Description' } + ] + } + ] + }; + + const mockResponse = { + rows: [mockData], + rowCount: 1 + } as any as Promise>; + + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const repository = new StandardsRepository(dbConnection); + + const result = await repository.getEnvironmentStandards(); + + expect(result).to.deep.equal(mockData); + }); + }); + + describe('getMethodStandards', () => { + it('should successfully retrieve method standards', async () => { + const mockData = [ + { + method_lookup_id: 1, + name: 'Method 1', + description: ' Description 1', + attributes: { + quantitative: [ + { name: 'Method Standard 1', description: 'Description 1', unit: 'Unit 1' }, + { name: 'Method Standard 2', description: 'Description 2', unit: 'Unit 2' } + ], + qualitative: [ + { + name: 'Qualitative 1', + description: 'Description 1', + options: [ + { name: 'Option 1', description: 'Option 1 Description' }, + { name: 'Option 2', description: 'Option 2 Description' } + ] + }, + { + name: 'Qualitative 2', + description: 'Description 2', + options: [ + { name: 'Option 3', description: 'Option 3 Description' }, + { name: 'Option 4', description: 'Option 4 Description' } + ] + } + ] + } + } + ]; + + const mockResponse = { + rows: mockData, + rowCount: 1 + } as any as Promise>; + + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const repository = new StandardsRepository(dbConnection); + + const result = await repository.getMethodStandards(); + + expect(result).to.deep.equal(mockData); + }); + }); +}); diff --git a/api/src/repositories/standards-repository.ts b/api/src/repositories/standards-repository.ts new file mode 100644 index 0000000000..6e7893df94 --- /dev/null +++ b/api/src/repositories/standards-repository.ts @@ -0,0 +1,145 @@ +import SQL from 'sql-template-strings'; +import { + EnvironmentStandards, + EnvironmentStandardsSchema, + MethodStandard, + MethodStandardSchema +} from '../models/standards-view'; +import { BaseRepository } from './base-repository'; + +/** + * Standards repository + * + * @export + * @class standardsRepository + * @extends {BaseRepository} + */ +export class StandardsRepository extends BaseRepository { + /** + * Gets environment standards + * + * @param {string} keyword - search term for filtering the response based on environmental variable name + * @return {*} + * @memberof StandardsRepository + */ + async getEnvironmentStandards(keyword?: string): Promise { + const sql = SQL` + WITH + quan AS ( + SELECT + eq.name AS quant_name, + eq.description AS quant_description, + eq.unit + FROM + environment_quantitative eq + WHERE + eq.name ILIKE '%' || ${keyword ?? ''} || '%' + ), + qual AS ( + SELECT + eq.name AS qual_name, + eq.description AS qual_description, + json_agg(json_build_object('name', eqo.name, 'description', eqo.description)) as options + FROM + environment_qualitative_option eqo + LEFT JOIN + environment_qualitative eq ON eqo.environment_qualitative_id = eq.environment_qualitative_id + WHERE + eq.name ILIKE '%' || ${keyword ?? ''} || '%' + GROUP BY + eq.name, + eq.description + ) + SELECT + (SELECT json_agg(json_build_object('name', quant_name, 'description', quant_description, 'unit', unit)) FROM quan) as quantitative, + (SELECT json_agg(json_build_object('name', qual_name, 'description', qual_description, 'options', options)) FROM qual) as qualitative; + `; + + const response = await this.connection.sql(sql, EnvironmentStandardsSchema); + + return response.rows[0]; + } + + /** + * Gets method standards + * + * @param {string} keyword - search term for filtering the response based on method lookup name + * @return {*} + * @memberof StandardsRepository + */ + async getMethodStandards(keyword?: string): Promise { + const sql = SQL` + WITH + quan AS ( + SELECT + mlaq.method_lookup_id, + tq.name AS quant_name, + tq.description AS quant_description, + mlaq.unit + FROM + method_lookup_attribute_quantitative mlaq + LEFT JOIN + technique_attribute_quantitative tq ON mlaq.technique_attribute_quantitative_id = tq.technique_attribute_quantitative_id + ), + qual AS ( + SELECT + mlaq.method_lookup_id, + taq.name AS qual_name, + taq.description AS qual_description, + COALESCE(json_agg( + json_build_object( + 'name', mlaqo.name, + 'description', mlaqo.description + ) ORDER BY mlaqo.name + ), '[]'::json) AS options + FROM + method_lookup_attribute_qualitative_option mlaqo + LEFT JOIN + method_lookup_attribute_qualitative mlaq ON mlaqo.method_lookup_attribute_qualitative_id = mlaq.method_lookup_attribute_qualitative_id + LEFT JOIN + technique_attribute_qualitative taq ON mlaq.technique_attribute_qualitative_id = taq.technique_attribute_qualitative_id + GROUP BY + mlaq.method_lookup_id, + taq.name, + taq.description + ), + method_lookup AS ( + SELECT + ml.method_lookup_id, + ml.name, + ml.description, + json_build_object( + 'quantitative', ( + SELECT COALESCE(json_agg( + json_build_object( + 'name', quan.quant_name, + 'description', quan.quant_description, + 'unit', quan.unit + ) ORDER BY quan.quant_name + ), '[]'::json) FROM quan + WHERE quan.method_lookup_id = ml.method_lookup_id + ), + 'qualitative', ( + SELECT COALESCE(json_agg( + json_build_object( + 'name', qual.qual_name, + 'description', qual.qual_description, + 'options', qual.options + ) ORDER BY qual.qual_name + ), '[]'::json) FROM qual + WHERE qual.method_lookup_id = ml.method_lookup_id + ) + ) AS attributes + FROM + method_lookup ml + WHERE + ml.name ILIKE '%' || ${keyword ?? ''} || '%' + ) + SELECT * FROM method_lookup; + `; + + const response = await this.connection.sql(sql, MethodStandardSchema); + + return response.rows; + } +} diff --git a/api/src/repositories/survey-critter-repository.test.ts b/api/src/repositories/survey-critter-repository.test.ts index 9969e53269..b57eee437f 100644 --- a/api/src/repositories/survey-critter-repository.test.ts +++ b/api/src/repositories/survey-critter-repository.test.ts @@ -52,19 +52,6 @@ describe('SurveyRepository', () => { }); }); - describe('upsertDeployment', () => { - it('should update existing row', async () => { - const mockResponse = { rows: [{ submissionId: 1 }], rowCount: 1 } as any as Promise>; - const dbConnection = getMockDBConnection({ knex: () => mockResponse }); - - const repository = new SurveyCritterRepository(dbConnection); - - const response = await repository.upsertDeployment(1, 'deployment_id'); - - expect(response).to.be.undefined; - }); - }); - describe('updateCritter', () => { it('should update existing row', async () => { const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; @@ -74,17 +61,4 @@ describe('SurveyRepository', () => { expect(response).to.be.undefined; }); }); - - describe('deleteDeployment', () => { - it('should delete existing row', async () => { - const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; - const dbConnection = getMockDBConnection({ knex: () => mockResponse }); - - const repository = new SurveyCritterRepository(dbConnection); - - const response = await repository.removeDeployment(1, 'deployment_id'); - - expect(response).to.be.undefined; - }); - }); }); diff --git a/api/src/repositories/survey-critter-repository.ts b/api/src/repositories/survey-critter-repository.ts index 2da82b9cb8..602805178d 100644 --- a/api/src/repositories/survey-critter-repository.ts +++ b/api/src/repositories/survey-critter-repository.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { getKnex } from '../database/db'; import { ApiExecuteSQLError } from '../errors/api-error'; import { IAnimalAdvancedFilters } from '../models/animal-view'; -import { ITelemetryAdvancedFilters } from '../models/telemetry-view'; +import { IAllTelemetryAdvancedFilters } from '../models/telemetry-view'; import { getLogger } from '../utils/logger'; import { ApiPaginationOptions } from '../zod-schema/pagination'; import { BaseRepository } from './base-repository'; @@ -42,6 +42,24 @@ export class SurveyCritterRepository extends BaseRepository { return response.rows; } + /** + * Get a specific critter by its integer Id + * + * @param {number} surveyId + * @param {number} critterId + * @return {*} {Promise} + * @memberof SurveyCritterRepository + */ + async getCritterById(surveyId: number, critterId: number): Promise { + defaultLog.debug({ label: 'getCritterById', critterId }); + + const queryBuilder = getKnex().table('critter').select().where({ survey_id: surveyId, critter_id: critterId }); + + const response = await this.connection.knex(queryBuilder); + + return response.rows[0]; + } + /** * Constructs a non-paginated query to retrieve critters that are available to the user based on the user's * permissions and filter criteria. @@ -83,7 +101,7 @@ export class SurveyCritterRepository extends BaseRepository { * * @param {boolean} isUserAdmin * @param {(number | null)} systemUserId The system user id of the user making the request - * @param {ITelemetryAdvancedFilters} [filterFields] + * @param {IAllTelemetryAdvancedFilters} [filterFields] * @param {ApiPaginationOptions} [pagination] * @return {*} {Promise} * @memberof SurveyCritterRepository @@ -91,7 +109,7 @@ export class SurveyCritterRepository extends BaseRepository { async findCritters( isUserAdmin: boolean, systemUserId: number | null, - filterFields?: ITelemetryAdvancedFilters, + filterFields?: IAllTelemetryAdvancedFilters, pagination?: ApiPaginationOptions ): Promise { const query = this._makeFindCrittersQuery(isUserAdmin, systemUserId, filterFields); @@ -115,14 +133,14 @@ export class SurveyCritterRepository extends BaseRepository { * * @param {boolean} isUserAdmin * @param {(number | null)} systemUserId The system user id of the user making the request - * @param {ITelemetryAdvancedFilters} [filterFields] + * @param {IAllTelemetryAdvancedFilters} [filterFields] * @return {*} {Promise} * @memberof SurveyCritterRepository */ async findCrittersCount( isUserAdmin: boolean, systemUserId: number | null, - filterFields?: ITelemetryAdvancedFilters + filterFields?: IAllTelemetryAdvancedFilters ): Promise { const findCrittersQuery = this._makeFindCrittersQuery(isUserAdmin, systemUserId, filterFields); @@ -202,44 +220,4 @@ export class SurveyCritterRepository extends BaseRepository { await this.connection.knex(queryBuilder); } - - /** - * Will insert a new critter - deployment uuid association, or update if it already exists. - * This update operation intentionally changes nothing. Only really being done to trigger update audit columns. - * - * @param {number} critterId - * @param {string} deplyomentId - * @return {*} {Promise} - * @memberof SurveyCritterRepository - */ - async upsertDeployment(critterId: number, deplyomentId: string): Promise { - defaultLog.debug({ label: 'addDeployment', deplyomentId }); - - const queryBuilder = getKnex() - .table('deployment') - .insert({ critter_id: critterId, bctw_deployment_id: deplyomentId }) - .onConflict(['critter_id', 'bctw_deployment_id']) - .merge(['critter_id', 'bctw_deployment_id']); - - await this.connection.knex(queryBuilder); - } - - /** - * Deletes a deployment row. - * - * @param {number} critterId - * @param {string} deploymentId - * @return {*} {Promise} - * @memberof SurveyCritterRepository - */ - async removeDeployment(critterId: number, deploymentId: string): Promise { - defaultLog.debug({ label: 'removeDeployment', deploymentId }); - - const queryBuilder = getKnex() - .table('deployment') - .where({ critter_id: critterId, bctw_deployment_id: deploymentId }) - .delete(); - - await this.connection.knex(queryBuilder); - } } diff --git a/api/src/repositories/survey-repository.test.ts b/api/src/repositories/survey-repository.test.ts index 134ec07c92..cfcffdb4e2 100644 --- a/api/src/repositories/survey-repository.test.ts +++ b/api/src/repositories/survey-repository.test.ts @@ -9,7 +9,12 @@ import { PostProprietorData, PostSurveyObject } from '../models/survey-create'; import { PutSurveyObject } from '../models/survey-update'; import { GetAttachmentsData, GetSurveyProprietorData, GetSurveyPurposeAndMethodologyData } from '../models/survey-view'; import { getMockDBConnection } from '../__mocks__/db'; -import { SurveyRecord, SurveyRepository, SurveyTypeRecord } from './survey-repository'; +import { + SurveyRecord, + SurveyRepository, + SurveyTaxonomyWithEcologicalUnits, + SurveyTypeRecord +} from './survey-repository'; chai.use(sinonChai); @@ -140,26 +145,46 @@ describe('SurveyRepository', () => { describe('getSpeciesData', () => { it('should return result', async () => { - const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; + const mockRows: SurveyTaxonomyWithEcologicalUnits[] = [ + { + itis_tsn: 123456, + ecological_units: [ + { + critterbase_collection_category_id: '123-456-789', + critterbase_collection_unit_id: '987-654-321' + } + ] + }, + { + itis_tsn: 654321, + ecological_units: [] + } + ]; + const mockResponse = { rows: mockRows, rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); - const response = await repository.getSpeciesData(1); + const surveyId = 1; - expect(response).to.eql([{ id: 1 }]); + const response = await repository.getSpeciesData(surveyId); + + expect(response).to.eql(mockRows); }); it('should return empty rows', async () => { - const mockResponse = { rows: [], rowCount: 1 } as any as Promise>; + const mockRows: SurveyTaxonomyWithEcologicalUnits[] = []; + const mockResponse = { rows: mockRows, rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); - const response = await repository.getSpeciesData(1); + const surveyId = 1; + + const response = await repository.getSpeciesData(surveyId); expect(response).to.not.be.null; - expect(response).to.eql([]); + expect(response).to.eql(mockRows); }); }); @@ -539,60 +564,6 @@ describe('SurveyRepository', () => { }); }); - describe('insertAncillarySpecies', () => { - it('should return result', async () => { - const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; - const dbConnection = getMockDBConnection({ sql: () => mockResponse }); - - const repository = new SurveyRepository(dbConnection); - - const response = await repository.insertAncillarySpecies(1, 1); - - expect(response).to.eql(1); - }); - - it('should throw an error', async () => { - const mockResponse = { rows: undefined, rowCount: 0 } as any as Promise>; - const dbConnection = getMockDBConnection({ sql: () => mockResponse }); - - const repository = new SurveyRepository(dbConnection); - - try { - await repository.insertAncillarySpecies(1, 1); - expect.fail(); - } catch (error) { - expect((error as Error).message).to.equal('Failed to insert ancillary species data'); - } - }); - }); - - describe('insertVantageCodes', () => { - it('should return result', async () => { - const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; - const dbConnection = getMockDBConnection({ sql: () => mockResponse }); - - const repository = new SurveyRepository(dbConnection); - - const response = await repository.insertVantageCodes(1, 1); - - expect(response).to.eql(1); - }); - - it('should throw an error', async () => { - const mockResponse = { rows: undefined, rowCount: 0 } as any as Promise>; - const dbConnection = getMockDBConnection({ sql: () => mockResponse }); - - const repository = new SurveyRepository(dbConnection); - - try { - await repository.insertVantageCodes(1, 1); - expect.fail(); - } catch (error) { - expect((error as Error).message).to.equal('Failed to insert vantage codes'); - } - }); - }); - describe('insertSurveyProprietor', () => { it('should return undefined if data is not proprietary', async () => { const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; @@ -759,19 +730,6 @@ describe('SurveyRepository', () => { }); }); - describe('deleteSurveyVantageCodes', () => { - it('should return result', async () => { - const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; - const dbConnection = getMockDBConnection({ sql: () => mockResponse }); - - const repository = new SurveyRepository(dbConnection); - - const response = await repository.deleteSurveyVantageCodes(1); - - expect(response).to.eql(undefined); - }); - }); - describe('updateSurveyDetailsData', () => { it('should return undefined and ue all inputs', async () => { const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; diff --git a/api/src/repositories/survey-repository.ts b/api/src/repositories/survey-repository.ts index 42e30e7dd2..83c2193590 100644 --- a/api/src/repositories/survey-repository.ts +++ b/api/src/repositories/survey-repository.ts @@ -13,12 +13,12 @@ import { GetSurveyPurposeAndMethodologyData, ISurveyAdvancedFilters } from '../models/survey-view'; +import { IPostCollectionUnit } from '../services/critterbase-service'; import { ApiPaginationOptions } from '../zod-schema/pagination'; import { BaseRepository } from './base-repository'; export interface IGetSpeciesData { itis_tsn: number; - is_focal: boolean; } export interface IObservationSubmissionInsertDetails { @@ -118,6 +118,18 @@ export const SurveyBasicFields = z.object({ export type SurveyBasicFields = z.infer; +export const SurveyTaxonomyWithEcologicalUnits = z.object({ + itis_tsn: z.number(), + ecological_units: z.array( + z.object({ + critterbase_collection_unit_id: z.string().uuid(), + critterbase_collection_category_id: z.string().uuid() + }) + ) +}); + +export type SurveyTaxonomyWithEcologicalUnits = z.infer; + export class SurveyRepository extends BaseRepository { /** * Deletes a survey and any associations for a given survey @@ -358,21 +370,41 @@ export class SurveyRepository extends BaseRepository { * Get species data for a given survey ID * * @param {number} surveyId - * @returns {*} {Promise} + * @return {*} {Promise} * @memberof SurveyRepository */ - async getSpeciesData(surveyId: number): Promise { + async getSpeciesData(surveyId: number): Promise { const sqlStatement = SQL` + WITH w_ecological_units AS ( SELECT - itis_tsn, - is_focal + ssu.study_species_id, + json_agg( + json_build_object( + 'critterbase_collection_category_id', ssu.critterbase_collection_category_id, + 'critterbase_collection_unit_id', ssu.critterbase_collection_unit_id + ) + ) AS units FROM - study_species + study_species_unit ssu + LEFT JOIN + study_species ss ON ss.study_species_id = ssu.study_species_id WHERE - survey_id = ${surveyId}; + ss.survey_id = ${surveyId} + GROUP BY + ssu.study_species_id + ) + SELECT + ss.itis_tsn, + COALESCE(weu.units, '[]'::json) AS ecological_units + FROM + study_species ss + LEFT JOIN + w_ecological_units weu ON weu.study_species_id = ss.study_species_id + WHERE + ss.survey_id = ${surveyId}; `; - const response = await this.connection.sql(sqlStatement); + const response = await this.connection.sql(sqlStatement, SurveyTaxonomyWithEcologicalUnits); return response.rows; } @@ -387,15 +419,9 @@ export class SurveyRepository extends BaseRepository { const sqlStatement = SQL` SELECT s.additional_details, - array_remove(array_agg(DISTINCT io.intended_outcome_id), NULL) as intended_outcome_ids, - array_remove(array_agg(DISTINCT sv.vantage_id), NULL) as vantage_ids - + array_remove(array_agg(DISTINCT io.intended_outcome_id), NULL) as intended_outcome_ids FROM survey s - LEFT OUTER JOIN - survey_vantage sv - ON - sv.survey_id = s.survey_id LEFT OUTER JOIN survey_intended_outcome io ON @@ -759,7 +785,7 @@ export class SurveyRepository extends BaseRepository { if (!result?.id) { throw new ApiExecuteSQLError('Failed to insert focal species data', [ - 'SurveyRepository->insertSurveyData', + 'SurveyRepository->insertFocalSpecies', 'response was null or undefined, expected response != null' ]); } @@ -768,32 +794,32 @@ export class SurveyRepository extends BaseRepository { } /** - * Inserts a new Ancillary species record and returns the new ID + * Inserts focal ecological units for focal species * - * @param {number} ancillary_species_id - * @param {number} surveyId + * @param {IPostCollectionUnit} ecologicalUnitObject + * @param {number} studySpeciesId * @returns {*} Promise * @memberof SurveyRepository */ - async insertAncillarySpecies(ancillary_species_id: number, surveyId: number): Promise { + async insertFocalSpeciesUnits(ecologicalUnitObject: IPostCollectionUnit, studySpeciesId: number): Promise { const sqlStatement = SQL` - INSERT INTO study_species ( - itis_tsn, - is_focal, - survey_id + INSERT INTO study_species_unit ( + study_species_id, + critterbase_collection_category_id, + critterbase_collection_unit_id ) VALUES ( - ${ancillary_species_id}, - FALSE, - ${surveyId} - ) RETURNING study_species_id as id; + ${studySpeciesId}, + ${ecologicalUnitObject.critterbase_collection_category_id}, + ${ecologicalUnitObject.critterbase_collection_unit_id} + ) RETURNING study_species_id AS id; `; const response = await this.connection.sql(sqlStatement); const result = response.rows?.[0]; if (!result?.id) { - throw new ApiExecuteSQLError('Failed to insert ancillary species data', [ - 'SurveyRepository->insertSurveyData', + throw new ApiExecuteSQLError('Failed to insert focal species units data', [ + 'SurveyRepository->insertFocalSpeciesUnits', 'response was null or undefined, expected response != null' ]); } @@ -801,37 +827,6 @@ export class SurveyRepository extends BaseRepository { return result.id; } - /** - * Inserts a new vantage code record and returns the new ID - * - * @param {number} vantage_code_id - * @param {number} surveyId - * @returns {*} Promise - * @memberof SurveyRepository - */ - async insertVantageCodes(vantage_code_id: number, surveyId: number): Promise { - const sqlStatement = SQL` - INSERT INTO survey_vantage ( - vantage_id, - survey_id - ) VALUES ( - ${vantage_code_id}, - ${surveyId} - ) RETURNING survey_vantage_id as id; - `; - - const response = await this.connection.sql(sqlStatement); - const result = response.rows?.[0]; - - if (!result?.id) { - throw new ApiExecuteSQLError('Failed to insert vantage codes', [ - 'SurveyRepository->insertVantageCodes', - 'response was null or undefined, expected response != null' - ]); - } - return result.id; - } - /** * Insert many rows associating a survey id to various intended outcome ids. * @@ -869,7 +864,6 @@ export class SurveyRepository extends BaseRepository { /** * Inserts a new Survey Proprietor record and returns the new ID * - * @param {number} ancillary_species_id * @param {number} surveyId * @returns {*} Promise * @memberof SurveyRepository @@ -1076,36 +1070,36 @@ export class SurveyRepository extends BaseRepository { } /** - * Breaks permit survey link for a given survey ID + * Deletes ecological units data for focal species in a given survey ID * * @param {number} surveyId * @returns {*} Promise * @memberof SurveyRepository */ - async unassociatePermitFromSurvey(surveyId: number) { + async deleteSurveySpeciesUnitData(surveyId: number) { const sqlStatement = SQL` - UPDATE - permit - SET - survey_id = ${null} - WHERE - survey_id = ${surveyId}; + DELETE FROM study_species_unit ssu + USING study_species ss + WHERE ss.study_species_id = ssu.study_species_id + AND ss.survey_id = ${surveyId}; `; await this.connection.sql(sqlStatement); } /** - * Deletes Survey proprietor data for a given survey ID + * Breaks permit survey link for a given survey ID * * @param {number} surveyId * @returns {*} Promise * @memberof SurveyRepository */ - async deleteSurveyProprietorData(surveyId: number) { + async unassociatePermitFromSurvey(surveyId: number) { const sqlStatement = SQL` - DELETE - from survey_proprietor + UPDATE + permit + SET + survey_id = ${null} WHERE survey_id = ${surveyId}; `; @@ -1114,16 +1108,16 @@ export class SurveyRepository extends BaseRepository { } /** - * Deletes Survey vantage codes for a given survey ID + * Deletes Survey proprietor data for a given survey ID * * @param {number} surveyId * @returns {*} Promise * @memberof SurveyRepository */ - async deleteSurveyVantageCodes(surveyId: number) { + async deleteSurveyProprietorData(surveyId: number) { const sqlStatement = SQL` DELETE - from survey_vantage + from survey_proprietor WHERE survey_id = ${surveyId}; `; diff --git a/api/src/repositories/telemetry-repository.ts b/api/src/repositories/telemetry-repository.ts index 501ebae74e..4246e7ba9e 100644 --- a/api/src/repositories/telemetry-repository.ts +++ b/api/src/repositories/telemetry-repository.ts @@ -8,8 +8,17 @@ import { BaseRepository } from './base-repository'; const defaultLog = getLogger('repositories/telemetry-repository'); export const Deployment = z.object({ + /** + * SIMS deployment primary ID + */ deployment_id: z.number(), + /** + * SIMS critter primary ID + */ critter_id: z.number(), + /** + * BCTW deployment primary ID + */ bctw_deployment_id: z.string().uuid() }); @@ -113,4 +122,35 @@ export class TelemetryRepository extends BaseRepository { return response.rows; } + + /** + * Get deployments for the provided survey id. + * + * Note: SIMS does not store deployment information, beyond an ID. Deployment details must be fetched from the + * external BCTW API. + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof TelemetryRepository + */ + async getDeploymentsBySurveyId(surveyId: number): Promise { + const sqlStatement = SQL` + SELECT + deployment.deployment_id, + deployment.critter_id, + deployment.bctw_deployment_id + FROM + deployment + LEFT JOIN + critter + ON + critter.critter_id = deployment.critter_id + WHERE + critter.survey_id = ${surveyId}; + `; + + const response = await this.connection.sql(sqlStatement, Deployment); + + return response.rows; + } } diff --git a/api/src/services/administrative-activity-service.test.ts b/api/src/services/administrative-activity-service.test.ts index 0f570b87cf..74cc0e175f 100644 --- a/api/src/services/administrative-activity-service.test.ts +++ b/api/src/services/administrative-activity-service.test.ts @@ -42,7 +42,9 @@ describe('AdministrativeActivityService', () => { identitySource: 'BCEIDBASIC' }, notes: null, - create_date: '2023-05-02T02:04:10.751Z' + create_date: '2023-05-02T02:04:10.751Z', + update_date: '2023-05-02T02:04:10.751Z', + updated_by: 'Doe, John WLRS:EX' } ]); @@ -66,7 +68,9 @@ describe('AdministrativeActivityService', () => { identitySource: 'BCEIDBASIC' }, notes: null, - create_date: '2023-05-02T02:04:10.751Z' + create_date: '2023-05-02T02:04:10.751Z', + update_date: '2023-05-02T02:04:10.751Z', + updated_by: 'Doe, John WLRS:EX' } ]); }); diff --git a/api/src/services/analytics-service.test.ts b/api/src/services/analytics-service.test.ts new file mode 100644 index 0000000000..ccd67465f6 --- /dev/null +++ b/api/src/services/analytics-service.test.ts @@ -0,0 +1,518 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { + ObservationAnalyticsResponse, + ObservationCountByGroup, + ObservationCountByGroupSQLResponse, + ObservationCountByGroupWithMeasurements +} from '../models/observation-analytics'; +import { AnalyticsRepository } from '../repositories/analytics-repository'; +import { getMockDBConnection } from '../__mocks__/db'; +import { AnalyticsService } from './analytics-service'; +import { + CBQualitativeMeasurementTypeDefinition, + CBQuantitativeMeasurementTypeDefinition, + CritterbaseService +} from './critterbase-service'; + +chai.use(sinonChai); + +describe('AnalyticsService', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('getObservationCountByGroup', () => { + it('returns an array of observation count analytics records', async () => { + const dbConnection = getMockDBConnection(); + + const analyticsService = new AnalyticsService(dbConnection); + + const mockGetObservationCountByGroupResponse: ObservationCountByGroupSQLResponse[] = [ + { + id: '123-456-789', + row_count: 3, + individual_count: 62, + individual_percentage: 57.41, + survey_sample_site_id: 4, + survey_sample_period_id: 4, + quant_measurements: {}, + qual_measurements: { '337f67fa-296d-43a9-88b2-ffdc77891aee': '61d1532d-b06e-4300-8da6-d195cc98f34e' } + }, + { + id: '987-654-321', + row_count: 2, + individual_count: 46, + individual_percentage: 42.59, + survey_sample_site_id: 4, + survey_sample_period_id: 4, + quant_measurements: {}, + qual_measurements: { '337f67fa-296d-43a9-88b2-ffdc77891aee': 'dd9a1672-ac93-4598-b166-caad463ed6f2' } + } + ]; + + sinon + .stub(AnalyticsRepository.prototype, 'getObservationCountByGroup') + .resolves(mockGetObservationCountByGroupResponse); + + sinon.stub(CritterbaseService.prototype, 'getQualitativeMeasurementTypeDefinition').resolves([ + { + taxon_measurement_id: '337f67fa-296d-43a9-88b2-ffdc77891aee', + itis_tsn: 180692, + measurement_name: 'antler configuration', + measurement_desc: null, + options: [ + { + qualitative_option_id: '61d1532d-b06e-4300-8da6-d195cc98f34e', + option_label: 'less than 3 points', + option_value: 0, + option_desc: null + }, + { + qualitative_option_id: 'dd9a1672-ac93-4598-b166-caad463ed6f2', + option_label: 'more than 3 points', + option_value: 1, + option_desc: null + } + ] + } + ]); + + sinon.stub(CritterbaseService.prototype, 'getQuantitativeMeasurementTypeDefinition').resolves([]); + + const surveyIds = [4]; + const groupByColumns = ['survey_sample_site_id', 'survey_sample_period_id']; + const groupByQuantitativeMeasurements: string[] = []; + const groupByQualitativeMeasurements = ['337f67fa-296d-43a9-88b2-ffdc77891aee']; + + const response = await analyticsService.getObservationCountByGroup( + surveyIds, + groupByColumns, + groupByQuantitativeMeasurements, + groupByQualitativeMeasurements + ); + + const expectedResponse: ObservationAnalyticsResponse[] = [ + { + id: '123-456-789', + row_count: 3, + individual_count: 62, + individual_percentage: 57.41, + qualitative_measurements: [ + { + option: { option_id: '61d1532d-b06e-4300-8da6-d195cc98f34e', option_label: 'less than 3 points' }, + taxon_measurement_id: '337f67fa-296d-43a9-88b2-ffdc77891aee', + measurement_name: 'antler configuration' + } + ], + quantitative_measurements: [], + survey_sample_period_id: 4, + survey_sample_site_id: 4 + }, + { + id: '987-654-321', + row_count: 2, + individual_count: 46, + individual_percentage: 42.59, + qualitative_measurements: [ + { + option: { option_id: 'dd9a1672-ac93-4598-b166-caad463ed6f2', option_label: 'more than 3 points' }, + taxon_measurement_id: '337f67fa-296d-43a9-88b2-ffdc77891aee', + measurement_name: 'antler configuration' + } + ], + quantitative_measurements: [], + survey_sample_period_id: 4, + survey_sample_site_id: 4 + } + ]; + + expect(response).to.eql(expectedResponse); + }); + }); + + describe('_filterNonEmptyColumns', () => { + it('returns an array of non-empty columns', () => { + const dbConnectionObj = getMockDBConnection(); + + const analyticsService = new AnalyticsService(dbConnectionObj); + + const columns = ['a', '', 'b', 'c', '']; + + const result = analyticsService._filterNonEmptyColumns(columns); + + expect(result).to.eql(['a', 'b', 'c']); + }); + }); + + describe('_fetchQualitativeDefinitions', () => { + it('returns an array of qualitative measurement type definitions', async () => { + const dbConnectionObj = getMockDBConnection(); + + const analyticsService = new AnalyticsService(dbConnectionObj); + + const mockQualitativeMeasurementTypeDefinitions: CBQualitativeMeasurementTypeDefinition[] = [ + { + itis_tsn: 123456, + taxon_measurement_id: '1', + measurement_name: 'measurement_name', + measurement_desc: 'measurement_desc', + options: [ + { + qualitative_option_id: '3', + option_label: 'option_label', + option_value: 0, + option_desc: 'option_desc' + } + ] + } + ]; + + sinon + .stub(CritterbaseService.prototype, 'getQualitativeMeasurementTypeDefinition') + .resolves(mockQualitativeMeasurementTypeDefinitions); + + const counts: ObservationCountByGroupSQLResponse[] = [ + { + id: '123-456-789', + row_count: 1, + individual_count: 1, + individual_percentage: 1, + life_stage: 'adult', + antler_length: 20, + quant_measurements: { + '1': 1 + }, + qual_measurements: { + '2': '2' + } + } + ]; + + const result = await analyticsService._fetchQualitativeDefinitions(counts); + + expect(result).to.eql(mockQualitativeMeasurementTypeDefinitions); + }); + }); + + describe('_fetchQuantitativeDefinitions', () => { + it('returns an array of quantitative measurement type definitions', async () => { + const dbConnectionObj = getMockDBConnection(); + + const analyticsService = new AnalyticsService(dbConnectionObj); + + const mockQuantitativeMeasurementTypeDefinitions: CBQuantitativeMeasurementTypeDefinition[] = [ + { + itis_tsn: 123456, + taxon_measurement_id: '1', + measurement_name: 'measurement_name', + measurement_desc: 'measurement_desc', + min_value: 1, + max_value: 2, + unit: 'millimeter' + } + ]; + + sinon + .stub(CritterbaseService.prototype, 'getQuantitativeMeasurementTypeDefinition') + .resolves(mockQuantitativeMeasurementTypeDefinitions); + + const counts: ObservationCountByGroupSQLResponse[] = [ + { + id: '123-456-789', + row_count: 5, + individual_count: 10, + individual_percentage: 46, + life_stage: 'adult', + antler_length: 20, + quant_measurements: { + '1': 1 + }, + qual_measurements: { + '2': '2' + } + } + ]; + + const result = await analyticsService._fetchQuantitativeDefinitions(counts); + + expect(result).to.eql(mockQuantitativeMeasurementTypeDefinitions); + }); + }); + + describe('_getQualitativeMeasurementIds', () => { + it('returns an array of measurement IDs', () => { + const dbConnectionObj = getMockDBConnection(); + + const analyticsService = new AnalyticsService(dbConnectionObj); + + const counts: ObservationCountByGroupSQLResponse[] = [ + { + id: '123-456-789', + row_count: 1, + individual_count: 1, + individual_percentage: 1, + life_stage: 'adult', + antler_length: 20, + quant_measurements: { + '1': 1, + '2': 2 + }, + qual_measurements: { + '3': '3', + '4': '4' + } + } + ]; + + const result = analyticsService._getQualitativeMeasurementIds(counts); + + expect(result).to.eql(['3', '4']); + }); + }); + + describe('_getQuantitativeMeasurementIds', () => { + it('returns an array of measurement IDs', () => { + const dbConnectionObj = getMockDBConnection(); + + const analyticsService = new AnalyticsService(dbConnectionObj); + + const counts: ObservationCountByGroupSQLResponse[] = [ + { + id: '123-456-789', + row_count: 1, + individual_count: 1, + individual_percentage: 1, + life_stage: 'adult', + antler_length: 20, + quant_measurements: { + '1': 1, + '2': 2 + }, + qual_measurements: { + '3': '3', + '4': '4' + } + } + ]; + + const result = analyticsService._getQuantitativeMeasurementIds(counts); + + expect(result).to.eql(['1', '2']); + }); + }); + + describe('_processCounts', () => { + it('returns an array of observation analytics responses', () => { + const dbConnectionObj = getMockDBConnection(); + + const analyticsService = new AnalyticsService(dbConnectionObj); + + const counts: (ObservationCountByGroupWithMeasurements & ObservationCountByGroup)[] = [ + { + row_count: 1, + individual_count: 1, + individual_percentage: 1, + quant_measurements: [ + { + critterbase_taxon_measurement_id: '1', + value: 1 + } + ], + qual_measurements: [ + { + critterbase_taxon_measurement_id: '2', + option_id: '3' + } + ] + } + ]; + + const qualitativeDefinitions: CBQualitativeMeasurementTypeDefinition[] = []; + const quantitativeDefinitions: CBQuantitativeMeasurementTypeDefinition[] = []; + + const result = analyticsService._processCounts(counts, qualitativeDefinitions, quantitativeDefinitions); + + expect(result).to.be.an('array'); + expect(result.length).to.equal(1); + }); + }); + + describe('_mapQualitativeMeasurements', () => { + it('returns an array of qualitative measurement analytics', () => { + const dbConnectionObj = getMockDBConnection(); + + const analyticsService = new AnalyticsService(dbConnectionObj); + + const qualMeasurements = [ + { + critterbase_taxon_measurement_id: '11', + option_id: '1' + }, + { + critterbase_taxon_measurement_id: '22', + option_id: null + } + ]; + + const definitions: CBQualitativeMeasurementTypeDefinition[] = [ + { + itis_tsn: 123456, + taxon_measurement_id: '11', + measurement_name: 'measurement_name', + measurement_desc: 'measurement_desc', + options: [ + { + qualitative_option_id: '1', + option_label: 'option_label', + option_value: 1, + option_desc: 'option_desc' + } + ] + }, + { + itis_tsn: 123456, + taxon_measurement_id: '22', + measurement_name: 'measurement_name', + measurement_desc: 'measurement_desc', + options: [ + { + qualitative_option_id: '2', + option_label: 'option_label', + option_value: 2, + option_desc: 'option_desc' + } + ] + } + ]; + + const result = analyticsService._mapQualitativeMeasurements(qualMeasurements, definitions); + + expect(result).to.eql([ + { + taxon_measurement_id: '11', + measurement_name: 'measurement_name', + option: { + option_id: '1', + option_label: 'option_label' + } + }, + { + taxon_measurement_id: '22', + measurement_name: 'measurement_name', + option: { + option_id: null, + option_label: '' + } + } + ]); + }); + }); + + describe('_mapQuantitativeMeasurements', () => { + it('returns an array of quantitative measurement analytics', () => { + const dbConnectionObj = getMockDBConnection(); + + const analyticsService = new AnalyticsService(dbConnectionObj); + + const quantMeasurements = [ + { + critterbase_taxon_measurement_id: '11', + value: 1 + }, + { + critterbase_taxon_measurement_id: '22', + value: null + } + ]; + + const definitions: CBQuantitativeMeasurementTypeDefinition[] = [ + { + itis_tsn: 123456, + taxon_measurement_id: '11', + measurement_name: 'measurement_name', + measurement_desc: 'measurement_desc', + min_value: 1, + max_value: 2, + unit: 'millimeter' + }, + { + itis_tsn: 123456, + taxon_measurement_id: '22', + measurement_name: 'measurement_name', + measurement_desc: 'measurement_desc', + min_value: 1, + max_value: 2, + unit: 'centimeter' + } + ]; + + const result = analyticsService._mapQuantitativeMeasurements(quantMeasurements, definitions); + + expect(result).to.eql([ + { + taxon_measurement_id: '11', + measurement_name: 'measurement_name', + value: 1 + }, + { + measurement_name: 'measurement_name', + taxon_measurement_id: '22', + value: null + } + ]); + }); + }); + + describe('_transformMeasurementObjectKeysToArrays', () => { + it('returns an array of transformed observation counts', () => { + const dbConnectionObj = getMockDBConnection(); + + const analyticsService = new AnalyticsService(dbConnectionObj); + + const counts: ObservationCountByGroupSQLResponse[] = [ + { + id: '123-456-789', + row_count: 1, + individual_count: 1, + individual_percentage: 1, + life_stage: 'adult', + antler_length: 20, + quant_measurements: { + '1': 1 + }, + qual_measurements: { + '2': '2' + } + } + ]; + + const result = analyticsService._transformMeasurementObjectKeysToArrays(counts); + + expect(result).to.eql([ + { + id: '123-456-789', + row_count: 1, + individual_count: 1, + individual_percentage: 1, + qual_measurements: [ + { + critterbase_taxon_measurement_id: '2', + option_id: '2' + } + ], + quant_measurements: [ + { + critterbase_taxon_measurement_id: '1', + value: 1 + } + ], + antler_length: 20, + life_stage: 'adult' + } + ]); + }); + }); +}); diff --git a/api/src/services/analytics-service.ts b/api/src/services/analytics-service.ts new file mode 100644 index 0000000000..06cf779344 --- /dev/null +++ b/api/src/services/analytics-service.ts @@ -0,0 +1,282 @@ +import { IDBConnection } from '../database/db'; +import { + ObservationAnalyticsResponse, + ObservationCountByGroup, + ObservationCountByGroupSQLResponse, + ObservationCountByGroupWithMeasurements, + QualitativeMeasurementAnalytics, + QuantitativeMeasurementAnalytics +} from '../models/observation-analytics'; +import { AnalyticsRepository } from '../repositories/analytics-repository'; +import { + CBQualitativeMeasurementTypeDefinition, + CBQuantitativeMeasurementTypeDefinition, + CritterbaseService +} from './critterbase-service'; +import { DBService } from './db-service'; + +/** + * Handles all business logic related to data analytics. + * + * @export + * @class AnalyticsService + * @extends {DBService} + */ +export class AnalyticsService extends DBService { + analyticsRepository: AnalyticsRepository; + + critterbaseService: CritterbaseService; + + constructor(connection: IDBConnection) { + super(connection); + this.analyticsRepository = new AnalyticsRepository(connection); + + this.critterbaseService = new CritterbaseService({ + keycloak_guid: this.connection.systemUserGUID(), + username: this.connection.systemUserIdentifier() + }); + } + + /** + * Gets observation counts by group for given survey IDs and groupings. + * + * @param {number[]} surveyIds Array of survey IDs + * @param {string[]} groupByColumns Columns to group by + * @param {string[]} groupByQuantitativeMeasurements Quantitative measurements to group by + * @param {string[]} groupByQualitativeMeasurements Qualitative measurements to group by + * @return {Promise} Array of ObservationCountByGroupWithNamedMeasurements + * @memberof AnalyticsService + */ + async getObservationCountByGroup( + surveyIds: number[], + groupByColumns: string[], + groupByQuantitativeMeasurements: string[], + groupByQualitativeMeasurements: string[] + ): Promise { + // Fetch observation counts from repository + const counts = await this.analyticsRepository.getObservationCountByGroup( + surveyIds, + this._filterNonEmptyColumns(groupByColumns), + this._filterNonEmptyColumns(groupByQuantitativeMeasurements), + this._filterNonEmptyColumns(groupByQualitativeMeasurements) + ); + + // Fetch measurement definitions in parallel + const [qualitativeDefinitions, quantitativeDefinitions] = await Promise.all([ + this._fetchQualitativeDefinitions(counts), + this._fetchQuantitativeDefinitions(counts) + ]); + + const transformedCounts = this._transformMeasurementObjectKeysToArrays(counts); + + return this._processCounts(transformedCounts, qualitativeDefinitions, quantitativeDefinitions); + } + + /** + * Filters out empty columns. + * + * @param {string[]} columns + * @return {*} {string[]} + * @memberof AnalyticsService + */ + _filterNonEmptyColumns(columns: string[]): string[] { + return columns.filter((column) => column.trim() !== ''); + } + + /** + * Fetches qualitative measurement definitions for given counts. + * + * @param {ObservationCountByGroupSQLResponse[]} counts + * @return {*} {Promise} + * @memberof AnalyticsService + */ + async _fetchQualitativeDefinitions( + counts: ObservationCountByGroupSQLResponse[] + ): Promise { + const qualTaxonMeasurementIds = this._getQualitativeMeasurementIds(counts); + + if (qualTaxonMeasurementIds.length === 0) { + return []; + } + + return this.critterbaseService.getQualitativeMeasurementTypeDefinition(qualTaxonMeasurementIds); + } + + /** + * Fetches quantitative measurement definitions for given counts. + * + * @param {ObservationCountByGroupSQLResponse[]} counts + * @return {*} {Promise} + * @memberof AnalyticsService + */ + async _fetchQuantitativeDefinitions( + counts: ObservationCountByGroupSQLResponse[] + ): Promise { + const quantTaxonMeasurementIds = this._getQuantitativeMeasurementIds(counts); + + if (quantTaxonMeasurementIds.length === 0) { + return []; + } + + return this.critterbaseService.getQuantitativeMeasurementTypeDefinition(quantTaxonMeasurementIds); + } + + /** + * Returns array of unique qualitative measurement IDs from given counts. + * + * @param {ObservationCountByGroupSQLResponse[]} counts + * @return {*} {string[]} + * @memberof AnalyticsService + */ + _getQualitativeMeasurementIds(counts: ObservationCountByGroupSQLResponse[]): string[] { + return Array.from(new Set(counts.flatMap((count) => Object.keys(count.qual_measurements)))); + } + + /** + * Returns array of unique quantitative measurement IDs from given counts. + * + * @param {ObservationCountByGroupSQLResponse[]} counts + * @return {*} {string[]} + * @memberof AnalyticsService + */ + _getQuantitativeMeasurementIds(counts: ObservationCountByGroupSQLResponse[]): string[] { + return Array.from(new Set(counts.flatMap((count) => Object.keys(count.quant_measurements)))); + } + + /** + * Parses the raw counts object, stripping out extra fields, and maps measurements to their definitions. + * + * @param {((ObservationCountByGroupWithMeasurements & ObservationCountByGroup)[])} counts + * @param {CBQualitativeMeasurementTypeDefinition[]} qualitativeDefinitions + * @param {CBQuantitativeMeasurementTypeDefinition[]} quantitativeDefinitions + * @return {*} {ObservationAnalyticsResponse[]} + * @memberof AnalyticsService + */ + _processCounts( + counts: (ObservationCountByGroupWithMeasurements & ObservationCountByGroup)[], + qualitativeDefinitions: CBQualitativeMeasurementTypeDefinition[], + quantitativeDefinitions: CBQuantitativeMeasurementTypeDefinition[] + ): ObservationAnalyticsResponse[] { + const newCounts: ObservationAnalyticsResponse[] = []; + + for (const count of counts) { + const { row_count, individual_count, individual_percentage, qual_measurements, quant_measurements, ...rest } = + count; + + newCounts.push({ + row_count, + individual_count, + individual_percentage, + ...rest, + qualitative_measurements: this._mapQualitativeMeasurements(qual_measurements, qualitativeDefinitions), + quantitative_measurements: this._mapQuantitativeMeasurements(quant_measurements, quantitativeDefinitions) + }); + } + + return newCounts; + } + + /** + * Maps qualitative measurements to their definitions. + * + * @param {({ option_id: string | null; critterbase_taxon_measurement_id: string }[])} qualMeasurements + * @param {CBQualitativeMeasurementTypeDefinition[]} definitions + * @return {*} {QualitativeMeasurementAnalytics[]} + * @memberof AnalyticsService + */ + _mapQualitativeMeasurements( + qualMeasurements: { option_id: string | null; critterbase_taxon_measurement_id: string }[], + definitions: CBQualitativeMeasurementTypeDefinition[] + ): QualitativeMeasurementAnalytics[] { + return qualMeasurements + .map((measurement) => { + const definition = definitions.find( + (def) => def.taxon_measurement_id === measurement.critterbase_taxon_measurement_id + ); + + if (!definition) { + return null; + } + + return { + taxon_measurement_id: measurement.critterbase_taxon_measurement_id, + measurement_name: definition.measurement_name ?? '', + option: { + option_id: measurement.option_id, + option_label: + definition.options.find((option) => option.qualitative_option_id === measurement.option_id) + ?.option_label ?? '' + } + }; + }) + .filter((item): item is QualitativeMeasurementAnalytics => item !== null); + } + + /** + * Maps quantitative measurements to their definitions. + * + * @param {({ value: number | null; critterbase_taxon_measurement_id: string }[])} quantMeasurements + * @param {CBQuantitativeMeasurementTypeDefinition[]} definitions + * @return {*} {QuantitativeMeasurementAnalytics[]} + * @memberof AnalyticsService + */ + _mapQuantitativeMeasurements( + quantMeasurements: { value: number | null; critterbase_taxon_measurement_id: string }[], + definitions: CBQuantitativeMeasurementTypeDefinition[] + ): QuantitativeMeasurementAnalytics[] { + return quantMeasurements + .map((measurement) => { + const definition = definitions.find( + (def) => def.taxon_measurement_id === measurement.critterbase_taxon_measurement_id + ); + + if (!definition) { + return null; + } + + return { + taxon_measurement_id: measurement.critterbase_taxon_measurement_id, + measurement_name: definition.measurement_name ?? '', + value: measurement.value + }; + }) + .filter((item): item is QuantitativeMeasurementAnalytics => item !== null); + } + + /** + * Transforms the keys of the measurement objects to arrays. + * + * @param {ObservationCountByGroupSQLResponse[]} counts + * @return {*} {((ObservationCountByGroupWithMeasurements & ObservationCountByGroup)[])} + * @memberof AnalyticsService + */ + _transformMeasurementObjectKeysToArrays( + counts: ObservationCountByGroupSQLResponse[] + ): (ObservationCountByGroupWithMeasurements & ObservationCountByGroup)[] { + return counts.map((count) => { + const { row_count, individual_count, individual_percentage, quant_measurements, qual_measurements, ...rest } = + count; + + // Transform quantitative measurements + const quantitative = Object.entries(quant_measurements).map(([measurementId, value]) => ({ + critterbase_taxon_measurement_id: measurementId, + value: value + })); + + // Transform qualitative measurements + const qualitative = Object.entries(qual_measurements).map(([measurementId, optionId]) => ({ + critterbase_taxon_measurement_id: measurementId, + option_id: optionId + })); + + return { + row_count, + individual_count, + individual_percentage, + ...rest, + qual_measurements: qualitative, + quant_measurements: quantitative + }; + }); + } +} diff --git a/api/src/services/attachment-service.test.ts b/api/src/services/attachment-service.test.ts index 7bcc38d4d6..3ad31469c4 100644 --- a/api/src/services/attachment-service.test.ts +++ b/api/src/services/attachment-service.test.ts @@ -221,7 +221,7 @@ describe('AttachmentService', () => { }); }); - it('should insert and return { id: number; revision_count: number; key: string }', async () => { + it('should insert and return { id: number; key: string }', async () => { const dbConnection = getMockDBConnection(); const service = new AttachmentService(dbConnection); @@ -921,7 +921,7 @@ describe('AttachmentService', () => { const service = new AttachmentService(dbConnection); const serviceStub1 = sinon - .stub(AttachmentService.prototype, 'getSurveyReportAttachmentByFileName') + .stub(AttachmentService.prototype, 'getSurveyAttachmentByFileName') .resolves({ rowCount: 1 } as unknown as QueryResult); const serviceStub2 = sinon @@ -949,7 +949,7 @@ describe('AttachmentService', () => { const service = new AttachmentService(dbConnection); const serviceStub1 = sinon - .stub(AttachmentService.prototype, 'getSurveyReportAttachmentByFileName') + .stub(AttachmentService.prototype, 'getSurveyAttachmentByFileName') .resolves({ rowCount: 0 } as unknown as QueryResult); const serviceStub2 = sinon diff --git a/api/src/services/attachment-service.ts b/api/src/services/attachment-service.ts index 7ad4ae160b..017ea1c44e 100644 --- a/api/src/services/attachment-service.ts +++ b/api/src/services/attachment-service.ts @@ -14,7 +14,8 @@ import { IProjectReportAttachmentAuthor, ISurveyAttachment, ISurveyReportAttachment, - ISurveyReportAttachmentAuthor + ISurveyReportAttachmentAuthor, + SurveyTelemetryCredentialAttachment } from '../repositories/attachment-repository'; import { deleteFileFromS3, generateS3FileKey } from '../utils/file-utils'; import { DBService } from './db-service'; @@ -261,6 +262,17 @@ export class AttachmentService extends DBService { return this.attachmentRepository.getSurveyReportAttachmentAuthors(reportAttachmentId); } + /** + * Gets all of the survey telemetry credential attachments for the given survey ID. + * + * @param {number} surveyId the ID of the survey + * @return {Promise} Promise resolving all survey telemetry attachments. + * @memberof AttachmentService + */ + async getSurveyTelemetryCredentialAttachments(surveyId: number): Promise { + return this.attachmentRepository.getSurveyTelemetryCredentialAttachments(surveyId); + } + /** *Insert Project Attachment * @@ -795,7 +807,7 @@ export class AttachmentService extends DBService { fileName: file.originalname }); - const getResponse = await this.getSurveyReportAttachmentByFileName(surveyId, file.originalname); + const getResponse = await this.getSurveyAttachmentByFileName(file.originalname, surveyId); let attachmentResult: { survey_attachment_id: number; revision_count: number }; @@ -892,4 +904,120 @@ export class AttachmentService extends DBService { // Delete the attachment from S3 await deleteFileFromS3(attachment.key); } + + /** + * Update survey telemetry credential attachment record. + * + * @param {number} surveyId + * @param {string} fileName + * @param {string} fileType + * @return {*} {Promise<{ survey_telemetry_credential_attachment_id: number }>} + * @memberof AttachmentService + */ + async updateSurveyTelemetryCredentialAttachment( + surveyId: number, + fileName: string, + fileType: string + ): Promise<{ survey_telemetry_credential_attachment_id: number }> { + return this.attachmentRepository.updateSurveyTelemetryCredentialAttachment(surveyId, fileName, fileType); + } + + /** + * Insert survey telemetry credential attachment record. + * + * @param {string} fileName + * @param {number} fileSize + * @param {string} fileType + * @param {number} surveyId + * @param {string} key + * @return {*} {Promise<{ survey_telemetry_credential_attachment_id: number }>} + * @memberof AttachmentService + */ + async insertSurveyTelemetryCredentialAttachment( + fileName: string, + fileSize: number, + fileType: string, + surveyId: number, + key: string + ): Promise<{ survey_telemetry_credential_attachment_id: number }> { + return this.attachmentRepository.insertSurveyTelemetryCredentialAttachment( + fileName, + fileSize, + fileType, + surveyId, + key + ); + } + + /** + * Get Survey Telemetry Attachment By File Name + * + * @param {string} fileName + * @param {number} surveyId + * @return {*} {Promise} + * @memberof AttachmentService + */ + async getSurveyTelemetryCredentialAttachmentByFileName(fileName: string, surveyId: number): Promise { + return this.attachmentRepository.getSurveyTelemetryCredentialAttachmentByFileName(fileName, surveyId); + } + + /** + * Upsert survey telemetry credential attachment record. + * + * @param {Express.Multer.File} file + * @param {number} projectId + * @param {number} surveyId + * @param {string} attachmentType + * @return {*} {Promise<{ survey_telemetry_credential_attachment_id: number; key: string }>} + * @memberof AttachmentService + */ + async upsertSurveyTelemetryCredentialAttachment( + file: Express.Multer.File, + projectId: number, + surveyId: number, + attachmentType: string + ): Promise<{ survey_telemetry_credential_attachment_id: number; key: string }> { + const key = generateS3FileKey({ + projectId: projectId, + surveyId: surveyId, + fileName: file.originalname, + folder: 'telemetry-credentials' + }); + + const getResponse = await this.getSurveyTelemetryCredentialAttachmentByFileName(file.originalname, surveyId); + + let attachmentResult: { survey_telemetry_credential_attachment_id: number }; + + if (getResponse && getResponse.rowCount) { + // Existing attachment with matching name found, update it + attachmentResult = await this.updateSurveyTelemetryCredentialAttachment( + surveyId, + file.originalname, + attachmentType + ); + } else { + // No matching attachment found, insert new attachment + attachmentResult = await this.insertSurveyTelemetryCredentialAttachment( + file.originalname, + file.size, + attachmentType, + surveyId, + key + ); + } + + return { ...attachmentResult, key }; + } + + /** + * Get Survey telemetry credential attachment S3 Key + * + * @param {number} surveyId + * @param {number} attachmentId + * @return {*} {Promise} + * @memberof AttachmentService + */ + async getSurveyTelemetryCredentialAttachmentS3Key(surveyId: number, attachmentId: number): Promise { + return this.attachmentRepository.getSurveyTelemetryCredentialAttachmentS3Key(surveyId, attachmentId); + } } diff --git a/api/src/services/bcgw-layer-service.test.ts b/api/src/services/bcgw-layer-service.test.ts index 461aa16efe..ea5835acd3 100644 --- a/api/src/services/bcgw-layer-service.test.ts +++ b/api/src/services/bcgw-layer-service.test.ts @@ -685,11 +685,11 @@ describe('BcgwLayerService', () => { const regions = await service.getIntersectingNrmRegionNamesFromFeatures([featureA, featureB], mockDbConnection); - expect(mockGetGeoJsonString.firstCall.calledWithExactly(featureA.geometry, Srid3005)).to.be.true; - expect(mockGetGeoJsonString.secondCall.calledWithExactly(featureB.geometry, Srid3005)).to.be.true; + expect(mockGetGeoJsonString.firstCall).to.have.been.calledWithExactly(featureA.geometry, Srid3005); + expect(mockGetGeoJsonString.secondCall).to.have.been.calledWithExactly(featureB.geometry, Srid3005); - expect(mockGetNrmRegionNames.firstCall.calledWithExactly('A')).to.be.true; - expect(mockGetNrmRegionNames.secondCall.calledWithExactly('B')).to.be.true; + expect(mockGetNrmRegionNames.firstCall).to.have.been.calledWithExactly('A'); + expect(mockGetNrmRegionNames.secondCall).to.have.been.calledWithExactly('B'); expect(regions).to.eqls(['Cariboo', 'South']); }); diff --git a/api/src/services/bctw-service.test.ts b/api/src/services/bctw-service.test.ts deleted file mode 100755 index e6b235c37f..0000000000 --- a/api/src/services/bctw-service.test.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { AxiosResponse } from 'axios'; -import chai, { expect } from 'chai'; -import FormData from 'form-data'; -import { describe } from 'mocha'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { - BctwService, - DELETE_DEPLOYMENT_ENDPOINT, - DEPLOY_DEVICE_ENDPOINT, - GET_CODE_ENDPOINT, - GET_COLLAR_VENDORS_ENDPOINT, - GET_DEPLOYMENTS_BY_CRITTER_ENDPOINT, - GET_DEPLOYMENTS_BY_DEVICE_ENDPOINT, - GET_DEPLOYMENTS_ENDPOINT, - GET_DEVICE_DETAILS, - GET_KEYX_STATUS_ENDPOINT, - GET_TELEMETRY_POINTS_ENDPOINT, - GET_TELEMETRY_TRACKS_ENDPOINT, - HEALTH_ENDPOINT, - IDeployDevice, - IDeploymentUpdate, - MANUAL_AND_VENDOR_TELEMETRY, - MANUAL_TELEMETRY, - UPDATE_DEPLOYMENT_ENDPOINT, - UPSERT_DEVICE_ENDPOINT, - VENDOR_TELEMETRY -} from './bctw-service'; -import { KeycloakService } from './keycloak-service'; - -chai.use(sinonChai); - -describe('BctwService', () => { - afterEach(() => { - sinon.restore(); - }); - - const mockUser = { keycloak_guid: 'abc123', username: 'testuser' }; - - describe('getUserHeader', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should return a JSON string', async () => { - const bctwService = new BctwService(mockUser); - const result = bctwService.getUserHeader(); - expect(result).to.be.a('string'); - expect(JSON.parse(result)).to.deep.equal(mockUser); - }); - }); - - describe('getToken', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should return a string from the keycloak service', async () => { - const mockToken = 'abc123'; - const bctwService = new BctwService(mockUser); - const getKeycloakServiceTokenStub = sinon - .stub(KeycloakService.prototype, 'getKeycloakServiceToken') - .resolves(mockToken); - - const result = await bctwService.getToken(); - expect(result).to.equal(mockToken); - expect(getKeycloakServiceTokenStub).to.have.been.calledOnce; - }); - }); - - describe('_makeGetRequest', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should make an axios get request', async () => { - const bctwService = new BctwService(mockUser); - const endpoint = '/endpoint'; - const mockResponse = { data: 'data' } as AxiosResponse; - - const mockAxios = sinon.stub(bctwService.axiosInstance, 'get').resolves(mockResponse); - - const result = await bctwService._makeGetRequest(endpoint); - - expect(result).to.equal(mockResponse.data); - expect(mockAxios).to.have.been.calledOnceWith(`${endpoint}`); - }); - - it('should make an axios get request with params', async () => { - const bctwService = new BctwService(mockUser); - const endpoint = '/endpoint'; - const queryParams = { param: 'param' }; - const mockResponse = { data: 'data' } as AxiosResponse; - - const mockAxios = sinon.stub(bctwService.axiosInstance, 'get').resolves(mockResponse); - - const result = await bctwService._makeGetRequest(endpoint, queryParams); - - expect(result).to.equal(mockResponse.data); - expect(mockAxios).to.have.been.calledOnceWith(`${endpoint}?param=${queryParams['param']}`); - }); - }); - - describe('BctwService public methods', () => { - afterEach(() => { - sinon.restore(); - }); - - const bctwService = new BctwService(mockUser); - const mockDevice: IDeployDevice = { - device_id: 1, - frequency: 100, - device_make: 'Lotek', - device_model: 'model', - attachment_start: '2020-01-01', - attachment_end: '2020-01-02', - critter_id: 'abc123' - }; - const mockDeployment: IDeploymentUpdate = { - deployment_id: 'adcd', - attachment_start: '2020-01-01', - attachment_end: '2020-01-02' - }; - - describe('deployDevice', () => { - it('should send a post request', async () => { - const mockAxios = sinon.stub(bctwService.axiosInstance, 'post'); - - await bctwService.deployDevice(mockDevice); - - expect(mockAxios).to.have.been.calledOnceWith(DEPLOY_DEVICE_ENDPOINT, mockDevice); - }); - }); - - describe('getDeployments', () => { - it('should send a get request', async () => { - const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); - - await bctwService.getDeployments(); - - expect(mockGetRequest).to.have.been.calledOnceWith(GET_DEPLOYMENTS_ENDPOINT); - }); - }); - - describe('updateDeployment', () => { - it('should send a patch request', async () => { - const mockAxios = sinon.stub(bctwService.axiosInstance, 'patch'); - - await bctwService.updateDeployment(mockDeployment); - - expect(mockAxios).to.have.been.calledOnceWith(UPDATE_DEPLOYMENT_ENDPOINT, mockDeployment); - }); - }); - - describe('getCollarVendors', () => { - it('should send a get request', async () => { - const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); - - await bctwService.getCollarVendors(); - - expect(mockGetRequest).to.have.been.calledOnceWith(GET_COLLAR_VENDORS_ENDPOINT); - }); - }); - - describe('getHealth', () => { - it('should send a get request', async () => { - const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); - - await bctwService.getHealth(); - - expect(mockGetRequest).to.have.been.calledOnceWith(HEALTH_ENDPOINT); - }); - }); - - describe('getCode', () => { - it('should send a get request', async () => { - const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); - - await bctwService.getCode('codeHeader'); - - expect(mockGetRequest).to.have.been.calledOnceWith(GET_CODE_ENDPOINT, { codeHeader: 'codeHeader' }); - }); - }); - - describe('getDeploymentsByCritterId', () => { - it('should send a get request', async () => { - const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); - - await bctwService.getDeploymentsByCritterId(['abc123']); - - expect(mockGetRequest).to.have.been.calledOnceWith(GET_DEPLOYMENTS_BY_CRITTER_ENDPOINT, { - critter_ids: ['abc123'] - }); - }); - }); - - describe('getDeviceDetails', () => { - it('should send a get request', async () => { - const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); - - await bctwService.getDeviceDetails(123, 'Lotek'); - - expect(mockGetRequest).to.have.been.calledOnceWith(`${GET_DEVICE_DETAILS}${123}`); - }); - }); - - describe('getDeviceDeployments', () => { - it('should send a get request', async () => { - const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); - - await bctwService.getDeviceDeployments(123, 'Lotek'); - - expect(mockGetRequest).to.have.been.calledOnceWith(GET_DEPLOYMENTS_BY_DEVICE_ENDPOINT, { - device_id: '123', - make: 'Lotek' - }); - }); - }); - - describe('uploadKeyX', () => { - it('should send a post request', async () => { - const mockAxios = sinon.stub(bctwService.axiosInstance, 'post').resolves({ data: { results: [], errors: [] } }); - const mockMulterFile = { buffer: 'buffer', originalname: 'originalname' } as unknown as Express.Multer.File; - sinon.stub(FormData.prototype, 'append'); - const mockGetFormDataHeaders = sinon - .stub(FormData.prototype, 'getHeaders') - .resolves({ 'content-type': 'multipart/form-data' }); - - const result = await bctwService.uploadKeyX(mockMulterFile); - - expect(mockGetFormDataHeaders).to.have.been.calledOnce; - expect(result).to.eql({ totalKeyxFiles: 0, newRecords: 0, existingRecords: 0 }); - expect(mockAxios).to.have.been.calledOnce; - }); - - it('should throw an error if the response body has errors', async () => { - sinon.stub(bctwService.axiosInstance, 'post').resolves({ data: { results: [], errors: [{ error: 'error' }] } }); - const mockMulterFile = { buffer: 'buffer', originalname: 'originalname' } as unknown as Express.Multer.File; - sinon.stub(FormData.prototype, 'append'); - sinon.stub(FormData.prototype, 'getHeaders').resolves({ 'content-type': 'multipart/form-data' }); - - await bctwService - .uploadKeyX(mockMulterFile) - .catch((e) => expect(e.message).to.equal('API request failed with errors')); - }); - }); - - describe('getKeyXDetails', () => { - it('should send a get request', async () => { - const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); - - await bctwService.getKeyXDetails([123]); - - expect(mockGetRequest).to.have.been.calledOnceWith(GET_KEYX_STATUS_ENDPOINT, { device_ids: ['123'] }); - }); - }); - - describe('updateDevice', () => { - it('should send a post request', async () => { - const mockAxios = sinon.stub(bctwService.axiosInstance, 'post').resolves({ data: { results: [], errors: [] } }); - - const body = { - device_id: 1, - collar_id: '' - }; - await bctwService.updateDevice(body); - - expect(mockAxios).to.have.been.calledOnceWith(UPSERT_DEVICE_ENDPOINT, body); - }); - it('should send a post request and get some errors back', async () => { - sinon - .stub(bctwService.axiosInstance, 'post') - .resolves({ data: { results: [], errors: [{ device_id: 'error' }] } }); - - const body = { - device_id: 1, - collar_id: '' - }; - await bctwService.updateDevice(body).catch((e) => expect(e.message).to.equal('[{"device_id":"error"}]')); - }); - }); - - describe('getCritterTelemetryPoints', () => { - it('should send a get request', async () => { - const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); - - const startDate = new Date(); - const endDate = new Date(); - - await bctwService.getCritterTelemetryPoints('asdf', startDate, endDate); - - expect(mockGetRequest).to.have.been.calledOnceWith(GET_TELEMETRY_POINTS_ENDPOINT, { - critter_id: 'asdf', - start: startDate.toISOString(), - end: endDate.toISOString() - }); - }); - }); - - describe('getCritterTelemetryTracks', () => { - it('should send a get request', async () => { - const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); - - const startDate = new Date(); - const endDate = new Date(); - - await bctwService.getCritterTelemetryTracks('asdf', startDate, endDate); - - expect(mockGetRequest).to.have.been.calledOnceWith(GET_TELEMETRY_TRACKS_ENDPOINT, { - critter_id: 'asdf', - start: startDate.toISOString(), - end: endDate.toISOString() - }); - }); - }); - - describe('deleteDeployment', () => { - it('should sent a delete request', async () => { - const mockAxios = sinon.stub(bctwService.axiosInstance, 'delete').resolves(); - - await bctwService.deleteDeployment('asdf'); - - expect(mockAxios).to.have.been.calledOnceWith(`${DELETE_DEPLOYMENT_ENDPOINT}/asdf`); - }); - }); - - describe('getManualTelemetry', () => { - it('should sent a post request', async () => { - const mockAxios = sinon.stub(bctwService.axiosInstance, 'get').resolves({ data: true }); - - const ret = await bctwService.getManualTelemetry(); - - expect(mockAxios).to.have.been.calledOnceWith(MANUAL_TELEMETRY); - expect(ret).to.be.true; - }); - }); - - describe('deleteManualTelemetry', () => { - it('should sent a post request', async () => { - const mockAxios = sinon.stub(bctwService.axiosInstance, 'post').resolves({ data: true }); - - const ids = ['a', 'b']; - const ret = await bctwService.deleteManualTelemetry(ids); - - expect(mockAxios).to.have.been.calledOnceWith(`${MANUAL_TELEMETRY}/delete`, ids); - expect(ret).to.be.true; - }); - }); - - describe('createManualTelemetry', () => { - it('should sent a post request', async () => { - const mockAxios = sinon.stub(bctwService.axiosInstance, 'post').resolves({ data: true }); - - const payload: any = { key: 'value' }; - const ret = await bctwService.createManualTelemetry(payload); - - expect(mockAxios).to.have.been.calledOnceWith(MANUAL_TELEMETRY, payload); - expect(ret).to.be.true; - }); - }); - - describe('updateManualTelemetry', () => { - it('should sent a patch request', async () => { - const mockAxios = sinon.stub(bctwService.axiosInstance, 'patch').resolves({ data: true }); - - const payload: any = { key: 'value' }; - const ret = await bctwService.updateManualTelemetry(payload); - - expect(mockAxios).to.have.been.calledOnceWith(MANUAL_TELEMETRY, payload); - expect(ret).to.be.true; - }); - }); - - describe('getManualTelemetryByDeploymentIds', () => { - it('should sent a post request', async () => { - const mockAxios = sinon.stub(bctwService.axiosInstance, 'post').resolves({ data: true }); - - const payload: any = { key: 'value' }; - const ret = await bctwService.getManualTelemetryByDeploymentIds(payload); - - expect(mockAxios).to.have.been.calledOnceWith(`${MANUAL_TELEMETRY}/deployments`, payload); - expect(ret).to.be.true; - }); - }); - - describe('getVendorTelemetryByDeploymentIds', () => { - it('should sent a post request', async () => { - const mockAxios = sinon.stub(bctwService.axiosInstance, 'post').resolves({ data: true }); - - const payload: any = { key: 'value' }; - const ret = await bctwService.getVendorTelemetryByDeploymentIds(payload); - - expect(mockAxios).to.have.been.calledOnceWith(`${VENDOR_TELEMETRY}/deployments`, payload); - expect(ret).to.be.true; - }); - }); - - describe('getAllTelemetryByDeploymentIds', () => { - it('should sent a post request', async () => { - const mockAxios = sinon.stub(bctwService.axiosInstance, 'post').resolves({ data: true }); - - const payload: any = { key: 'value' }; - const ret = await bctwService.getAllTelemetryByDeploymentIds(payload); - - expect(mockAxios).to.have.been.calledOnceWith(`${MANUAL_AND_VENDOR_TELEMETRY}/deployments`, payload); - expect(ret).to.be.true; - }); - }); - }); -}); diff --git a/api/src/services/bctw-service.ts b/api/src/services/bctw-service.ts deleted file mode 100644 index 136aee209a..0000000000 --- a/api/src/services/bctw-service.ts +++ /dev/null @@ -1,584 +0,0 @@ -import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; -import { Request } from 'express'; -import FormData from 'form-data'; -import { GeometryCollection } from 'geojson'; -import { URLSearchParams } from 'url'; -import { z } from 'zod'; -import { ApiError, ApiErrorType, ApiGeneralError } from '../errors/api-error'; -import { HTTP500 } from '../errors/http-error'; -import { getSystemUserFromRequest } from '../utils/request'; -import { KeycloakService } from './keycloak-service'; - -export const IDeployDevice = z.object({ - device_id: z.number(), - frequency: z.number().optional(), - frequency_unit: z.string().optional(), - device_make: z.string().optional(), - device_model: z.string().optional(), - attachment_start: z.string(), - attachment_end: z.string().nullable(), - critter_id: z.string() -}); - -export type IDeployDevice = z.infer; - -export type IDevice = Omit & { collar_id: string }; - -export const IDeploymentUpdate = z.object({ - deployment_id: z.string(), - attachment_start: z.string(), - attachment_end: z.string() -}); - -export type IDeploymentUpdate = z.infer; - -export const IDeploymentRecord = z.object({ - assignment_id: z.string(), - collar_id: z.string(), - critter_id: z.string(), - created_at: z.string(), - created_by_user_id: z.string(), - updated_at: z.string(), - updated_by_user_id: z.string(), - valid_from: z.string(), - valid_to: z.string(), - attachment_start: z.string(), - attachment_end: z.string(), - deployment_id: z.string(), - device_id: z.number() -}); - -export type IDeploymentRecord = z.infer; - -export const IUploadKeyxResponse = z.object({ - errors: z.array( - z.object({ - row: z.string(), - error: z.string(), - rownum: z.number() - }) - ), - results: z.array( - z.object({ - idcollar: z.number(), - comtype: z.string(), - idcom: z.string(), - collarkey: z.string(), - collartype: z.number(), - dtlast_fetch: z.string().nullable() - }) - ) -}); - -export type IUploadKeyxResponse = z.infer; - -export const IKeyXDetails = z.object({ - device_id: z.number(), - keyx: z - .object({ - idcom: z.string(), - comtype: z.string(), - idcollar: z.number(), - collarkey: z.string(), - collartype: z.number() - }) - .nullable() -}); - -export type IKeyXDetails = z.infer; - -export const IAllTelemetry = z - .object({ - deployment_id: z.string().uuid(), - latitude: z.number(), - longitude: z.number(), - acquisition_date: z.string(), - telemetry_type: z.string() - }) - .and( - // One of telemetry_id or telemetry_manual_id is expected to be non-null - z.union([ - z.object({ - telemetry_id: z.string().uuid(), - telemetry_manual_id: z.null() - }), - z.object({ - telemetry_id: z.null(), - telemetry_manual_id: z.string().uuid() - }) - ]) - ); - -export type IAllTelemetry = z.infer; - -export const IVendorTelemetry = z.object({ - telemetry_id: z.string(), - deployment_id: z.string().uuid(), - collar_transaction_id: z.string().uuid(), - critter_id: z.string().uuid(), - deviceid: z.number(), - latitude: z.number(), - longitude: z.number(), - elevation: z.number(), - vendor: z.string(), - acquisition_date: z.string() -}); - -export type IVendorTelemetry = z.infer; - -export const IManualTelemetry = z.object({ - telemetry_manual_id: z.string().uuid(), - deployment_id: z.string().uuid(), - latitude: z.number(), - longitude: z.number(), - acquisition_date: z.string() -}); - -export type IManualTelemetry = z.infer; - -export const IBctwUser = z.object({ - keycloak_guid: z.string(), - username: z.string() -}); - -interface ICodeResponse { - code_header_title: string; - code_header_name: string; - id: number; - code: string; - description: string; - long_description: string; -} - -export type IBctwUser = z.infer; - -export interface ICreateManualTelemetry { - deployment_id: string; - latitude: number; - longitude: number; - acquisition_date: string; -} - -export const BCTW_API_HOST = process.env.BCTW_API_HOST || ''; -export const DEPLOY_DEVICE_ENDPOINT = '/deploy-device'; -export const UPSERT_DEVICE_ENDPOINT = '/upsert-collar'; -export const GET_DEPLOYMENTS_ENDPOINT = '/get-deployments'; -export const GET_DEPLOYMENTS_BY_CRITTER_ENDPOINT = '/get-deployments-by-critter-id'; -export const GET_DEPLOYMENTS_BY_DEVICE_ENDPOINT = '/get-deployments-by-device-id'; -export const UPDATE_DEPLOYMENT_ENDPOINT = '/update-deployment'; -export const DELETE_DEPLOYMENT_ENDPOINT = '/delete-deployment'; -export const GET_COLLAR_VENDORS_ENDPOINT = '/get-collar-vendors'; -export const HEALTH_ENDPOINT = '/health'; -export const GET_CODE_ENDPOINT = '/get-code'; -export const GET_DEVICE_DETAILS = '/get-collar-history-by-device/'; -export const UPLOAD_KEYX_ENDPOINT = '/import-xml'; -export const GET_KEYX_STATUS_ENDPOINT = '/get-collars-keyx'; -export const GET_TELEMETRY_POINTS_ENDPOINT = '/get-critters'; -export const GET_TELEMETRY_TRACKS_ENDPOINT = '/get-critter-tracks'; -export const MANUAL_TELEMETRY = '/manual-telemetry'; -export const VENDOR_TELEMETRY = '/vendor-telemetry'; -export const DELETE_MANUAL_TELEMETRY = '/manual-telemetry/delete'; -export const MANUAL_AND_VENDOR_TELEMETRY = '/all-telemetry'; - -/** - * Safely attempt to retrieve system user fields for BCTW user dependency. - * - * @param {Request} req - * @throws {ApiGeneralError} - Missing required fields - * @returns {IBctwUser} - */ -export const getBctwUser = (req: Request): IBctwUser => { - const systemUser = getSystemUserFromRequest(req); - - if (!systemUser.user_guid || !systemUser.user_identifier) { - throw new ApiGeneralError('System user missing required fields', ['bctw-service->getBctwUser']); - } - - return { - keycloak_guid: systemUser.user_guid, - username: systemUser.user_identifier - }; -}; - -export class BctwService { - user: IBctwUser; - keycloak: KeycloakService; - axiosInstance: AxiosInstance; - - constructor(user: IBctwUser) { - this.user = user; - this.keycloak = new KeycloakService(); - this.axiosInstance = axios.create({ - headers: { - user: this.getUserHeader() - }, - baseURL: BCTW_API_HOST, - timeout: 10000 - }); - - this.axiosInstance.interceptors.response.use( - (response: AxiosResponse) => { - return response; - }, - (error: AxiosError) => { - if ( - error?.code === 'ECONNREFUSED' || - error?.code === 'ECONNRESET' || - error?.code === 'ETIMEOUT' || - error?.code === 'ECONNABORTED' - ) { - return Promise.reject( - new HTTP500('Connection to the BCTW API server was refused. Please try again later.', [error?.message]) - ); - } - const data: any = error.response?.data; - const errMsg = data?.error ?? data?.errors ?? data ?? 'Unknown error'; - - return Promise.reject( - new ApiError( - ApiErrorType.UNKNOWN, - `API request failed with status code ${error?.response?.status}, ${errMsg}`, - Array.isArray(errMsg) ? errMsg : [errMsg] - ) - ); - } - ); - - // Async request interceptor - this.axiosInstance.interceptors.request.use( - async (config) => { - const token = await this.getToken(); - config.headers['Authorization'] = `Bearer ${token}`; - - return config; - }, - (error) => { - return Promise.reject(error); - } - ); - } - - /** - * Return user information as a JSON string. - * - * @return {*} {string} - * @memberof BctwService - */ - getUserHeader(): string { - return JSON.stringify(this.user); - } - - /** - * Retrieve an authentication token using Keycloak service. - * - * @return {*} {Promise} - * @memberof BctwService - */ - async getToken(): Promise { - const token = await this.keycloak.getKeycloakServiceToken(); - return token; - } - - /** - * Send an authorized get request to the BCTW API. - * - * @param {string} endpoint - * @param {Record} [queryParams] - An object containing query parameters as key-value pairs - * @return {*} - * @memberof BctwService - */ - async _makeGetRequest(endpoint: string, queryParams?: Record) { - let url = endpoint; - if (queryParams) { - const params = new URLSearchParams(queryParams); - url += `?${params.toString()}`; - } - - const response = await this.axiosInstance.get(url); - return response.data; - } - - /** - * Create a new deployment for a telemetry device on a critter. - * - * @param {IDeployDevice} device - * @return {*} {Promise} - * @memberof BctwService - */ - async deployDevice(device: IDeployDevice): Promise { - return this.axiosInstance.post(DEPLOY_DEVICE_ENDPOINT, device); - } - - /** - * Update device hardware details in BCTW. - * - * @param {IDevice} device - * @returns {*} {IDevice} - * @memberof BctwService - */ - async updateDevice(device: IDevice): Promise { - const { data } = await this.axiosInstance.post(UPSERT_DEVICE_ENDPOINT, device); - if (data.errors.length) { - throw Error(JSON.stringify(data.errors)); - } - return data; - } - - /** - * Get device hardware details by device id and device make. - * - * @param {number} deviceId - * @param {deviceMake} deviceMake - * @returns {*} {Promise} - * @memberof BctwService - */ - async getDeviceDetails(deviceId: number, deviceMake: string): Promise { - return this._makeGetRequest(`${GET_DEVICE_DETAILS}${deviceId}`, { make: deviceMake }); - } - - /** - * Get deployments by device id and device make, may return results for multiple critters. - * - * @param {number} deviceId - * @param {string} deviceMake - * @returns {*} {Promise} - * @memberof BctwService - */ - async getDeviceDeployments(deviceId: number, deviceMake: string): Promise { - return this._makeGetRequest(GET_DEPLOYMENTS_BY_DEVICE_ENDPOINT, { - device_id: String(deviceId), - make: deviceMake - }); - } - - /** - * Get all existing deployments. - * - * @return {*} {Promise} - * @memberof BctwService - */ - async getDeployments(): Promise { - return this._makeGetRequest(GET_DEPLOYMENTS_ENDPOINT); - } - - /** - * Get all existing deployments for a list of critter IDs. - * - * @param {string[]} critter_ids - * @return {*} {Promise} - * @memberof BctwService - */ - async getDeploymentsByCritterId(critter_ids: string[]): Promise { - const query = { critter_ids: critter_ids }; - return this._makeGetRequest(GET_DEPLOYMENTS_BY_CRITTER_ENDPOINT, query); - } - - /** - * Update the start and end dates of an existing deployment. - * - * @param {IDeploymentUpdate} deployment - * @return {*} {Promise} - * @memberof BctwService - */ - async updateDeployment(deployment: IDeploymentUpdate): Promise { - return this.axiosInstance.patch(UPDATE_DEPLOYMENT_ENDPOINT, deployment); - } - - /** - * Soft deletes the deployment in BCTW. - * - * @param {string} deployment_id uuid - * @returns {*} {Promise} - * @memberof BctwService - */ - async deleteDeployment(deployment_id: string): Promise { - return this.axiosInstance.delete(`${DELETE_DEPLOYMENT_ENDPOINT}/${deployment_id}`); - } - - /** - * Get a list of all supported collar vendors. - * - * @return {*} {Promise} - * @memberof BctwService - */ - async getCollarVendors(): Promise { - return this._makeGetRequest(GET_COLLAR_VENDORS_ENDPOINT); - } - - /** - * Get the health of the platform. - * - * @return {*} {Promise} - * @memberof BctwService - */ - async getHealth(): Promise { - return this._makeGetRequest(HEALTH_ENDPOINT); - } - - /** - * Upload a single or multiple zipped keyX files to the BCTW API. - * - * @param {Express.Multer.File} keyX - * @return {*} {Promise} - * @memberof BctwService - */ - async uploadKeyX(keyX: Express.Multer.File) { - const formData = new FormData(); - formData.append('xml', keyX.buffer, keyX.originalname); - const config = { - headers: { - ...formData.getHeaders() - } - }; - const response = await this.axiosInstance.post(UPLOAD_KEYX_ENDPOINT, formData, config); - const data: IUploadKeyxResponse = response.data; - if (data.errors.length) { - const actualErrors: string[] = []; - for (const error of data.errors) { - // Ignore errors that indicate that a keyX already exists - if (!error.error.endsWith('already exists')) { - actualErrors.push(error.error); - } - } - if (actualErrors.length) { - throw new ApiError(ApiErrorType.UNKNOWN, 'API request failed with errors', actualErrors); - } - } - return { - totalKeyxFiles: data.results.length + data.errors.length, - newRecords: data.results.length, - existingRecords: data.errors.length - }; - } - - async getKeyXDetails(deviceIds: number[]): Promise { - return this._makeGetRequest(GET_KEYX_STATUS_ENDPOINT, { device_ids: deviceIds.map((id) => String(id)) }); - } - - /** - * Get a list of all BCTW codes with a given header name. - * - * @param {string} codeHeaderName - * @return {*} {Promise} - * @memberof BctwService - */ - async getCode(codeHeaderName: string): Promise { - return this._makeGetRequest(GET_CODE_ENDPOINT, { codeHeader: codeHeaderName }); - } - - /** - * Get all telemetry points for an animal. - * The geometry will be points, and the properties will include the critter id and deployment id. - * @param critterId uuid - * @param startDate - * @param endDate - * @return {*} {Promise} - * @memberof BctwService - */ - async getCritterTelemetryPoints(critterId: string, startDate: Date, endDate: Date): Promise { - return this._makeGetRequest(GET_TELEMETRY_POINTS_ENDPOINT, { - critter_id: critterId, - start: startDate.toISOString(), - end: endDate.toISOString() - }); - } - - /** - * Get all telemetry tracks for an animal. - * The geometry will be lines, and the properties will include the critter id and deployment id. - * The lines are actually just generated on the fly by the the db using the same points as getCritterTelemetryPoints. - * - * @param critterId uuid - * @param startDate - * @param endDate - * @return {*} {Promise} - * @memberof BctwService - */ - async getCritterTelemetryTracks(critterId: string, startDate: Date, endDate: Date): Promise { - return this._makeGetRequest(GET_TELEMETRY_TRACKS_ENDPOINT, { - critter_id: critterId, - start: startDate.toISOString(), - end: endDate.toISOString() - }); - } - - /** - * Get all manual telemetry records - * This set of telemetry is mostly useful for testing purposes. - * - * @returns {*} IManualTelemetry[] - **/ - async getManualTelemetry(): Promise { - return this._makeGetRequest(MANUAL_TELEMETRY); - } - - /** - * retrieves manual telemetry from list of deployment ids - * - * @async - * @param {string[]} deployment_ids - bctw deployments - * @returns {*} IManualTelemetry[] - */ - async getManualTelemetryByDeploymentIds(deployment_ids: string[]): Promise { - const res = await this.axiosInstance.post(`${MANUAL_TELEMETRY}/deployments`, deployment_ids); - return res.data; - } - - /** - * retrieves manual telemetry from list of deployment ids - * - * @async - * @param {string[]} deployment_ids - bctw deployments - * @returns {*} IManualTelemetry[] - */ - async getVendorTelemetryByDeploymentIds(deployment_ids: string[]): Promise { - const res = await this.axiosInstance.post(`${VENDOR_TELEMETRY}/deployments`, deployment_ids); - return res.data; - } - - /** - * retrieves manual and vendor telemetry from list of deployment ids - * - * @async - * @param {string[]} deployment_ids - bctw deployments - * @returns {*} IManualTelemetry[] - */ - async getAllTelemetryByDeploymentIds(deployment_ids: string[]): Promise { - const res = await this.axiosInstance.post(`${MANUAL_AND_VENDOR_TELEMETRY}/deployments`, deployment_ids); - return res.data; - } - - /** - * Delete manual telemetry records by telemetry_manual_id - * Note: This is a post request that accepts an array of ids - * @param {uuid[]} telemetry_manaual_ids - * - * @returns {*} IManualTelemetry[] - **/ - async deleteManualTelemetry(telemetry_manual_ids: string[]): Promise { - const res = await this.axiosInstance.post(DELETE_MANUAL_TELEMETRY, telemetry_manual_ids); - return res.data; - } - - /** - * Bulk create manual telemetry records - * @param {ICreateManualTelemetry[]} payload - * - * @returns {*} IManualTelemetry[] - **/ - async createManualTelemetry(payload: ICreateManualTelemetry[]): Promise { - const res = await this.axiosInstance.post(MANUAL_TELEMETRY, payload); - return res.data; - } - - /** - * Bulk update manual telemetry records - * @param {IManualTelemetry} payload - * - * @returns {*} IManualTelemetry[] - **/ - async updateManualTelemetry(payload: IManualTelemetry[]): Promise { - const res = await this.axiosInstance.patch(MANUAL_TELEMETRY, payload); - return res.data; - } -} diff --git a/api/src/services/bctw-service/bctw-deployment-service.ts b/api/src/services/bctw-service/bctw-deployment-service.ts new file mode 100644 index 0000000000..1d4a9da582 --- /dev/null +++ b/api/src/services/bctw-service/bctw-deployment-service.ts @@ -0,0 +1,129 @@ +import { z } from 'zod'; +import { BctwService } from './bctw-service'; + +export const BctwDeploymentRecordWithDeviceMeta = z.object({ + assignment_id: z.string().uuid(), + collar_id: z.string().uuid(), + critter_id: z.string().uuid(), + created_at: z.string(), + created_by_user_id: z.string().nullable(), + updated_at: z.string().nullable(), + updated_by_user_id: z.string().nullable(), + valid_from: z.string(), + valid_to: z.string().nullable(), + attachment_start: z.string(), + attachment_end: z.string().nullable(), + deployment_id: z.string(), + device_id: z.number().nullable(), + device_make: z.number().nullable(), + device_model: z.string().nullable(), + frequency: z.number().nullable(), + frequency_unit: z.number().nullable() +}); +export type BctwDeploymentRecordWithDeviceMeta = z.infer; + +export const BctwDeploymentRecord = z.object({ + assignment_id: z.string(), + collar_id: z.string(), + critter_id: z.string(), + created_at: z.string(), + created_by_user_id: z.string(), + updated_at: z.string().nullable(), + updated_by_user_id: z.string().nullable(), + valid_from: z.string(), + valid_to: z.string().nullable(), + attachment_start: z.string(), + attachment_end: z.string().nullable(), + deployment_id: z.string(), + device_id: z.number() +}); +export type BctwDeploymentRecord = z.infer; + +export const BctwDeploymentUpdate = z.object({ + deployment_id: z.string(), + attachment_start: z.string(), + attachment_end: z.string().nullable() +}); +export type BctwDeploymentUpdate = z.infer; + +export const BctwDeployDevice = z.object({ + deployment_id: z.string().uuid(), + device_id: z.number(), + frequency: z.number().optional(), + frequency_unit: z.string().optional(), + device_make: z.string().optional(), + device_model: z.string().optional(), + attachment_start: z.string(), + attachment_end: z.string().nullable(), + critter_id: z.string() +}); +export type BctwDeployDevice = z.infer; + +export class BctwDeploymentService extends BctwService { + /** + * Create a new deployment for a telemetry device on a critter. + * + * @param {BctwDeployDevice} device + * @return {*} {Promise} + * @memberof BctwDeploymentService + */ + async createDeployment(device: BctwDeployDevice): Promise { + const { data } = await this.axiosInstance.post('/deploy-device', device); + + return data; + } + + /** + * Get deployment records for a list of deployment IDs. + * + * @param {string[]} deploymentIds + * @return {*} {Promise} + * @memberof BctwDeploymentService + */ + async getDeploymentsByIds(deploymentIds: string[]): Promise { + const { data } = await this.axiosInstance.post('/get-deployments', deploymentIds); + + return data; + } + + /** + * Get all existing deployments for a list of critter IDs. + * + * @param {string[]} critter_ids + * @return {*} {Promise} + * @memberof BctwDeploymentService + */ + async getDeploymentsByCritterId(critter_ids: string[]): Promise { + const { data } = await this.axiosInstance.get('/get-deployments-by-critter-id', { + params: { critter_ids: critter_ids } + }); + + return data; + } + + /** + * Update the start and end dates of an existing deployment. + * + * @param {BctwDeploymentUpdate} deployment + * @return {*} {Promise} + * @memberof BctwDeploymentService + */ + async updateDeployment(deployment: BctwDeploymentUpdate): Promise[]> { + const { data } = await this.axiosInstance.patch('/update-deployment', deployment); + + return data; + } + + /** + * Soft deletes the deployment in BCTW. + * + * @param {string} deployment_id uuid + * @returns {*} {Promise} + * @memberof BctwDeploymentService + */ + async deleteDeployment(deployment_id: string): Promise { + const { data } = await this.axiosInstance.delete(`/delete-deployment/${deployment_id}`); + + return data; + } +} diff --git a/api/src/services/bctw-service/bctw-device-service.ts b/api/src/services/bctw-service/bctw-device-service.ts new file mode 100644 index 0000000000..e53e38088a --- /dev/null +++ b/api/src/services/bctw-service/bctw-device-service.ts @@ -0,0 +1,85 @@ +import { BctwDeployDevice } from './bctw-deployment-service'; +import { BctwService } from './bctw-service'; + +export type BctwDevice = Omit & { + collar_id: string; +}; + +export type BctwUpdateCollarRequest = { + /** + * The primary ID (uuid) of the collar record to update. + */ + collar_id: string; + device_make?: number | null; + device_model?: string | null; + frequency?: number | null; + frequency_unit?: number | null; +}; + +export class BctwDeviceService extends BctwService { + /** + * Get a list of all supported collar vendors. + * + * TODO: unused? + * + * @return {*} {Promise} + * @memberof BctwDeviceService + */ + async getCollarVendors(): Promise { + const { data } = await this.axiosInstance.get('/get-collar-vendors'); + + return data; + } + + /** + * Get device hardware details by device id and device make. + * + * TODO: unused? + * + * @param {number} deviceId + * @param {deviceMake} deviceMake + * @returns {*} {Promise} + * @memberof BctwService + */ + async getDeviceDetails(deviceId: number, deviceMake: string): Promise { + const { data } = await this.axiosInstance.get(`/get-collar-history-by-device/${deviceId}`, { + params: { make: deviceMake } + }); + + return data; + } + + /** + * Update device hardware details in BCTW. + * + * @param {BctwDevice} device + * @returns {*} {BctwDevice} + * @memberof BctwService + */ + async updateDevice(device: BctwDevice): Promise { + const { data } = await this.axiosInstance.post('/upsert-collar', device); + + if (data?.errors?.length) { + throw Error(JSON.stringify(data.errors)); + } + + return data; + } + + /** + * Update collar details in BCTW. + * + * @param {BctwUpdateCollarRequest} collar - The collar details to update. + * @return {*} {Promise} + * @memberof BctwDeviceService + */ + async updateCollar(collar: BctwUpdateCollarRequest): Promise { + const { data } = await this.axiosInstance.patch('/update-collar', collar); + + if (data?.errors?.length) { + throw Error(JSON.stringify(data.errors)); + } + + return data; + } +} diff --git a/api/src/services/bctw-service/bctw-keyx-service.test.ts b/api/src/services/bctw-service/bctw-keyx-service.test.ts new file mode 100644 index 0000000000..52ad7a64e1 --- /dev/null +++ b/api/src/services/bctw-service/bctw-keyx-service.test.ts @@ -0,0 +1,83 @@ +import chai, { expect } from 'chai'; +import FormData from 'form-data'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { BctwKeyxService } from '../bctw-service/bctw-keyx-service'; + +chai.use(sinonChai); + +describe('BctwKeyxService', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('uploadKeyX', () => { + it('should send a post request', async () => { + const mockUser = { keycloak_guid: 'abc123', username: 'testuser' }; + + const bctwKeyxService = new BctwKeyxService(mockUser); + + const mockAxios = sinon + .stub(bctwKeyxService.axiosInstance, 'post') + .resolves({ data: { results: [], errors: [] } }); + + const mockMulterFile = { buffer: 'buffer', originalname: 'originalname.keyx' } as unknown as Express.Multer.File; + + sinon.stub(FormData.prototype, 'append'); + + const mockGetFormDataHeaders = sinon + .stub(FormData.prototype, 'getHeaders') + .resolves({ 'content-type': 'multipart/form-data' }); + + const result = await bctwKeyxService.uploadKeyX(mockMulterFile); + + expect(mockGetFormDataHeaders).to.have.been.calledOnce; + expect(result).to.eql({ totalKeyxFiles: 0, newRecords: 0, existingRecords: 0 }); + expect(mockAxios).to.have.been.calledOnce; + }); + + it('should throw an error if the file is not a valid keyx file', async () => { + const mockUser = { keycloak_guid: 'abc123', username: 'testuser' }; + + const bctwKeyxService = new BctwKeyxService(mockUser); + + sinon.stub(bctwKeyxService.axiosInstance, 'post').rejects(); + + const mockMulterFile = { + buffer: 'buffer', + originalname: 'originalname.notValid' // invalid file extension + } as unknown as Express.Multer.File; + + sinon.stub(FormData.prototype, 'append'); + + sinon.stub(FormData.prototype, 'getHeaders').resolves({ 'content-type': 'multipart/form-data' }); + + await bctwKeyxService + .uploadKeyX(mockMulterFile) + .catch((e) => + expect(e.message).to.equal('File is neither a .keyx file, nor an archive containing only .keyx files') + ); + }); + + it('should throw an error if the response body has errors', async () => { + const mockUser = { keycloak_guid: 'abc123', username: 'testuser' }; + + const bctwKeyxService = new BctwKeyxService(mockUser); + + sinon + .stub(bctwKeyxService.axiosInstance, 'post') + .resolves({ data: { results: [], errors: [{ error: 'error' }] } }); + + const mockMulterFile = { buffer: 'buffer', originalname: 'originalname.keyx' } as unknown as Express.Multer.File; + + sinon.stub(FormData.prototype, 'append'); + + sinon.stub(FormData.prototype, 'getHeaders').resolves({ 'content-type': 'multipart/form-data' }); + + await bctwKeyxService + .uploadKeyX(mockMulterFile) + .catch((e) => expect(e.message).to.equal('API request failed with errors')); + }); + }); +}); diff --git a/api/src/services/bctw-service/bctw-keyx-service.ts b/api/src/services/bctw-service/bctw-keyx-service.ts new file mode 100644 index 0000000000..5ced5e4c17 --- /dev/null +++ b/api/src/services/bctw-service/bctw-keyx-service.ts @@ -0,0 +1,102 @@ +import FormData from 'form-data'; +import { z } from 'zod'; +import { ApiError, ApiErrorType } from '../../errors/api-error'; +import { checkFileForKeyx } from '../../utils/media/media-utils'; +import { BctwService } from './bctw-service'; + +export const BctwUploadKeyxResponse = z.object({ + errors: z.array( + z.object({ + row: z.string(), + error: z.string(), + rownum: z.number() + }) + ), + results: z.array( + z.object({ + idcollar: z.number(), + comtype: z.string(), + idcom: z.string(), + collarkey: z.string(), + collartype: z.number(), + dtlast_fetch: z.string().nullable() + }) + ) +}); +export type BctwUploadKeyxResponse = z.infer; + +export const BctwKeyXDetails = z.object({ + device_id: z.number(), + keyx: z + .object({ + idcom: z.string(), + comtype: z.string(), + idcollar: z.number(), + collarkey: z.string(), + collartype: z.number() + }) + .nullable() +}); +export type BctwKeyXDetails = z.infer; + +export class BctwKeyxService extends BctwService { + /** + * Upload a single or multiple zipped keyX files to the BCTW API. + * + * @param {Express.Multer.File} keyX + * @return {*} {Promise} + * @memberof BctwKeyxService + */ + async uploadKeyX(keyX: Express.Multer.File) { + const isValidKeyX = checkFileForKeyx(keyX); + + if (isValidKeyX.error) { + throw new ApiError(ApiErrorType.GENERAL, isValidKeyX.error); + } + + const formData = new FormData(); + + formData.append('xml', keyX.buffer, keyX.originalname); + + const config = { + headers: { + ...formData.getHeaders() + } + }; + + const response = await this.axiosInstance.post('/import-xml', formData, config); + + const data: BctwUploadKeyxResponse = response.data; + + if (data.errors.length) { + const actualErrors: string[] = []; + + for (const error of data.errors) { + // Ignore errors that indicate that a keyX already exists + if (!error.error.endsWith('already exists')) { + actualErrors.push(error.error); + } + } + + if (actualErrors.length) { + throw new ApiError(ApiErrorType.UNKNOWN, 'API request failed with errors', actualErrors); + } + } + + return { + totalKeyxFiles: data.results.length + data.errors.length, + newRecords: data.results.length, + existingRecords: data.errors.length + }; + } + + async getKeyXDetails(deviceIds: number[]): Promise { + const { data } = await this.axiosInstance.get('/get-collars-keyx', { + params: { + device_ids: deviceIds.map((id) => String(id)) + } + }); + + return data; + } +} diff --git a/api/src/services/bctw-service/bctw-service.ts b/api/src/services/bctw-service/bctw-service.ts new file mode 100644 index 0000000000..eb28484b91 --- /dev/null +++ b/api/src/services/bctw-service/bctw-service.ts @@ -0,0 +1,124 @@ +import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; +import { Request } from 'express'; +import { z } from 'zod'; +import { ApiError, ApiErrorType } from '../../errors/api-error'; +import { HTTP500 } from '../../errors/http-error'; +import { ICodeResponse } from '../../models/bctw'; +import { KeycloakService } from '../keycloak-service'; + +export const BctwUser = z.object({ + keycloak_guid: z.string(), + username: z.string() +}); +export type BctwUser = z.infer; + +export const getBctwUser = (req: Request): BctwUser => ({ + keycloak_guid: req.system_user?.user_guid ?? '', + username: req.system_user?.user_identifier ?? '' +}); + +export class BctwService { + user: BctwUser; + keycloak: KeycloakService; + axiosInstance: AxiosInstance; + + constructor(user: BctwUser) { + this.user = user; + this.keycloak = new KeycloakService(); + this.axiosInstance = axios.create({ + headers: { + user: this.getUserHeader() + }, + baseURL: process.env.BCTW_API_HOST || '', + timeout: 10000 + }); + + this.axiosInstance.interceptors.response.use( + (response: AxiosResponse) => { + return response; + }, + (error: AxiosError) => { + if ( + error?.code === 'ECONNREFUSED' || + error?.code === 'ECONNRESET' || + error?.code === 'ETIMEOUT' || + error?.code === 'ECONNABORTED' + ) { + return Promise.reject( + new HTTP500('Connection to the BCTW API server was refused. Please try again later.', [error?.message]) + ); + } + const data: any = error.response?.data; + const errMsg = data?.error ?? data?.errors ?? data ?? 'Unknown error'; + const issues = data?.issues ?? []; + + return Promise.reject( + new ApiError( + ApiErrorType.UNKNOWN, + `API request failed with status code ${error?.response?.status}: ${errMsg}`, + [].concat(errMsg).concat(issues) + ) + ); + } + ); + + // Async request interceptor + this.axiosInstance.interceptors.request.use( + async (config) => { + const token = await this.getToken(); + config.headers['Authorization'] = `Bearer ${token}`; + + return config; + }, + (error) => { + return Promise.reject(error); + } + ); + } + + /** + * Return user information as a JSON string. + * + * @return {*} {string} + * @memberof BctwService + */ + getUserHeader(): string { + return JSON.stringify(this.user); + } + + /** + * Retrieve an authentication token using Keycloak service. + * + * @return {*} {Promise} + * @memberof BctwService + */ + async getToken(): Promise { + const token = await this.keycloak.getKeycloakServiceToken(); + return token; + } + + /** + * Get the health of the platform. + * + * @return {*} {Promise} + * @memberof BctwService + */ + async getHealth(): Promise { + const { data } = await this.axiosInstance.get('/health'); + + return data; + } + + /** + * Get a list of all BCTW codes with a given header name. + * + * @param {string} codeHeaderName + * @return {*} {Promise} + * @memberof BctwService + */ + async getCode(codeHeaderName: string): Promise { + const { data } = await this.axiosInstance.get('/get-code', { params: { codeHeader: codeHeaderName } }); + + return data; + } +} diff --git a/api/src/services/bctw-service/bctw-telemetry-service.ts b/api/src/services/bctw-service/bctw-telemetry-service.ts new file mode 100644 index 0000000000..c8d1d2deba --- /dev/null +++ b/api/src/services/bctw-service/bctw-telemetry-service.ts @@ -0,0 +1,139 @@ +import { z } from 'zod'; +import { BctwService } from './bctw-service'; + +export const IAllTelemetry = z + .object({ + id: z.string().uuid(), + deployment_id: z.string().uuid(), + latitude: z.number(), + longitude: z.number(), + acquisition_date: z.string(), + telemetry_type: z.string() + }) + .and( + // One of telemetry_id or telemetry_manual_id is expected to be non-null + z.union([ + z.object({ + telemetry_id: z.string().uuid(), + telemetry_manual_id: z.null() + }), + z.object({ + telemetry_id: z.null(), + telemetry_manual_id: z.string().uuid() + }) + ]) + ); +export type IAllTelemetry = z.infer; + +export const IVendorTelemetry = z.object({ + telemetry_id: z.string(), + deployment_id: z.string().uuid(), + collar_transaction_id: z.string().uuid(), + critter_id: z.string().uuid(), + deviceid: z.number(), + latitude: z.number(), + longitude: z.number(), + elevation: z.number(), + vendor: z.string(), + acquisition_date: z.string() +}); +export type IVendorTelemetry = z.infer; + +export const IManualTelemetry = z.object({ + telemetry_manual_id: z.string().uuid(), + deployment_id: z.string().uuid(), + latitude: z.number(), + longitude: z.number(), + acquisition_date: z.string() +}); +export type IManualTelemetry = z.infer; + +export interface ICreateManualTelemetry { + deployment_id: string; + latitude: number; + longitude: number; + acquisition_date: string; +} + +export class BctwTelemetryService extends BctwService { + /** + * Get all manual telemetry records + * This set of telemetry is mostly useful for testing purposes. + * + * @returns {*} IManualTelemetry[] + **/ + async getManualTelemetry(): Promise { + const res = await this.axiosInstance.get('/manual-telemetry'); + return res.data; + } + + /** + * retrieves manual telemetry from list of deployment ids + * + * @async + * @param {string[]} deployment_ids - bctw deployments + * @returns {*} IManualTelemetry[] + */ + async getManualTelemetryByDeploymentIds(deployment_ids: string[]): Promise { + const res = await this.axiosInstance.post('/manual-telemetry/deployments', deployment_ids); + return res.data; + } + + /** + * retrieves manual telemetry from list of deployment ids + * + * @async + * @param {string[]} deployment_ids - bctw deployments + * @returns {*} IVendorTelemetry[] + */ + async getVendorTelemetryByDeploymentIds(deployment_ids: string[]): Promise { + const res = await this.axiosInstance.post('/vendor-telemetry/deployments', deployment_ids); + return res.data; + } + + /** + * retrieves manual and vendor telemetry from list of deployment ids + * + * @async + * @param {string[]} deploymentIds - bctw deployments + * @returns {*} IAllTelemetry[] + */ + async getAllTelemetryByDeploymentIds(deploymentIds: string[]): Promise { + const res = await this.axiosInstance.post('/all-telemetry/deployments', deploymentIds); + return res.data; + } + + /** + * Delete manual telemetry records by telemetry_manual_id + * Note: This is a post request that accepts an array of ids + * @param {string[]} telemetry_manual_ids + * + * @returns {*} IManualTelemetry[] + **/ + async deleteManualTelemetry(telemetry_manual_ids: string[]): Promise { + const res = await this.axiosInstance.post('/manual-telemetry/delete', telemetry_manual_ids); + return res.data; + } + + /** + * Bulk create manual telemetry records + * @param {ICreateManualTelemetry[]} payload + * + * @returns {*} IManualTelemetry[] + **/ + async createManualTelemetry(payload: ICreateManualTelemetry[]): Promise { + const res = await this.axiosInstance.post('/manual-telemetry', payload); + return res.data; + } + + /** + * Bulk update manual telemetry records + * @param {IManualTelemetry} payload + * + * @returns {*} IManualTelemetry[] + **/ + async updateManualTelemetry(payload: IManualTelemetry[]): Promise { + const res = await this.axiosInstance.patch('/manual-telemetry', payload); + return res.data; + } +} diff --git a/api/src/services/code-service.test.ts b/api/src/services/code-service.test.ts index 85a2e7a494..c65a6960cf 100644 --- a/api/src/services/code-service.test.ts +++ b/api/src/services/code-service.test.ts @@ -40,7 +40,6 @@ describe('CodeService', () => { 'project_roles', 'administrative_activity_status_type', 'intended_outcomes', - 'vantage_codes', 'site_selection_strategies', 'survey_jobs', 'sample_methods', diff --git a/api/src/services/code-service.ts b/api/src/services/code-service.ts index 2121227753..20de968419 100644 --- a/api/src/services/code-service.ts +++ b/api/src/services/code-service.ts @@ -38,7 +38,6 @@ export class CodeService extends DBService { project_roles, administrative_activity_status_type, intended_outcomes, - vantage_codes, survey_jobs, site_selection_strategies, sample_methods, @@ -59,7 +58,6 @@ export class CodeService extends DBService { await this.codeRepository.getProjectRoles(), await this.codeRepository.getAdministrativeActivityStatusType(), await this.codeRepository.getIntendedOutcomes(), - await this.codeRepository.getVantageCodes(), await this.codeRepository.getSurveyJobs(), await this.codeRepository.getSiteSelectionStrategies(), await this.codeRepository.getSampleMethods(), @@ -82,7 +80,6 @@ export class CodeService extends DBService { project_roles, administrative_activity_status_type, intended_outcomes, - vantage_codes, survey_jobs, site_selection_strategies, sample_methods, diff --git a/api/src/services/critterbase-service.test.ts b/api/src/services/critterbase-service.test.ts index e2ab4bc68b..6409fe9e46 100644 --- a/api/src/services/critterbase-service.test.ts +++ b/api/src/services/critterbase-service.test.ts @@ -1,9 +1,7 @@ -import { AxiosResponse } from 'axios'; import chai, { expect } from 'chai'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { CritterbaseService, ICreateCritter } from './critterbase-service'; -import { KeycloakService } from './keycloak-service'; chai.use(sinonChai); @@ -14,63 +12,6 @@ describe('CritterbaseService', () => { const mockUser = { keycloak_guid: 'abc123', username: 'testuser' }; - describe('getUserHeader', () => { - const cb = new CritterbaseService(mockUser); - const result = cb.getUserHeader(); - expect(result).to.be.a('string'); - }); - - describe('getToken', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should return a string from the keycloak service', async () => { - const mockToken = 'abc123'; - const cb = new CritterbaseService(mockUser); - const getKeycloakServiceTokenStub = sinon - .stub(KeycloakService.prototype, 'getKeycloakServiceToken') - .resolves(mockToken); - - const result = await cb.getToken(); - expect(result).to.equal(mockToken); - expect(getKeycloakServiceTokenStub).to.have.been.calledOnce; - }); - }); - - describe('makeGetRequest', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should make an axios get request', async () => { - const cb = new CritterbaseService(mockUser); - const endpoint = '/endpoint'; - const mockResponse = { data: 'data' } as AxiosResponse; - - const mockAxios = sinon.stub(cb.axiosInstance, 'get').resolves(mockResponse); - - const result = await cb._makeGetRequest(endpoint, []); - - expect(result).to.equal(mockResponse.data); - expect(mockAxios).to.have.been.calledOnceWith(`${endpoint}?`); - }); - - it('should make an axios get request with params', async () => { - const cb = new CritterbaseService(mockUser); - const endpoint = '/endpoint'; - const queryParams = [{ key: 'param', value: 'param' }]; - const mockResponse = { data: 'data' } as AxiosResponse; - - const mockAxios = sinon.stub(cb.axiosInstance, 'get').resolves(mockResponse); - - const result = await cb._makeGetRequest(endpoint, queryParams); - - expect(result).to.equal(mockResponse.data); - expect(mockAxios).to.have.been.calledOnceWith(`${endpoint}?param=param`); - }); - }); - describe('Critterbase service public methods', () => { afterEach(() => { sinon.restore(); @@ -78,68 +19,58 @@ describe('CritterbaseService', () => { const cb = new CritterbaseService(mockUser); - describe('getLookupValues', () => { - it('should retrieve matching lookup values', async () => { - const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); - const mockParams = [{ key: 'format', value: 'asSelect ' }]; - await cb.getLookupValues('colours', mockParams); - expect(mockGetRequest).to.have.been.calledOnceWith('/lookups/colours', mockParams); - }); - }); - describe('getTaxonMeasurements', () => { it('should retrieve taxon measurements', async () => { - const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); + const axiosStub = sinon.stub(cb.axiosInstance, 'get').resolves({ data: [] }); await cb.getTaxonMeasurements('123456'); - expect(mockGetRequest).to.have.been.calledOnceWith('/xref/taxon-measurements', [ - { key: 'tsn', value: '123456' } - ]); + expect(axiosStub).to.have.been.calledOnceWith('/xref/taxon-measurements', { params: { tsn: '123456' } }); }); }); describe('getTaxonBodyLocations', () => { it('should retrieve taxon body locations', async () => { - const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); + const axiosStub = sinon.stub(cb.axiosInstance, 'get').resolves({ data: [] }); await cb.getTaxonBodyLocations('asdf'); - expect(mockGetRequest).to.have.been.calledOnceWith('/xref/taxon-marking-body-locations', [ - { key: 'tsn', value: 'asdf' }, - { key: 'format', value: 'asSelect' } - ]); + expect(axiosStub).to.have.been.calledOnceWith('/xref/taxon-marking-body-locations', { + params: { + tsn: 'asdf', + format: 'asSelect' + } + }); }); }); describe('getQualitativeOptions', () => { it('should retrieve qualitative options', async () => { - const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); + const axiosStub = sinon.stub(cb.axiosInstance, 'get').resolves({ data: [] }); await cb.getQualitativeOptions('asdf'); - expect(mockGetRequest).to.have.been.calledOnceWith('/xref/taxon-qualitative-measurement-options', [ - { key: 'taxon_measurement_id', value: 'asdf' }, - { key: 'format', value: 'asSelect' } - ]); + expect(axiosStub).to.have.been.calledOnceWith('/xref/taxon-qualitative-measurement-options', { + params: { taxon_measurement_id: 'asdf', format: 'asSelect' } + }); }); }); describe('getFamilies', () => { it('should retrieve families', async () => { - const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); + const axiosStub = sinon.stub(cb.axiosInstance, 'get').resolves({ data: [] }); await cb.getFamilies(); - expect(mockGetRequest).to.have.been.calledOnceWith('/family', []); + expect(axiosStub).to.have.been.calledOnceWith('/family'); }); }); describe('getFamilyById', () => { it('should retrieve a family', async () => { - const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); + const axiosStub = sinon.stub(cb.axiosInstance, 'get').resolves({ data: [] }); await cb.getFamilyById('asdf'); - expect(mockGetRequest).to.have.been.calledOnceWith('/family/' + 'asdf', []); + expect(axiosStub).to.have.been.calledOnceWith('/family/' + 'asdf'); }); }); describe('getCritter', () => { it('should fetch a critter', async () => { - const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); + const axiosStub = sinon.stub(cb.axiosInstance, 'get').resolves({ data: [] }); await cb.getCritter('asdf'); - expect(mockGetRequest).to.have.been.calledOnceWith('/critters/' + 'asdf', [{ key: 'format', value: 'detail' }]); + expect(axiosStub).to.have.been.calledOnceWith('/critters/' + 'asdf', { params: { format: 'detailed' } }); }); }); diff --git a/api/src/services/critterbase-service.ts b/api/src/services/critterbase-service.ts index 4f736139f0..9ca6e1274b 100644 --- a/api/src/services/critterbase-service.ts +++ b/api/src/services/critterbase-service.ts @@ -1,15 +1,23 @@ import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; -import { URLSearchParams } from 'url'; +import { Request } from 'express'; +import qs from 'qs'; import { z } from 'zod'; import { ApiError, ApiErrorType } from '../errors/api-error'; import { getLogger } from '../utils/logger'; import { KeycloakService } from './keycloak-service'; +// TODO: TechDebt: Audit the existing types / return types in this file. + export interface ICritterbaseUser { username: string; keycloak_guid: string; } +export const getCritterbaseUser = (req: Request): ICritterbaseUser => ({ + keycloak_guid: req.system_user?.user_guid ?? '', + username: req.system_user?.user_identifier ?? '' +}); + export interface QueryParam { key: string; value: string; @@ -25,6 +33,11 @@ export interface ICritter { critter_comment: string | null; } +export interface ICritterDetailed extends ICritter { + captures: ICaptureDetailed[]; + mortality: IMortality; +} + export interface ICreateCritter { critter_id?: string; wlh_id?: string | null; @@ -39,13 +52,51 @@ export interface ICapture { critter_id: string; capture_method_id?: string | null; capture_location_id: string; - release_location_id: string; + release_location_id?: string | null; + capture_date: string; + capture_time?: string | null; + release_date?: string | null; + release_time?: string | null; + capture_comment?: string | null; + release_comment?: string | null; +} + +export interface ICaptureDetailed { + capture_id: string; + critter_id: string; + capture_method_id?: string | null; + capture_location_id?: string | null; + release_location_id?: string | null; + capture_date: string; + capture_time?: string | null; + release_date?: string | null; + release_time?: string | null; + capture_comment?: string | null; + release_comment?: string | null; + markings: IMarking[]; + quantitative_measurements: IQualMeasurement[]; + qualitative_measurements: IQuantMeasurement[]; + capture_location: { + latitude: number; + longitude: number; + }; + release_location: { + latitude: number; + longitude: number; + }; +} + +export interface ICreateCapture { + critter_id: string; + capture_method_id?: string; + capture_location: ILocation; + release_location?: ILocation; capture_date: string; capture_time?: string | null; release_date?: string | null; release_time?: string | null; - capture_comment: string; - release_comment: string; + capture_comment?: string | null; + release_comment?: string | null; } export interface IMortality { @@ -60,6 +111,10 @@ export interface IMortality { ultimate_cause_of_death_confidence: string; ultimate_predated_by_itis_tsn: string; mortality_comment: string; + mortality_location: { + latitude: number; + longitude: number; + }; } export interface ILocation { @@ -88,6 +143,34 @@ export interface IMarking { removed_timestamp: string; } +/** + * This is the more flexible interface for bulk importing Markings. + * + * Note: Critterbase bulk-create endpoint will attempt to patch + * english values to UUID's. + * ie: primary_colour: "red" -> primary_colour_id: + * + */ +export interface IBulkCreateMarking { + marking_id?: string; + critter_id: string; + capture_id?: string | null; + mortality_id?: string | null; + body_location: string; // Critterbase will patch to UUID + marking_type?: string | null; // Critterbase will patch to UUID + marking_material_id?: string | null; + primary_colour?: string | null; // Critterbase will patch to UUID + secondary_colour?: string | null; // Critterbase will patch to UUID + text_colour_id?: string | null; + identifier?: string | null; + frequency?: number | null; + frequency_unit?: string | null; + order?: number | null; + comment?: string | null; + attached_timestamp?: string | null; + removed_timestamp?: string | null; +} + export interface IQualMeasurement { measurement_qualitative_id?: string; critter_id: string; @@ -95,8 +178,8 @@ export interface IQualMeasurement { capture_id?: string; mortality_id?: string; qualitative_option_id: string; - measurement_comment: string; - measured_timestamp: string; + measurement_comment?: string; + measured_timestamp?: string; } export interface IQuantMeasurement { @@ -127,7 +210,7 @@ export interface IBulkCreate { collections?: ICollection[]; mortalities?: IMortality[]; locations?: ILocation[]; - markings?: IMarking[]; + markings?: IMarking[] | IBulkCreateMarking[]; quantitative_measurements?: IQuantMeasurement[]; qualitative_measurements?: IQualMeasurement[]; families?: IFamilyPayload[]; @@ -166,6 +249,21 @@ export interface ICollectionCategory { itis_tsn: number; } +/** + * Prefixed with critterbase_* to match SIMS database field names + */ +export interface IPostCollectionUnit { + critterbase_collection_unit_id: string; + critterbase_collection_category_id: string; +} + +// Lookup value `asSelect` format +export interface IAsSelectLookup { + id: string; + key: string; + value: string; +} + /** * A Critterbase quantitative measurement. */ @@ -258,56 +356,36 @@ export const CBMeasurementType = z.union([ export type CBMeasurementType = z.infer; -const lookups = '/lookups'; -const xref = '/xref'; -const lookupsEnum = lookups + '/enum'; -const lookupsTaxons = lookups + '/taxons'; -export const CbRoutes = { - // lookups - ['region-envs']: `${lookups}/region-envs`, - ['region_nrs']: `${lookups}/region-nrs`, - wmus: `${lookups}/wmus`, - cods: `${lookups}/cods`, - ['marking-materials']: `${lookups}/marking-materials`, - ['marking-types']: `${lookups}/marking-types`, - ['collection-categories']: `${lookups}/collection-unit-categories`, - taxons: lookupsTaxons, - species: `${lookupsTaxons}/species`, - colours: `${lookups}/colours`, - - // lookups/enum - sex: `${lookupsEnum}/sex`, - ['critter-status']: `${lookupsEnum}/critter-status`, - ['cause-of-death-confidence']: `${lookupsEnum}/cod-confidence`, - ['coordinate-uncertainty-unit']: `${lookupsEnum}/coordinate-uncertainty-unit`, - ['frequency-units']: `${lookupsEnum}/frequency-units`, - ['measurement-units']: `${lookupsEnum}/measurement-units`, - - // xref - ['collection-units']: `${xref}/collection-units`, - - // taxon xrefs - ['taxon-measurements']: `${xref}/taxon-measurements`, - ['taxon_qualitative_measurements']: `${xref}/taxon-qualitative-measurements`, - ['taxon-qualitative-measurement-options']: `${xref}/taxon-qualitative-measurement-options`, - ['taxon-quantitative-measurements']: `${xref}/taxon-quantitative-measurements`, - ['taxon-collection-categories']: `${xref}/taxon-collection-categories`, - ['taxon-marking-body-locations']: `${xref}/taxon-marking-body-locations` -} as const; - -export type CbRouteKey = keyof typeof CbRoutes; - export const CRITTERBASE_API_HOST = process.env.CB_API_HOST || ``; -const CRITTER_ENDPOINT = '/critters'; -const BULK_ENDPOINT = '/bulk'; -const SIGNUP_ENDPOINT = '/signup'; -const FAMILY_ENDPOINT = '/family'; const defaultLog = getLogger('CritterbaseServiceLogger'); +// Response formats +enum CritterbaseFormatEnum { + DETAILED = 'detailed', + AS_SELECT = 'asSelect' +} + +/** + * @export + * @class CritterbaseService + * + */ export class CritterbaseService { + /** + * User details for Critterbase auditing + * + */ user: ICritterbaseUser; + /** + * KeycloakService for retrieving token + * + */ keycloak: KeycloakService; + /** + * Critterbase specific axios instance + * + */ axiosInstance: AxiosInstance; constructor(user: ICritterbaseUser) { @@ -315,29 +393,43 @@ export class CritterbaseService { this.keycloak = new KeycloakService(); this.axiosInstance = axios.create({ - headers: { - user: this.getUserHeader() - }, + paramsSerializer: (params) => qs.stringify(params), baseURL: CRITTERBASE_API_HOST }); + /** + * Response interceptor + * + * Formats Critterbase errors into SIMS format + */ this.axiosInstance.interceptors.response.use( (response: AxiosResponse) => { return response; }, (error: AxiosError) => { - defaultLog.error({ label: 'CritterbaseService', message: error.message, error }); + defaultLog.error({ label: 'CritterbaseService', message: error.message, error: error.response?.data }); + return Promise.reject( - new ApiError(ApiErrorType.GENERAL, `API request failed with status code ${error?.response?.status}`) + new ApiError( + ApiErrorType.GENERAL, + `Critterbase API request failed with status code ${error?.response?.status}`, + [error.response?.data as object] + ) ); } ); - // Async request interceptor + /** + * Async request interceptor + * + * Injects the bearer authentication token and user details into headers + */ this.axiosInstance.interceptors.request.use( async (config) => { - const token = await this.getToken(); + const token = await this.keycloak.getKeycloakServiceToken(); + config.headers['Authorization'] = `Bearer ${token}`; + config.headers.user = JSON.stringify(this.user); return config; }, @@ -347,92 +439,208 @@ export class CritterbaseService { ); } - async getToken(): Promise { - const token = await this.keycloak.getKeycloakServiceToken(); - return token; - } - /** - * Return user information as a JSON string. + * Fetches Critterbase colour lookup values. * - * @return {*} {string} - * @memberof BctwService + * @async + * @returns {Promise} AsSelect format */ - getUserHeader(): string { - return JSON.stringify(this.user); - } - - async _makeGetRequest(endpoint: string, params: QueryParam[]) { - const appendParams = new URLSearchParams(); - for (const p of params) { - appendParams.append(p.key, p.value); - } - const url = `${endpoint}?${appendParams.toString()}`; + async getColours(): Promise { + const response = await this.axiosInstance.get('/lookups/colours', { + params: { format: CritterbaseFormatEnum.AS_SELECT } + }); - const response = await this.axiosInstance.get(url); return response.data; } - async getLookupValues(route: CbRouteKey, params: QueryParam[]) { - return this._makeGetRequest(CbRoutes[route], params); + /** + * Fetches Critterbase marking type lookup values. + * + * @async + * @returns {Promise} AsSelect format + */ + async getMarkingTypes(): Promise { + const response = await this.axiosInstance.get('/lookups/marking-types', { + params: { format: CritterbaseFormatEnum.AS_SELECT } + }); + + return response.data; } + /** + * Fetches qualitative and quantitative measurements for the specified taxon. + * + * @param {string} tsn - The taxon serial number (TSN). + * @returns {Promise<{ qualitative: CBQualitativeMeasurementTypeDefinition[], quantitative: CBQuantitativeMeasurementTypeDefinition[] }>} - The response data containing qualitative and quantitative measurements. + */ async getTaxonMeasurements(tsn: string): Promise<{ qualitative: CBQualitativeMeasurementTypeDefinition[]; quantitative: CBQuantitativeMeasurementTypeDefinition[]; }> { - const response = await this._makeGetRequest(CbRoutes['taxon-measurements'], [{ key: 'tsn', value: tsn }]); - return response; + const response = await this.axiosInstance.get('/xref/taxon-measurements', { params: { tsn } }); + + return response.data; } - async getTaxonBodyLocations(tsn: string) { - return this._makeGetRequest(CbRoutes['taxon-marking-body-locations'], [ - { key: 'tsn', value: tsn }, - { key: 'format', value: 'asSelect' } - ]); + /** + * Fetches body location information for the specified taxon. + * + * @param {string} tsn - The taxon serial number (TSN). + * @returns {Promise} - The response data containing body location information. + */ + async getTaxonBodyLocations(tsn: string): Promise { + const response = await this.axiosInstance.get('/xref/taxon-marking-body-locations', { + params: { tsn, format: CritterbaseFormatEnum.AS_SELECT } + }); + + return response.data; } - async getQualitativeOptions(taxon_measurement_id: string, format = 'asSelect') { - return this._makeGetRequest(CbRoutes['taxon-qualitative-measurement-options'], [ - { key: 'taxon_measurement_id', value: taxon_measurement_id }, - { key: 'format', value: format } - ]); + /** + * Fetches qualitative options for the specified taxon measurement. + * + * @param {string} taxon_measurement_id - The taxon measurement ID. + * @param {string} [format='asSelect'] - The format of the response data. + * @returns {Promise} - The response data containing qualitative options. + */ + async getQualitativeOptions(taxon_measurement_id: string, format = CritterbaseFormatEnum.AS_SELECT): Promise { + const response = await this.axiosInstance.get('/xref/taxon-qualitative-measurement-options', { + params: { taxon_measurement_id, format } + }); + + return response.data; } - async getFamilies() { - return this._makeGetRequest(FAMILY_ENDPOINT, []); + /** + * Fetches a list of all families. + * + * @returns {Promise} - The response data containing a list of families. + */ + async getFamilies(): Promise { + const response = await this.axiosInstance.get('/family'); + + return response.data; } - async getFamilyById(family_id: string) { - return this._makeGetRequest(`${FAMILY_ENDPOINT}/${family_id}`, []); + /** + * Fetches information about a family by its ID. + * + * @param {string} family_id - The ID of the family. + * @returns {Promise} - The response data containing family information. + */ + async getFamilyById(family_id: string): Promise { + const response = await this.axiosInstance.get(`/family/${family_id}`); + + return response.data; } - async getCritter(critter_id: string) { - return this._makeGetRequest(`${CRITTER_ENDPOINT}/${critter_id}`, [{ key: 'format', value: 'detail' }]); + /** + * Fetches information about a critter by its ID. + * + * @param {string} critter_id - The ID of the critter. + * @returns {Promise} - The response data containing critter information. + */ + async getCritter(critter_id: string): Promise { + const response = await this.axiosInstance.get(`/critters/${critter_id}`, { + params: { format: CritterbaseFormatEnum.DETAILED } + }); + + return response.data; } - async createCritter(data: ICreateCritter) { - const response = await this.axiosInstance.post(`${CRITTER_ENDPOINT}/create`, data); + async getCaptureById(capture_id: string): Promise { + const response = await this.axiosInstance.get(`/captures/${capture_id}`, { + params: { format: CritterbaseFormatEnum.DETAILED } + }); + return response.data; } - async updateCritter(data: IBulkCreate) { - const response = await this.axiosInstance.patch(BULK_ENDPOINT, data); + /** + * Creates a new critter with the provided data. + * + * @param {ICreateCritter} data - The data of the critter to be created. + * @returns {Promise} - The response data from the create operation. + */ + async createCritter(data: ICreateCritter): Promise { + const response = await this.axiosInstance.post(`/critters/create`, data); + return response.data; } + /** + * Updates critters in bulk with the provided data. + * + * @param {IBulkCreate} data - The data for the bulk update. + * @returns {Promise} - The response data from the update operation. + */ + async updateCritter(data: IBulkCreate): Promise { + const response = await this.axiosInstance.patch('/bulk', data); + + return response.data; + } + + /** + * Creates critters in bulk with the provided data. + * + * @param {IBulkCreate} data + * @return {*} {Promise} + * @memberof CritterbaseService + */ async bulkCreate(data: IBulkCreate): Promise { - const response = await this.axiosInstance.post(BULK_ENDPOINT, data); + const response = await this.axiosInstance.post('/bulk', data); + return response.data; } + /** + * Fetches multiple critters by their IDs. + * + * @param {string[]} critter_ids - The IDs of the critters. + * @returns {Promise} - The response data containing multiple critters. + */ async getMultipleCrittersByIds(critter_ids: string[]): Promise { - const response = await this.axiosInstance.post(CRITTER_ENDPOINT, { critter_ids }); + const response = await this.axiosInstance.post('/critters', { critter_ids }); + return response.data; } - async signUp() { - const response = await this.axiosInstance.post(SIGNUP_ENDPOINT); + /** + * Fetches detailed information about multiple critters by their IDs. + * + * @param {string[]} critter_ids - The IDs of the critters. + * @returns {Promise} - The response data containing detailed information about multiple critters. + */ + async getMultipleCrittersByIdsDetailed(critter_ids: string[]): Promise { + const response = await this.axiosInstance.post( + `/critters`, + { critter_ids }, + { params: { format: CritterbaseFormatEnum.DETAILED } } + ); + + return response.data; + } + + /** + * Fetches detailed information about multiple critters by their IDs. + * + * @param {string[]} critter_ids - The IDs of the critters. + * @returns {Promise} - The response data containing detailed information about multiple critters. + */ + async getMultipleCrittersGeometryByIds(critter_ids: string[]): Promise { + const response = await this.axiosInstance.post(`/critters/spatial`, { critter_ids }); + + return response.data; + } + + /** + * Signs up a user. + * + * @returns {Promise} - The response data from the sign-up operation. + */ + async signUp(): Promise { + const response = await this.axiosInstance.post('/signup'); + return response.data; } @@ -446,11 +654,11 @@ export class CritterbaseService { async getQualitativeMeasurementTypeDefinition( taxon_measurement_ids: string[] ): Promise { - const { data } = await this.axiosInstance.post(`/xref/taxon-qualitative-measurements`, { + const response = await this.axiosInstance.post(`/xref/taxon-qualitative-measurements`, { taxon_measurement_ids: taxon_measurement_ids }); - return data; + return response.data; } /** @@ -463,11 +671,11 @@ export class CritterbaseService { async getQuantitativeMeasurementTypeDefinition( taxon_measurement_ids: string[] ): Promise { - const { data } = await this.axiosInstance.post(`/xref/taxon-quantitative-measurements`, { + const response = await this.axiosInstance.post(`/xref/taxon-quantitative-measurements`, { taxon_measurement_ids: taxon_measurement_ids }); - return data; + return response.data; } /** @@ -478,7 +686,7 @@ export class CritterbaseService { * @returns {Promise} Collection categories */ async findTaxonCollectionCategories(tsn: string): Promise { - const response = await this.axiosInstance.get(`/xref/taxon-collection-categories?tsn=${tsn}`); + const response = await this.axiosInstance.get(`/xref/taxon-collection-categories`, { params: { tsn } }); return response.data; } @@ -491,7 +699,7 @@ export class CritterbaseService { * @returns {Promise} Collection units */ async findTaxonCollectionUnits(tsn: string): Promise { - const response = await this.axiosInstance.get(`/xref/taxon-collection-units?tsn=${tsn}`); + const response = await this.axiosInstance.get(`/xref/taxon-collection-units`, { params: { tsn } }); return response.data; } diff --git a/api/src/services/deployment-service.ts b/api/src/services/deployment-service.ts new file mode 100644 index 0000000000..c0cc0faf23 --- /dev/null +++ b/api/src/services/deployment-service.ts @@ -0,0 +1,89 @@ +import { IDBConnection } from '../database/db'; +import { ICreateSurveyDeployment, IUpdateSurveyDeployment, SurveyDeployment } from '../models/survey-deployment'; +import { DeploymentRepository } from '../repositories/deployment-repository'; +import { DBService } from './db-service'; + +/** + * Service layer for survey critters. + * + * @export + * @class DeploymentService + * @extends {DBService} + */ +export class DeploymentService extends DBService { + deploymentRepository: DeploymentRepository; + + constructor(connection: IDBConnection) { + super(connection); + + this.deploymentRepository = new DeploymentRepository(connection); + } + + /** + * Get deployments for a Survey + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof DeploymentService + */ + async getDeploymentsForSurveyId(surveyId: number): Promise { + return this.deploymentRepository.getDeploymentsForSurveyId(surveyId); + } + + /** + * Get a specific deployment by its integer ID + * + * @param {number} deploymentId + * @return {*} {Promise} + * @memberof DeploymentService + */ + async getDeploymentById(deploymentId: number): Promise { + return this.deploymentRepository.getDeploymentById(deploymentId); + } + + /** + * Get a specific deployment by its integer ID + * + * @param {number} surveyId + * @param {number} critterId + * @return {*} {Promise} + * @memberof DeploymentService + */ + async getDeploymentForCritterId(surveyId: number, critterId: number): Promise { + return this.deploymentRepository.getDeploymentForCritterId(surveyId, critterId); + } + + /** + * Create a new deployment + * + * @param {ICreateSurveyDeployment} deployment + * @return {*} {Promise} + * @memberof DeploymentService + */ + async insertDeployment(deployment: ICreateSurveyDeployment): Promise { + return this.deploymentRepository.insertDeployment(deployment); + } + + /** + * Update a deployment in SIMS + * + * @param {IUpdateSurveyDeployment} deployment + * @return {*} {Promise} + * @memberof DeploymentService + */ + async updateDeployment(deployment: IUpdateSurveyDeployment): Promise { + return this.deploymentRepository.updateDeployment(deployment); + } + + /** + * Deletes the deployment in SIMS. + * + * @param {number} surveyId + * @param {number} deploymentId + * @return {*} {Promise<{ bctw_deployment_id: string }>} + * @memberof DeploymentService + */ + async deleteDeployment(surveyId: number, deploymentId: number): Promise<{ bctw_deployment_id: string }> { + return this.deploymentRepository.deleteDeployment(surveyId, deploymentId); + } +} diff --git a/api/src/services/import-services/capture/import-captures-strategy.interface.ts b/api/src/services/import-services/capture/import-captures-strategy.interface.ts new file mode 100644 index 0000000000..b5dd346dec --- /dev/null +++ b/api/src/services/import-services/capture/import-captures-strategy.interface.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +/** + * Zod Csv Capture schema + * + */ +export const CsvCaptureSchema = z + .object({ + capture_id: z.string({ required_error: 'Capture exists using same date and time' }).uuid(), + critter_id: z.string({ required_error: 'Unable to map alias to Critter ID.' }).uuid(), + capture_location_id: z.string().uuid(), + capture_date: z.string().date(), + capture_time: z.string().time().optional(), + capture_latitude: z.number(), + capture_longitude: z.number(), + release_location_id: z.string().uuid().optional(), + release_date: z.string().date().optional(), + release_time: z.string().time().optional(), + release_latitude: z.number().optional(), + release_longitude: z.number().optional(), + capture_comment: z.string().optional(), + release_comment: z.string().optional() + }) + .refine((schema) => { + const hasReleaseLatLng = schema.release_latitude && schema.release_longitude; + return hasReleaseLatLng || !hasReleaseLatLng; + }, 'Both release latitude and longitude are required if one is provided.'); + +/** + * A validated CSV Capture object + * + */ +export type CsvCapture = z.infer; + +/** + * Partial CSV Capture object with defined `critter_id` + * + */ +export type PartialCsvCapture = Partial & { critter_id: string }; diff --git a/api/src/services/import-services/capture/import-captures-strategy.test.ts b/api/src/services/import-services/capture/import-captures-strategy.test.ts new file mode 100644 index 0000000000..b7df1d6eff --- /dev/null +++ b/api/src/services/import-services/capture/import-captures-strategy.test.ts @@ -0,0 +1,213 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { MediaFile } from '../../../utils/media/media-file'; +import * as worksheetUtils from '../../../utils/xlsx-utils/worksheet-utils'; +import { getMockDBConnection } from '../../../__mocks__/db'; +import { IBulkCreateResponse, ICritterDetailed } from '../../critterbase-service'; +import { importCSV } from '../import-csv'; +import { ImportCapturesStrategy } from './import-captures-strategy'; + +describe('import-captures-service', () => { + describe('importCSV capture worksheet', () => { + it('should validate successfully', async () => { + const worksheet = { + A1: { t: 's', v: 'CAPTURE_DATE' }, + B1: { t: 's', v: 'NICKNAME' }, + C1: { t: 's', v: 'CAPTURE_TIME' }, + D1: { t: 's', v: 'CAPTURE_LATITUDE' }, + E1: { t: 's', v: 'CAPTURE_LONGITUDE' }, + F1: { t: 's', v: 'RELEASE_DATE' }, + G1: { t: 's', v: 'RELEASE_TIME' }, + H1: { t: 's', v: 'RELEASE_LATITUDE' }, + I1: { t: 's', v: 'RELEASE_LONGITUDE' }, + J1: { t: 's', v: 'RELEASE_COMMENT' }, + K1: { t: 's', v: 'CAPTURE_COMMENT' }, + A2: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + B2: { t: 's', v: 'Carl' }, + C2: { t: 's', v: '10:10:10' }, + D2: { t: 'n', w: '90', v: 90 }, + E2: { t: 'n', w: '100', v: 100 }, + F2: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + G2: { t: 's', v: '9:09' }, + H2: { t: 'n', w: '90', v: 90 }, + I2: { t: 'n', w: '90', v: 90 }, + J2: { t: 's', v: 'release' }, + K2: { t: 's', v: 'capture' }, + A3: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + B3: { t: 's', v: 'Carlita' }, + D3: { t: 'n', w: '90', v: 90 }, + E3: { t: 'n', w: '100', v: 100 }, + '!ref': 'A1:K3' + }; + + const mockDBConnection = getMockDBConnection(); + + const importCapturesStrategy = new ImportCapturesStrategy(mockDBConnection, 1); + + const getDefaultWorksheetStub = sinon.stub(worksheetUtils, 'getDefaultWorksheet'); + const aliasMapStub = sinon.stub(importCapturesStrategy.surveyCritterService, 'getSurveyCritterAliasMap'); + const critterbaseInsertStub = sinon.stub( + importCapturesStrategy.surveyCritterService.critterbaseService, + 'bulkCreate' + ); + + getDefaultWorksheetStub.returns(worksheet); + aliasMapStub.resolves( + new Map([ + [ + 'carl', + { + critter_id: '3647cdc9-6fe9-4c32-acfa-6096fe123c4a', + captures: [{ capture_id: '', capture_date: '', capture_time: '' }] + } as ICritterDetailed + ], + [ + 'carlita', + { + critter_id: '3647cdc9-6fe9-4c32-acfa-6096fe123c4a', + captures: [{ capture_id: '', capture_date: '', capture_time: '' }] + } as ICritterDetailed + ] + ]) + ); + critterbaseInsertStub.resolves({ created: { captures: 2 } } as IBulkCreateResponse); + + const data = await importCSV(new MediaFile('test', 'test', 'test' as unknown as Buffer), importCapturesStrategy); + + expect(data).to.deep.equal(2); + }); + }); + describe('validateRows', () => { + it('should format and validate the rows successfully', async () => { + const mockConnection = getMockDBConnection(); + const importCaptures = new ImportCapturesStrategy(mockConnection, 1); + const aliasMapStub = sinon.stub(importCaptures.surveyCritterService, 'getSurveyCritterAliasMap'); + + aliasMapStub.resolves( + new Map([ + [ + 'carl', + { + critter_id: '3647cdc9-6fe9-4c32-acfa-6096fe123c4a', + captures: [{ capture_id: '', capture_date: '', capture_time: '' }] + } as ICritterDetailed + ] + ]) + ); + + const validate = await importCaptures.validateRows([ + { + ALIAS: 'Carl', + CAPTURE_DATE: '2024-01-01', + CAPTURE_TIME: '10:10:10', + CAPTURE_LATITUDE: 90, + CAPTURE_LONGITUDE: 90, + RELEASE_DATE: '2024-01-01', + RELEASE_TIME: '11:11:11', + RELEASE_LATITUDE: 80, + RELEASE_LONGITUDE: 80, + CAPTURE_COMMENT: 'capture', + RELEASE_COMMENT: 'release' + } + ]); + + if (validate.success) { + expect(validate.data[0]).to.contain({ + critter_id: '3647cdc9-6fe9-4c32-acfa-6096fe123c4a', + capture_date: '2024-01-01', + capture_time: '10:10:10', + capture_latitude: 90, + capture_longitude: 90, + release_date: '2024-01-01', + release_time: '11:11:11', + release_latitude: 80, + release_longitude: 80, + capture_comment: 'capture', + release_comment: 'release' + }); + expect(validate.data[0].capture_location_id).to.exist; + expect(validate.data[0].release_location_id).to.exist; + } else { + expect.fail(); + } + }); + + it('should format and validate the rows with optional values successfully', async () => { + const mockConnection = getMockDBConnection(); + const importCaptures = new ImportCapturesStrategy(mockConnection, 1); + const aliasMapStub = sinon.stub(importCaptures.surveyCritterService, 'getSurveyCritterAliasMap'); + + aliasMapStub.resolves( + new Map([ + [ + 'carl', + { + critter_id: '3647cdc9-6fe9-4c32-acfa-6096fe123c4a', + captures: [{ capture_id: '', capture_date: '', capture_time: '' }] + } as ICritterDetailed + ] + ]) + ); + + const validate = await importCaptures.validateRows([ + { + ALIAS: 'Carl', + CAPTURE_DATE: '2024-01-01', + CAPTURE_LATITUDE: 90, + CAPTURE_LONGITUDE: 90 + } + ]); + + if (validate.success) { + expect(validate.data[0]).to.contain({ + critter_id: '3647cdc9-6fe9-4c32-acfa-6096fe123c4a', + capture_date: '2024-01-01', + capture_latitude: 90, + capture_longitude: 90, + capture_time: undefined, + release_date: undefined, + release_time: undefined, + release_latitude: undefined, + release_longitude: undefined, + capture_comment: undefined, + release_comment: undefined + }); + expect(validate.data[0].capture_location_id).to.exist; + } else { + expect.fail(); + } + }); + + it('should return error if invalid', async () => { + const mockConnection = getMockDBConnection(); + const importCaptures = new ImportCapturesStrategy(mockConnection, 1); + const aliasMapStub = sinon.stub(importCaptures.surveyCritterService, 'getSurveyCritterAliasMap'); + + aliasMapStub.resolves( + new Map([ + [ + 'carl', + { + critter_id: '3647cdc9-6fe9-4c32-acfa-6096fe123c4a', + captures: [{ capture_id: '', capture_date: '', capture_time: '' }] + } as ICritterDetailed + ] + ]) + ); + + const validate = await importCaptures.validateRows([ + { + ALIAS: 'Carl', + CAPTURE_DATE: '2024-01-01', + CAPTURE_LATITUDE: 90 + } + ]); + + if (validate.success) { + expect.fail(); + } else { + expect(validate.error.issues.length).to.be.eql(1); + } + }); + }); +}); diff --git a/api/src/services/import-services/capture/import-captures-strategy.ts b/api/src/services/import-services/capture/import-captures-strategy.ts new file mode 100644 index 0000000000..f69ab1469e --- /dev/null +++ b/api/src/services/import-services/capture/import-captures-strategy.ts @@ -0,0 +1,157 @@ +import { v4 as uuid } from 'uuid'; +import { z } from 'zod'; +import { IDBConnection } from '../../../database/db'; +import { CSV_COLUMN_ALIASES } from '../../../utils/xlsx-utils/column-aliases'; +import { generateColumnCellGetterFromColumnValidator } from '../../../utils/xlsx-utils/column-validator-utils'; +import { IXLSXCSVValidator } from '../../../utils/xlsx-utils/worksheet-utils'; +import { ICapture, ILocation } from '../../critterbase-service'; +import { DBService } from '../../db-service'; +import { SurveyCritterService } from '../../survey-critter-service'; +import { CSVImportStrategy, Row } from '../import-csv.interface'; +import { findCapturesFromDateTime, formatTimeString } from '../utils/datetime'; +import { CsvCapture, CsvCaptureSchema } from './import-captures-strategy.interface'; + +/** + * + * @class ImportCapturesStrategy + * @extends DBService + * @see CSVImportStrategy + * + */ +export class ImportCapturesStrategy extends DBService implements CSVImportStrategy { + surveyCritterService: SurveyCritterService; + surveyId: number; + + /** + * An XLSX validation config for the standard columns of a Critterbase Capture CSV. + * + * Note: `satisfies` allows `keyof` to correctly infer keyof type, while also + * enforcing uppercase object keys. + */ + columnValidator = { + ALIAS: { type: 'string', aliases: CSV_COLUMN_ALIASES.ALIAS }, + CAPTURE_DATE: { type: 'date' }, + CAPTURE_TIME: { type: 'string', optional: true }, + CAPTURE_LATITUDE: { type: 'number' }, + CAPTURE_LONGITUDE: { type: 'number' }, + RELEASE_DATE: { type: 'date', optional: true }, + RELEASE_TIME: { type: 'string', optional: true }, + RELEASE_LATITUDE: { type: 'number', optional: true }, + RELEASE_LONGITUDE: { type: 'number', optional: true }, + CAPTURE_COMMENT: { type: 'string', optional: true }, + RELEASE_COMMENT: { type: 'string', optional: true } + } satisfies IXLSXCSVValidator; + + /** + * Construct an instance of ImportCapturesStrategy. + * + * @param {IDBConnection} connection - DB connection + * @param {string} surveyId + */ + constructor(connection: IDBConnection, surveyId: number) { + super(connection); + + this.surveyId = surveyId; + + this.surveyCritterService = new SurveyCritterService(connection); + } + + /** + * Validate the CSV rows against zod schema. + * + * @param {Row[]} rows - CSV rows + * @returns {*} + */ + async validateRows(rows: Row[]) { + // Generate type-safe cell getter from column validator + const getColumnCell = generateColumnCellGetterFromColumnValidator(this.columnValidator); + const critterAliasMap = await this.surveyCritterService.getSurveyCritterAliasMap(this.surveyId); + + const rowsToValidate = []; + + for (const row of rows) { + let critterId, captureId; + + const alias = getColumnCell(row, 'ALIAS'); + + const releaseLatitude = getColumnCell(row, 'RELEASE_LATITUDE'); + const releaseLongitude = getColumnCell(row, 'RELEASE_LONGITUDE'); + const captureDate = getColumnCell(row, 'CAPTURE_DATE'); + const captureTime = getColumnCell(row, 'CAPTURE_TIME'); + const releaseTime = getColumnCell(row, 'RELEASE_TIME'); + + const releaseLocationId = releaseLatitude.cell && releaseLongitude.cell ? uuid() : undefined; + const formattedCaptureTime = formatTimeString(captureTime.cell); + const formattedReleaseTime = formatTimeString(releaseTime.cell); + + // If the alias is included attempt to retrieve the critterId from row + // Checks if date time fields are unique for the critter's captures + if (alias.cell) { + const critter = critterAliasMap.get(alias.cell.toLowerCase()); + if (critter) { + const captures = findCapturesFromDateTime(critter.captures, captureDate.cell, captureTime.cell); + critterId = critter.critter_id; + // Only set the captureId if a capture does not exist with matching date time + captureId = captures.length > 0 ? undefined : uuid(); + } + } + + rowsToValidate.push({ + capture_id: captureId, // this will be undefined if capture exists with same date / time + critter_id: critterId, + capture_location_id: uuid(), + capture_date: captureDate.cell, + capture_time: formattedCaptureTime, + capture_latitude: getColumnCell(row, 'CAPTURE_LATITUDE').cell, + capture_longitude: getColumnCell(row, 'CAPTURE_LONGITUDE').cell, + release_location_id: releaseLocationId, + release_date: getColumnCell(row, 'RELEASE_DATE').cell, + release_time: formattedReleaseTime, + release_latitude: getColumnCell(row, 'RELEASE_LATITUDE').cell, + release_longitude: getColumnCell(row, 'RELEASE_LONGITUDE').cell, + capture_comment: getColumnCell(row, 'CAPTURE_COMMENT').cell, + release_comment: getColumnCell(row, 'RELEASE_COMMENT').cell + }); + } + + return z.array(CsvCaptureSchema).safeParseAsync(rowsToValidate); + } + + /** + * Insert captures into Critterbase. + * + * @async + * @param {CsvCapture[]} captures - List of CSV captures to create + * @returns {Promise} Number of created captures + */ + async insert(captures: CsvCapture[]): Promise { + const critterbasePayload: { captures: ICapture[]; locations: ILocation[] } = { captures: [], locations: [] }; + + for (const row of captures) { + const { capture_latitude, capture_longitude, release_latitude, release_longitude, ...capture } = row; + + // Push the critter captures into payload + critterbasePayload.captures.push(capture); + + // Push the capture location + critterbasePayload.locations.push({ + location_id: row.capture_location_id, + latitude: capture_latitude, + longitude: capture_longitude + }); + + // Push the capture release location if included + if (row.release_location_id && release_latitude && release_longitude) { + critterbasePayload.locations?.push({ + location_id: row.release_location_id, + latitude: release_latitude, + longitude: release_longitude + }); + } + } + + const response = await this.surveyCritterService.critterbaseService.bulkCreate(critterbasePayload); + + return response.created.captures; + } +} diff --git a/api/src/services/import-services/critter/import-critters-strategy.interface.ts b/api/src/services/import-services/critter/import-critters-strategy.interface.ts new file mode 100644 index 0000000000..7d50161433 --- /dev/null +++ b/api/src/services/import-services/critter/import-critters-strategy.interface.ts @@ -0,0 +1,20 @@ +/** + * A validated CSV Critter object + * + */ +export type CsvCritter = { + critter_id: string; + sex: string; + itis_tsn: number; + animal_id: string; + wlh_id?: string; + critter_comment?: string; +} & { + [collectionUnitColumn: string]: unknown; +}; + +/** + * Invalidated CSV Critter object + * + */ +export type PartialCsvCritter = Partial & { critter_id: string }; diff --git a/api/src/services/import-services/critter/import-critters-strategy.test.ts b/api/src/services/import-services/critter/import-critters-strategy.test.ts new file mode 100644 index 0000000000..9872119aa4 --- /dev/null +++ b/api/src/services/import-services/critter/import-critters-strategy.test.ts @@ -0,0 +1,596 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { WorkSheet } from 'xlsx'; +import { getMockDBConnection } from '../../../__mocks__/db'; +import { IBulkCreateResponse } from '../../critterbase-service'; +import { ImportCrittersStrategy } from './import-critters-strategy'; +import { CsvCritter } from './import-critters-strategy.interface'; + +chai.use(sinonChai); + +const mockConnection = getMockDBConnection(); + +describe('ImportCrittersStrategy', () => { + describe('_getRowsToValidate', () => { + it('it should correctly format rows', () => { + const rows = [ + { + SEX: 'Male', + ITIS_TSN: 1, + WLH_ID: '10-1000', + ALIAS: 'Carl', + COMMENT: 'Test', + COLLECTION: 'Unit', + BAD_COLLECTION: 'Bad' + } + ]; + const service = new ImportCrittersStrategy(mockConnection, 1); + + const parsedRow = service._getRowsToValidate(rows, ['COLLECTION'])[0]; + + expect(parsedRow.sex).to.be.eq('Male'); + expect(parsedRow.itis_tsn).to.be.eq(1); + expect(parsedRow.wlh_id).to.be.eq('10-1000'); + expect(parsedRow.animal_id).to.be.eq('Carl'); + expect(parsedRow.critter_comment).to.be.eq('Test'); + expect(parsedRow.COLLECTION).to.be.eq('Unit'); + expect(parsedRow.TEST).to.be.undefined; + expect(parsedRow.BAD_COLLECTION).to.be.undefined; + }); + }); + + describe('_getCritterFromRow', () => { + it('should get all critter properties', () => { + const row: any = { + critter_id: 'id', + sex: 'Male', + itis_tsn: 1, + animal_id: 'Carl', + wlh_id: '10-1000', + critter_comment: 'comment', + extra_property: 'test' + }; + const service = new ImportCrittersStrategy(mockConnection, 1); + + const critter = service._getCritterFromRow(row); + + expect(critter).to.be.eql({ + critter_id: 'id', + sex: 'Male', + itis_tsn: 1, + animal_id: 'Carl', + wlh_id: '10-1000', + critter_comment: 'comment' + }); + }); + }); + + describe('_getCollectionUnitsFromRow', () => { + it('should get all collection unit properties', () => { + const row: any = { + critter_id: 'id', + sex: 'Male', + itis_tsn: 1, + animal_id: 'Carl', + wlh_id: '10-1000', + critter_comment: 'comment', + COLLECTION: 'ID1', + HERD: 'ID2' + }; + const service = new ImportCrittersStrategy(mockConnection, 1); + + const collectionUnits = service._getCollectionUnitsFromRow(row); + + expect(collectionUnits).to.be.deep.equal([ + { collection_unit_id: 'ID1', critter_id: 'id' }, + { collection_unit_id: 'ID2', critter_id: 'id' } + ]); + }); + }); + + describe('_getValidTsns', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should return unique list of tsns', async () => { + const service = new ImportCrittersStrategy(mockConnection, 1); + + const getTaxonomyStub = sinon.stub(service.platformService, 'getTaxonomyByTsns').resolves([ + { tsn: 1, scientificName: 'a' }, + { tsn: 2, scientificName: 'b' } + ]); + + const tsns = await service._getValidTsns([ + { critter_id: 'a', itis_tsn: 1 }, + { critter_id: 'b', itis_tsn: 2 } + ]); + + expect(getTaxonomyStub).to.have.been.calledWith(['1', '2']); + expect(tsns).to.deep.equal(['1', '2']); + }); + }); + + describe('_getCollectionUnitMap', () => { + afterEach(() => { + sinon.restore(); + }); + + const collectionUnitsA = [ + { + collection_unit_id: '1', + collection_category_id: '2', + category_name: 'COLLECTION', + unit_name: 'UNIT_A', + description: 'description' + }, + { + collection_unit_id: '2', + collection_category_id: '3', + category_name: 'COLLECTION', + unit_name: 'UNIT_B', + description: 'description' + } + ]; + + const collectionUnitsB = [ + { + collection_unit_id: '1', + collection_category_id: '2', + category_name: 'HERD', + unit_name: 'UNIT_A', + description: 'description' + }, + { + collection_unit_id: '2', + collection_category_id: '3', + category_name: 'HERD', + unit_name: 'UNIT_B', + description: 'description' + } + ]; + + it('should return collection unit mapping', async () => { + const service = new ImportCrittersStrategy(mockConnection, 1); + + const getColumnsStub = sinon.stub(service, '_getNonStandardColumns'); + const mockWorksheet = {} as unknown as WorkSheet; + + const findCollectionUnitsStub = sinon.stub(service.critterbaseService, 'findTaxonCollectionUnits'); + + getColumnsStub.returns(['COLLECTION', 'HERD']); + findCollectionUnitsStub.onCall(0).resolves(collectionUnitsA); + findCollectionUnitsStub.onCall(1).resolves(collectionUnitsB); + + const mapping = await service._getCollectionUnitMap(mockWorksheet, ['1', '2']); + expect(getColumnsStub).to.have.been.calledWith(mockWorksheet); + expect(findCollectionUnitsStub).to.have.been.calledTwice; + + expect(mapping).to.be.instanceof(Map); + expect(mapping.get('COLLECTION')).to.be.deep.equal({ collectionUnits: collectionUnitsA, tsn: 1 }); + expect(mapping.get('HERD')).to.be.deep.equal({ collectionUnits: collectionUnitsB, tsn: 2 }); + }); + + it('should return empty map when no collection unit columns', async () => { + const service = new ImportCrittersStrategy(mockConnection, 1); + + const getColumnsStub = sinon.stub(service, '_getNonStandardColumns'); + const mockWorksheet = {} as unknown as WorkSheet; + + const findCollectionUnitsStub = sinon.stub(service.critterbaseService, 'findTaxonCollectionUnits'); + getColumnsStub.returns([]); + + const mapping = await service._getCollectionUnitMap(mockWorksheet, ['1', '2']); + expect(getColumnsStub).to.have.been.calledWith(mockWorksheet); + expect(findCollectionUnitsStub).to.have.not.been.called; + + expect(mapping).to.be.instanceof(Map); + }); + }); + + describe('insert', () => { + afterEach(() => { + sinon.restore(); + }); + + const critters: CsvCritter[] = [ + { + critter_id: '1', + sex: 'Male', + itis_tsn: 1, + animal_id: 'Carl', + wlh_id: '10-1000', + critter_comment: 'comment', + COLLECTION: 'Collection Unit' + }, + { + critter_id: '2', + sex: 'Female', + itis_tsn: 2, + animal_id: 'Carl', + wlh_id: '10-1000', + critter_comment: 'comment', + HERD: 'Herd Unit' + } + ]; + + it('should correctly parse collection units and critters and insert into sims / critterbase', async () => { + const service = new ImportCrittersStrategy(mockConnection, 1); + + const critterbaseBulkCreateStub = sinon.stub(service.critterbaseService, 'bulkCreate'); + const simsAddSurveyCrittersStub = sinon.stub(service.surveyCritterService, 'addCrittersToSurvey'); + + critterbaseBulkCreateStub.resolves({ created: { critters: 2, collections: 1 } } as IBulkCreateResponse); + simsAddSurveyCrittersStub.resolves([1]); + + const ids = await service.insert(critters); + + expect(critterbaseBulkCreateStub).to.have.been.calledWithExactly({ + critters: [ + { + critter_id: '1', + sex: 'Male', + itis_tsn: 1, + animal_id: 'Carl', + wlh_id: '10-1000', + critter_comment: 'comment' + }, + { + critter_id: '2', + sex: 'Female', + itis_tsn: 2, + animal_id: 'Carl', + wlh_id: '10-1000', + critter_comment: 'comment' + } + ], + collections: [ + { collection_unit_id: 'Collection Unit', critter_id: '1' }, + { collection_unit_id: 'Herd Unit', critter_id: '2' } + ] + }); + + expect(ids).to.be.deep.equal([1]); + }); + + it('should throw error if response from critterbase is less than provided critters', async () => { + const service = new ImportCrittersStrategy(mockConnection, 1); + + const critterbaseBulkCreateStub = sinon.stub(service.critterbaseService, 'bulkCreate'); + const simsAddSurveyCrittersStub = sinon.stub(service.surveyCritterService, 'addCrittersToSurvey'); + + critterbaseBulkCreateStub.resolves({ created: { critters: 1, collections: 1 } } as IBulkCreateResponse); + simsAddSurveyCrittersStub.resolves([1]); + + try { + await service.insert(critters); + expect.fail(); + } catch (err: any) { + expect(err.message).to.be.equal('Unable to fully import critters from CSV'); + } + + expect(simsAddSurveyCrittersStub).to.not.have.been.called; + }); + }); + + describe('validateRows', () => { + afterEach(() => { + sinon.restore(); + }); + + const collectionUnitsA = [ + { + collection_unit_id: '1', + collection_category_id: '2', + category_name: 'COLLECTION', + unit_name: 'UNIT_A', + description: 'description' + }, + { + collection_unit_id: '2', + collection_category_id: '3', + category_name: 'COLLECTION', + unit_name: 'UNIT_B', + description: 'description' + } + ]; + + const collectionUnitsB = [ + { + collection_unit_id: '1', + collection_category_id: '2', + category_name: 'HERD', + unit_name: 'UNIT_A', + description: 'description' + }, + { + collection_unit_id: '2', + collection_category_id: '3', + category_name: 'HERD', + unit_name: 'UNIT_B', + description: 'description' + } + ]; + + it('should return successful', async () => { + const service = new ImportCrittersStrategy(mockConnection, 1); + + const getColumnsStub = sinon.stub(service, '_getNonStandardColumns'); + const surveyAliasesStub = sinon.stub(service.surveyCritterService, 'getUniqueSurveyCritterAliases'); + const getValidTsnsStub = sinon.stub(service, '_getValidTsns'); + const collectionMapStub = sinon.stub(service, '_getCollectionUnitMap'); + + getColumnsStub.returns(['COLLECTION', 'HERD']); + surveyAliasesStub.resolves(new Set(['Not Carl', 'Carlita'])); + getValidTsnsStub.resolves(['1', '2']); + collectionMapStub.resolves( + new Map([ + ['COLLECTION', { collectionUnits: collectionUnitsA, tsn: 1 }], + ['HERD', { collectionUnits: collectionUnitsB, tsn: 2 }] + ]) + ); + + const rows = [ + { + ITIS_TSN: 1, + SEX: 'Male', + ALIAS: 'Carl', + WLH_ID: '10-1000', + DESCRIPTION: 'A', + COLLECTION: 'UNIT_A' + }, + { + ITIS_TSN: 2, + SEX: 'Female', + ALIAS: 'Carl2', + WLH_ID: '10-1000', + DESCRIPTION: 'B', + HERD: 'UNIT_B' + } + ]; + + const validation = await service.validateRows(rows, {}); + + if (validation.success) { + // Unable to spoof UUID so using contain + expect(validation.data[0]).to.contain({ + sex: 'Male', + itis_tsn: 1, + animal_id: 'Carl', + wlh_id: '10-1000', + critter_comment: 'A', + COLLECTION: '1' + }); + + expect(validation.data[1]).to.contain({ + sex: 'Female', + itis_tsn: 2, + animal_id: 'Carl2', + wlh_id: '10-1000', + critter_comment: 'B', + HERD: '2' + }); + } else { + expect.fail(); + } + }); + + it('should return error when sex undefined', async () => { + const service = new ImportCrittersStrategy(mockConnection, 1); + + const surveyAliasesStub = sinon.stub(service.surveyCritterService, 'getUniqueSurveyCritterAliases'); + const getValidTsnsStub = sinon.stub(service, '_getValidTsns'); + + surveyAliasesStub.resolves(new Set([])); + getValidTsnsStub.resolves(['1']); + + const rows = [ + { + ITIS_TSN: 1, + SEX: undefined, + ALIAS: 'Carl', + WLH_ID: '10-1000', + DESCRIPTION: 'A' + }, + { + ITIS_TSN: 1, + SEX: 'NO', // invalid value + ALIAS: 'Carl2', + WLH_ID: '10-1000', + DESCRIPTION: 'A' + } + ]; + + const validation = await service.validateRows(rows, {}); + + if (validation.success) { + expect.fail(); + } else { + expect(validation.error.issues).to.deep.equal([ + { row: 0, message: 'Invalid SEX. Expecting: UNKNOWN, MALE, FEMALE, HERMAPHRODITIC.' }, + { row: 1, message: 'Invalid SEX. Expecting: UNKNOWN, MALE, FEMALE, HERMAPHRODITIC.' } + ]); + } + }); + + it('should return error when wlh_id invalid regex', async () => { + const service = new ImportCrittersStrategy(mockConnection, 1); + + const surveyAliasesStub = sinon.stub(service.surveyCritterService, 'getUniqueSurveyCritterAliases'); + const getValidTsnsStub = sinon.stub(service, '_getValidTsns'); + + surveyAliasesStub.resolves(new Set([])); + getValidTsnsStub.resolves(['1']); + + const rows = [ + { + ITIS_TSN: 1, + SEX: 'Male', + ALIAS: 'Carl', + WLH_ID: '1-1000', + DESCRIPTION: 'A' + }, + { + ITIS_TSN: 1, + SEX: 'Male', + ALIAS: 'Carl2', + WLH_ID: '101000', + DESCRIPTION: 'A' + } + ]; + + const validation = await service.validateRows(rows, {}); + + if (validation.success) { + expect.fail(); + } else { + expect(validation.error.issues).to.deep.equal([ + { row: 0, message: `Invalid WLH_ID. Example format '10-1000R'.` }, + { row: 1, message: `Invalid WLH_ID. Example format '10-1000R'.` } + ]); + } + }); + + it('should return error when itis_tsn invalid option or undefined', async () => { + const service = new ImportCrittersStrategy(mockConnection, 1); + + const surveyAliasesStub = sinon.stub(service.surveyCritterService, 'getUniqueSurveyCritterAliases'); + const getValidTsnsStub = sinon.stub(service, '_getValidTsns'); + + surveyAliasesStub.resolves(new Set([])); + getValidTsnsStub.resolves(['1']); + + const rows = [ + { + ITIS_TSN: undefined, + SEX: 'Male', + ALIAS: 'Carl', + WLH_ID: '10-1000', + DESCRIPTION: 'A' + }, + { + ITIS_TSN: 3, + SEX: 'Male', + ALIAS: 'Carl2', + WLH_ID: '10-1000', + DESCRIPTION: 'A' + } + ]; + + const validation = await service.validateRows(rows, {}); + + if (validation.success) { + expect.fail(); + } else { + expect(validation.error.issues).to.deep.equal([ + { row: 0, message: `Invalid ITIS_TSN.` }, + { row: 1, message: `Invalid ITIS_TSN.` } + ]); + } + }); + + it('should return error if alias undefined, duplicate or exists in surve', async () => { + const service = new ImportCrittersStrategy(mockConnection, 1); + + const surveyAliasesStub = sinon.stub(service.surveyCritterService, 'getUniqueSurveyCritterAliases'); + const getValidTsnsStub = sinon.stub(service, '_getValidTsns'); + + surveyAliasesStub.resolves(new Set(['Carl3'])); + getValidTsnsStub.resolves(['1']); + + const rows = [ + { + ITIS_TSN: 1, + SEX: 'Male', + ALIAS: undefined, + WLH_ID: '10-1000', + DESCRIPTION: 'A' + }, + { + ITIS_TSN: 1, + SEX: 'Male', + ALIAS: 'Carl2', + WLH_ID: '10-1000', + DESCRIPTION: 'A' + }, + { + ITIS_TSN: 1, + SEX: 'Male', + ALIAS: 'Carl2', + WLH_ID: '10-1000', + DESCRIPTION: 'A' + }, + { + ITIS_TSN: 1, + SEX: 'Male', + ALIAS: 'Carl3', + WLH_ID: '10-1000', + DESCRIPTION: 'A' + } + ]; + + const validation = await service.validateRows(rows, {}); + + if (validation.success) { + expect.fail(); + } else { + expect(validation.error.issues).to.deep.equal([ + { row: 0, message: `Invalid ALIAS. Must be unique in Survey and CSV.` }, + { row: 1, message: `Invalid ALIAS. Must be unique in Survey and CSV.` }, + { row: 2, message: `Invalid ALIAS. Must be unique in Survey and CSV.` }, + { row: 3, message: `Invalid ALIAS. Must be unique in Survey and CSV.` } + ]); + } + }); + + it('should return error if collection unit invalid value', async () => { + const service = new ImportCrittersStrategy(mockConnection, 1); + + const surveyAliasesStub = sinon.stub(service.surveyCritterService, 'getUniqueSurveyCritterAliases'); + const getValidTsnsStub = sinon.stub(service, '_getValidTsns'); + const collectionMapStub = sinon.stub(service, '_getCollectionUnitMap'); + const getColumnsStub = sinon.stub(service, '_getNonStandardColumns'); + + surveyAliasesStub.resolves(new Set([])); + getValidTsnsStub.resolves(['1', '2']); + getColumnsStub.returns(['COLLECTION', 'HERD']); + collectionMapStub.resolves( + new Map([ + ['COLLECTION', { collectionUnits: collectionUnitsA, tsn: 1 }], + ['HERD', { collectionUnits: collectionUnitsB, tsn: 2 }] + ]) + ); + + const rows = [ + { + ITIS_TSN: 1, + SEX: 'Male', + ALIAS: 'Carl', + WLH_ID: '10-1000', + DESCRIPTION: 'A', + COLLECTION: 'UNIT_C' + }, + { + ITIS_TSN: 2, + SEX: 'Male', + ALIAS: 'Carl2', + WLH_ID: '10-1000', + DESCRIPTION: 'A', + COLLECTION: 'UNIT_A' + } + ]; + + const validation = await service.validateRows(rows, {}); + + if (validation.success) { + expect.fail(); + } else { + expect(validation.error.issues).to.deep.equal([ + { row: 0, message: `Invalid COLLECTION. Cell value is not valid.` }, + { row: 1, message: `Invalid COLLECTION. Cell value not allowed for TSN.` } + ]); + } + }); + }); +}); diff --git a/api/src/services/import-services/import-critters-service.ts b/api/src/services/import-services/critter/import-critters-strategy.ts similarity index 62% rename from api/src/services/import-services/import-critters-service.ts rename to api/src/services/import-services/critter/import-critters-strategy.ts index 0c7a97550c..dbe0cf7bf7 100644 --- a/api/src/services/import-services/import-critters-service.ts +++ b/api/src/services/import-services/critter/import-critters-strategy.ts @@ -1,37 +1,24 @@ -import { capitalize, keys, omit, toUpper, uniq } from 'lodash'; +import { keys, omit, startCase, toUpper, uniq } from 'lodash'; import { v4 as uuid } from 'uuid'; import { WorkSheet } from 'xlsx'; -import { IDBConnection } from '../../database/db'; -import { ApiGeneralError } from '../../errors/api-error'; -import { getLogger } from '../../utils/logger'; -import { MediaFile } from '../../utils/media/media-file'; -import { - critterStandardColumnValidator, - getAliasFromRow, - getColumnValidatorSpecification, - getDescriptionFromRow, - getSexFromRow, - getTsnFromRow, - getWlhIdFromRow -} from '../../utils/xlsx-utils/column-cell-utils'; -import { - constructXLSXWorkbook, - getDefaultWorksheet, - getNonStandardColumnNamesFromWorksheet, - getWorksheetRowObjects, - validateCsvFile -} from '../../utils/xlsx-utils/worksheet-utils'; +import { IDBConnection } from '../../../database/db'; +import { ApiGeneralError } from '../../../errors/api-error'; +import { getLogger } from '../../../utils/logger'; +import { CSV_COLUMN_ALIASES } from '../../../utils/xlsx-utils/column-aliases'; +import { generateColumnCellGetterFromColumnValidator } from '../../../utils/xlsx-utils/column-validator-utils'; +import { getNonStandardColumnNamesFromWorksheet, IXLSXCSVValidator } from '../../../utils/xlsx-utils/worksheet-utils'; import { CritterbaseService, IBulkCreate, ICollection, ICollectionUnitWithCategory, ICreateCritter -} from '../critterbase-service'; -import { DBService } from '../db-service'; -import { PlatformService } from '../platform-service'; -import { SurveyCritterService } from '../survey-critter-service'; -import { CsvCritter, PartialCsvCritter, Row, Validation, ValidationError } from './import-critters-service.interface'; +} from '../../critterbase-service'; +import { DBService } from '../../db-service'; +import { PlatformService } from '../../platform-service'; +import { SurveyCritterService } from '../../survey-critter-service'; +import { CSVImportStrategy, Row, Validation, ValidationError } from '../import-csv.interface'; +import { CsvCritter, PartialCsvCritter } from './import-critters-strategy.interface'; const defaultLog = getLogger('services/import/import-critters-service'); @@ -39,20 +26,46 @@ const CSV_CRITTER_SEX_OPTIONS = ['UNKNOWN', 'MALE', 'FEMALE', 'HERMAPHRODITIC']; /** * - * @class ImportCrittersService + * ImportCrittersStrategy - Injected into CSVImportStrategy as the CSV import dependency + * + * @example new CSVImportStrategy(new ImportCrittersStrategy(connection, surveyId)).import(file); + * + * @class ImportCrittersStrategy * @extends DBService * */ -export class ImportCrittersService extends DBService { +export class ImportCrittersStrategy extends DBService implements CSVImportStrategy { platformService: PlatformService; critterbaseService: CritterbaseService; surveyCritterService: SurveyCritterService; - _rows?: PartialCsvCritter[]; + surveyId: number; - constructor(connection: IDBConnection) { + /** + * An XLSX validation config for the standard columns of a Critter CSV. + * + * Note: `satisfies` allows `keyof` to correctly infer key types, while also + * enforcing uppercase object keys. + */ + columnValidator = { + ITIS_TSN: { type: 'number', aliases: CSV_COLUMN_ALIASES.ITIS_TSN }, + SEX: { type: 'string' }, + ALIAS: { type: 'string', aliases: CSV_COLUMN_ALIASES.ALIAS }, + WLH_ID: { type: 'string' }, + DESCRIPTION: { type: 'string', aliases: CSV_COLUMN_ALIASES.DESCRIPTION } + } satisfies IXLSXCSVValidator; + + /** + * Instantiates an instance of ImportCrittersStrategy + * + * @param {IDBConnection} connection - Database connection + * @param {number} surveyId - Survey identifier + */ + constructor(connection: IDBConnection, surveyId: number) { super(connection); + this.surveyId = surveyId; + this.platformService = new PlatformService(connection); this.surveyCritterService = new SurveyCritterService(connection); this.critterbaseService = new CritterbaseService({ @@ -61,16 +74,6 @@ export class ImportCrittersService extends DBService { }); } - /** - * Get the worksheet from the CSV file. - * - * @param {MediaFile} critterCsv - CSV MediaFile - * @returns {WorkSheet} Xlsx worksheet - */ - _getWorksheet(critterCsv: MediaFile) { - return getDefaultWorksheet(constructXLSXWorkbook(critterCsv)); - } - /** * Get non-standard columns (collection unit columns) from worksheet. * @@ -78,57 +81,7 @@ export class ImportCrittersService extends DBService { * @returns {string[]} Array of non-standard headers from CSV (worksheet) */ _getNonStandardColumns(worksheet: WorkSheet) { - return uniq(getNonStandardColumnNamesFromWorksheet(worksheet, critterStandardColumnValidator)); - } - /** - * Parse the CSV rows into the Critterbase critter format. - * - * @param {Row[]} rows - CSV rows - * @returns {PartialCsvCritter[]} CSV critters before validation - */ - _getCritterRowsToValidate(rows: Row[], collectionUnitColumns: string[]): PartialCsvCritter[] { - return rows.map((row) => { - // Standard critter properties from CSV - const standardCritterRow: PartialCsvCritter = { - critter_id: uuid(), // Generate a uuid for each critter for convienence - sex: getSexFromRow(row), - itis_tsn: getTsnFromRow(row), - wlh_id: getWlhIdFromRow(row), - animal_id: getAliasFromRow(row), - critter_comment: getDescriptionFromRow(row) - }; - - // All other properties must be collection units ie: `population unit` or `herd unit` etc... - collectionUnitColumns.forEach((categoryHeader) => { - standardCritterRow[categoryHeader] = row[categoryHeader]; - }); - - return standardCritterRow; - }); - } - - /** - * Get the critter rows from the xlsx worksheet. - * - * @param {WorkSheet} worksheet - * @returns {PartialCsvCritter[]} List of partial CSV Critters - */ - _getRows(worksheet: WorkSheet) { - // Attempt to retrieve from rows property to prevent unnecessary parsing - if (this._rows) { - return this._rows; - } - - // Convert the worksheet into an array of records - const worksheetRows = getWorksheetRowObjects(worksheet); - - // Get the collection unit columns (all non standard columns) - const collectionUnitColumns = this._getNonStandardColumns(worksheet); - - // Pre parse the records into partial critter rows - this._rows = this._getCritterRowsToValidate(worksheetRows, collectionUnitColumns); - - return this._rows; + return uniq(getNonStandardColumnNamesFromWorksheet(worksheet, this.columnValidator)); } /** @@ -140,7 +93,7 @@ export class ImportCrittersService extends DBService { _getCritterFromRow(row: CsvCritter): ICreateCritter { return { critter_id: row.critter_id, - sex: capitalize(row.sex), + sex: row.sex, itis_tsn: row.itis_tsn, animal_id: row.animal_id, wlh_id: row.wlh_id, @@ -158,7 +111,7 @@ export class ImportCrittersService extends DBService { const critterId = row.critter_id; // Get portion of row object that is not a critter - const partialRow = omit(row, keys(this._getCritterFromRow(row))); + const partialRow: { [key: string]: any } = omit(row, keys(this._getCritterFromRow(row))); // Keys of collection units const collectionUnitKeys = keys(partialRow); @@ -170,22 +123,19 @@ export class ImportCrittersService extends DBService { } /** - * Get a Set of valid ITIS TSNS from xlsx worksheet. + * Get a Set of valid ITIS TSNS from xlsx worksheet rows. * * @async - * @param {WorkSheet} worksheet - Xlsx Worksheet * @returns {Promise} Unique Set of valid TSNS from worksheet. */ - async _getValidTsns(worksheet: WorkSheet): Promise { - const rows = this._getRows(worksheet); - + async _getValidTsns(rows: PartialCsvCritter[]): Promise { // Get a unique list of tsns from worksheet const critterTsns = uniq(rows.map((row) => String(row.itis_tsn))); // Query the platform service (taxonomy) for matching tsns const taxonomy = await this.platformService.getTaxonomyByTsns(critterTsns); - return taxonomy.map((taxon) => taxon.tsn); + return taxonomy.map((taxon) => String(taxon.tsn)); } /** @@ -223,33 +173,63 @@ export class ImportCrittersService extends DBService { return collectionUnitMap; } + /** + * Parse the CSV rows into the Critterbase critter format. + * + * @param {Row[]} rows - CSV rows + * @param {string[]} collectionUnitColumns - Non standard columns + * @returns {PartialCsvCritter[]} CSV critters before validation + */ + _getRowsToValidate(rows: Row[], collectionUnitColumns: string[]): PartialCsvCritter[] { + const getColumnCell = generateColumnCellGetterFromColumnValidator(this.columnValidator); + + return rows.map((row) => { + // Standard critter properties from CSV + const standardCritterRow = { + critter_id: uuid(), // Generate a uuid for each critter for convienence + sex: getColumnCell(row, 'SEX').cell, + itis_tsn: getColumnCell(row, 'ITIS_TSN').cell, + wlh_id: getColumnCell(row, 'WLH_ID').cell, + animal_id: getColumnCell(row, 'ALIAS').cell, + critter_comment: getColumnCell(row, 'DESCRIPTION').cell + }; + + // All other properties must be collection units ie: `population unit` or `herd unit` etc... + collectionUnitColumns.forEach((categoryHeader) => { + standardCritterRow[categoryHeader] = row[categoryHeader]; + }); + + return standardCritterRow; + }); + } + /** * Validate CSV worksheet rows against reference data. * * @async - * @param {number} surveyId - Survey identifier + * @param {Row[]} rows - Invalidated CSV rows * @param {WorkSheet} worksheet - Xlsx worksheet - * @returns {Promise} Conditional validation object + * @returns {Promise>} Conditional validation object */ - async _validateRows(surveyId: number, worksheet: WorkSheet): Promise { - const rows = this._getRows(worksheet); + async validateRows(rows: Row[], worksheet: WorkSheet): Promise> { const nonStandardColumns = this._getNonStandardColumns(worksheet); + const rowsToValidate = this._getRowsToValidate(rows, nonStandardColumns); // Retrieve the dynamic validation config const [validRowTsns, surveyCritterAliases] = await Promise.all([ - this._getValidTsns(worksheet), - this.surveyCritterService.getUniqueSurveyCritterAliases(surveyId) + this._getValidTsns(rowsToValidate), + this.surveyCritterService.getUniqueSurveyCritterAliases(this.surveyId) ]); const collectionUnitMap = await this._getCollectionUnitMap(worksheet, validRowTsns); // Parse reference data for validation const tsnSet = new Set(validRowTsns.map((tsn) => Number(tsn))); - const csvCritterAliases = rows.map((row) => row.animal_id); + const csvCritterAliases = rowsToValidate.map((row) => row.animal_id); // Track the row validation errors const errors: ValidationError[] = []; - const csvCritters = rows.map((row, index) => { + const csvCritters = rowsToValidate.map((row, index) => { /** * -------------------------------------------------------------------- * STANDARD ROW VALIDATION @@ -281,6 +261,9 @@ export class ImportCrittersService extends DBService { errors.push({ row: index, message: `Invalid ALIAS. Must be unique in Survey and CSV.` }); } + // Covert `sex` to expected casing for Critterbase + row.sex = startCase(row.sex?.toLowerCase()); + /** * -------------------------------------------------------------------- * NON-STANDARD ROW VALIDATION @@ -294,7 +277,6 @@ export class ImportCrittersService extends DBService { delete row[column]; return; } - // Attempt to find the collection unit with the cell value from the mapping const collectionUnitMatch = collectionUnitColumn.collectionUnits.find( (unit) => unit.unit_name.toLowerCase() === String(row[column]).toLowerCase() @@ -320,51 +302,18 @@ export class ImportCrittersService extends DBService { return { success: true, data: csvCritters as CsvCritter[] }; } - return { success: false, errors }; - } - - /** - * Validate the worksheet contains no errors within the data or structure of CSV. - * - * @async - * @param {number} surveyId - Survey identifier - * @param {WorkSheet} worksheet - Xlsx worksheet - * @throws {ApiGeneralError} - If validation fails - * @returns {Promise} Validated CSV rows - */ - async _validate(surveyId: number, worksheet: WorkSheet): Promise { - // Validate the standard columns in the CSV file - if (!validateCsvFile(worksheet, critterStandardColumnValidator)) { - throw new ApiGeneralError(`Column validator failed. Column headers or cell data types are incorrect.`, [ - { column_specification: getColumnValidatorSpecification(critterStandardColumnValidator) }, - 'importCrittersService->_validate->validateCsvFile' - ]); - } - - // Validate the CSV rows with reference data - const validation = await this._validateRows(surveyId, worksheet); - - // Throw error is row validation failed and inject validation errors - if (!validation.success) { - throw new ApiGeneralError(`Failed to import Critter CSV. Column data validator failed.`, [ - { column_validation: validation.errors }, - 'importCrittersService->_validate->_validateRows' - ]); - } - - return validation.data; + return { success: false, error: { issues: errors } }; } /** * Insert CSV critters into Critterbase and SIMS. * * @async - * @param {number} surveyId - Survey identifier * @param {CsvCritter[]} critterRows - CSV row critters * @throws {ApiGeneralError} - If unable to fully insert records into Critterbase * @returns {Promise} List of inserted survey critter ids */ - async _insertCsvCrittersIntoSimsAndCritterbase(surveyId: number, critterRows: CsvCritter[]): Promise { + async insert(critterRows: CsvCritter[]): Promise { const simsPayload: string[] = []; const critterbasePayload: IBulkCreate = { critters: [], collections: [] }; @@ -384,31 +333,12 @@ export class ImportCrittersService extends DBService { // In reality this error should not be triggered, safeguard to prevent floating critter ids in SIMS if (bulkResponse.created.critters !== simsPayload.length) { throw new ApiGeneralError('Unable to fully import critters from CSV', [ - 'importCrittersService -> insertCsvCrittersIntoSimsAndCritterbase', + 'importCrittersStrategy -> insertCsvCrittersIntoSimsAndCritterbase', 'critterbase bulk create response count !== critterIds.length' ]); } // Add Critters to SIMS survey - return this.surveyCritterService.addCrittersToSurvey(surveyId, simsPayload); - } - - /** - * Import the CSV into SIMS and Critterbase. - * - * @async - * @param {number} surveyId - Survey identifier - * @param {MediaFile} critterCsv - CSV MediaFile - * @returns {Promise} List of survey critter identifiers - */ - async import(surveyId: number, critterCsv: MediaFile): Promise { - // Get the worksheet from the CSV - const worksheet = this._getWorksheet(critterCsv); - - // Validate the standard columns and the data of the CSV - const critters = await this._validate(surveyId, worksheet); - - // Insert the data into SIMS and Critterbase - return this._insertCsvCrittersIntoSimsAndCritterbase(surveyId, critters); + return this.surveyCritterService.addCrittersToSurvey(this.surveyId, simsPayload); } } diff --git a/api/src/services/import-services/import-critters-service.interface.ts b/api/src/services/import-services/import-critters-service.interface.ts deleted file mode 100644 index 0a99524824..0000000000 --- a/api/src/services/import-services/import-critters-service.interface.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Type wrapper for unknown CSV rows/records - * - */ -export type Row = Record; - -/** - * A validated CSV Critter object - * - */ -export type CsvCritter = { - critter_id: string; - sex: string; - itis_tsn: number; - animal_id: string; - wlh_id?: string; - critter_comment?: string; -} & { - [collectionUnitColumn: string]: unknown; -}; - -/** - * Invalidated CSV Critter object - * - */ -export type PartialCsvCritter = Partial & { critter_id: string }; - -export type ValidationError = { row: number; message: string }; - -/** - * Conditional validation type similar to Zod SafeParseReturn - * - */ -export type Validation = - | { - success: true; - data: CsvCritter[]; - } - | { - success: false; - errors: ValidationError[]; - }; diff --git a/api/src/services/import-services/import-critters-service.test.ts b/api/src/services/import-services/import-critters-service.test.ts deleted file mode 100644 index 338e347a55..0000000000 --- a/api/src/services/import-services/import-critters-service.test.ts +++ /dev/null @@ -1,783 +0,0 @@ -import chai, { expect } from 'chai'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { WorkSheet } from 'xlsx'; -import { MediaFile } from '../../utils/media/media-file'; -import { critterStandardColumnValidator } from '../../utils/xlsx-utils/column-cell-utils'; -import * as xlsxUtils from '../../utils/xlsx-utils/worksheet-utils'; -import { getMockDBConnection } from '../../__mocks__/db'; -import { IBulkCreateResponse } from '../critterbase-service'; -import { ImportCrittersService } from './import-critters-service'; -import { CsvCritter, PartialCsvCritter } from './import-critters-service.interface'; - -chai.use(sinonChai); - -const mockConnection = getMockDBConnection(); - -describe('ImportCrittersService', () => { - describe('_getCritterRowsToValidate', () => { - it('it should correctly format rows', () => { - const rows = [ - { - SEX: 'Male', - ITIS_TSN: 1, - WLH_ID: '10-1000', - ALIAS: 'Carl', - COMMENT: 'Test', - COLLECTION: 'Unit', - BAD_COLLECTION: 'Bad' - } - ]; - const service = new ImportCrittersService(mockConnection); - - const parsedRow = service._getCritterRowsToValidate(rows, ['COLLECTION', 'TEST'])[0]; - - expect(parsedRow.sex).to.be.eq('Male'); - expect(parsedRow.itis_tsn).to.be.eq(1); - expect(parsedRow.wlh_id).to.be.eq('10-1000'); - expect(parsedRow.animal_id).to.be.eq('Carl'); - expect(parsedRow.critter_comment).to.be.eq('Test'); - expect(parsedRow.COLLECTION).to.be.eq('Unit'); - expect(parsedRow.TEST).to.be.undefined; - expect(parsedRow.BAD_COLLECTION).to.be.undefined; - }); - }); - - describe('_getCritterFromRow', () => { - it('should get all critter properties', () => { - const row: any = { - critter_id: 'id', - sex: 'Male', - itis_tsn: 1, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment', - extra_property: 'test' - }; - const service = new ImportCrittersService(mockConnection); - - const critter = service._getCritterFromRow(row); - - expect(critter).to.be.eql({ - critter_id: 'id', - sex: 'Male', - itis_tsn: 1, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment' - }); - }); - }); - - describe('_getCollectionUnitsFromRow', () => { - it('should get all collection unit properties', () => { - const row: any = { - critter_id: 'id', - sex: 'Male', - itis_tsn: 1, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment', - COLLECTION: 'ID1', - HERD: 'ID2' - }; - const service = new ImportCrittersService(mockConnection); - - const collectionUnits = service._getCollectionUnitsFromRow(row); - - expect(collectionUnits).to.be.deep.equal([ - { collection_unit_id: 'ID1', critter_id: 'id' }, - { collection_unit_id: 'ID2', critter_id: 'id' } - ]); - }); - }); - - describe('_getValidTsns', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should return unique list of tsns', async () => { - const service = new ImportCrittersService(mockConnection); - - const mockWorksheet = {} as unknown as WorkSheet; - - const getRowsStub = sinon - .stub(service, '_getRows') - .returns([{ itis_tsn: 1 }, { itis_tsn: 2 }, { itis_tsn: 2 }] as any); - - const getTaxonomyStub = sinon.stub(service.platformService, 'getTaxonomyByTsns').resolves([ - { tsn: '1', scientificName: 'a' }, - { tsn: '2', scientificName: 'b' } - ]); - const tsns = await service._getValidTsns(mockWorksheet); - expect(getRowsStub).to.have.been.calledWith(mockWorksheet); - expect(getTaxonomyStub).to.have.been.calledWith(['1', '2']); - expect(tsns).to.deep.equal(['1', '2']); - }); - }); - - describe('_getCollectionUnitMap', () => { - afterEach(() => { - sinon.restore(); - }); - - const collectionUnitsA = [ - { - collection_unit_id: '1', - collection_category_id: '2', - category_name: 'COLLECTION', - unit_name: 'UNIT_A', - description: 'description' - }, - { - collection_unit_id: '2', - collection_category_id: '3', - category_name: 'COLLECTION', - unit_name: 'UNIT_B', - description: 'description' - } - ]; - - const collectionUnitsB = [ - { - collection_unit_id: '1', - collection_category_id: '2', - category_name: 'HERD', - unit_name: 'UNIT_A', - description: 'description' - }, - { - collection_unit_id: '2', - collection_category_id: '3', - category_name: 'HERD', - unit_name: 'UNIT_B', - description: 'description' - } - ]; - - it('should return collection unit mapping', async () => { - const service = new ImportCrittersService(mockConnection); - - const getColumnsStub = sinon.stub(service, '_getNonStandardColumns'); - const mockWorksheet = {} as unknown as WorkSheet; - - const findCollectionUnitsStub = sinon.stub(service.critterbaseService, 'findTaxonCollectionUnits'); - - getColumnsStub.returns(['COLLECTION', 'HERD']); - findCollectionUnitsStub.onCall(0).resolves(collectionUnitsA); - findCollectionUnitsStub.onCall(1).resolves(collectionUnitsB); - - const mapping = await service._getCollectionUnitMap(mockWorksheet, ['1', '2']); - expect(getColumnsStub).to.have.been.calledWith(mockWorksheet); - expect(findCollectionUnitsStub).to.have.been.calledTwice; - - expect(mapping).to.be.instanceof(Map); - expect(mapping.get('COLLECTION')).to.be.deep.equal({ collectionUnits: collectionUnitsA, tsn: 1 }); - expect(mapping.get('HERD')).to.be.deep.equal({ collectionUnits: collectionUnitsB, tsn: 2 }); - }); - - it('should return empty map when no collection unit columns', async () => { - const service = new ImportCrittersService(mockConnection); - - const getColumnsStub = sinon.stub(service, '_getNonStandardColumns'); - const mockWorksheet = {} as unknown as WorkSheet; - - const findCollectionUnitsStub = sinon.stub(service.critterbaseService, 'findTaxonCollectionUnits'); - getColumnsStub.returns([]); - - const mapping = await service._getCollectionUnitMap(mockWorksheet, ['1', '2']); - expect(getColumnsStub).to.have.been.calledWith(mockWorksheet); - expect(findCollectionUnitsStub).to.have.not.been.called; - - expect(mapping).to.be.instanceof(Map); - }); - }); - - describe('_insertCsvCrittersIntoSimsAndCritterbase', () => { - afterEach(() => { - sinon.restore(); - }); - - const critters: CsvCritter[] = [ - { - critter_id: '1', - sex: 'Male', - itis_tsn: 1, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment', - COLLECTION: 'Collection Unit' - }, - { - critter_id: '2', - sex: 'Female', - itis_tsn: 2, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment', - HERD: 'Herd Unit' - } - ]; - - it('should correctly parse collection units and critters and insert into sims / critterbase', async () => { - const service = new ImportCrittersService(mockConnection); - - const critterbaseBulkCreateStub = sinon.stub(service.critterbaseService, 'bulkCreate'); - const simsAddSurveyCrittersStub = sinon.stub(service.surveyCritterService, 'addCrittersToSurvey'); - - critterbaseBulkCreateStub.resolves({ created: { critters: 2, collections: 1 } } as IBulkCreateResponse); - simsAddSurveyCrittersStub.resolves([1]); - - const ids = await service._insertCsvCrittersIntoSimsAndCritterbase(1, critters); - - expect(critterbaseBulkCreateStub).to.have.been.calledWithExactly({ - critters: [ - { - critter_id: '1', - sex: 'Male', - itis_tsn: 1, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment' - }, - { - critter_id: '2', - sex: 'Female', - itis_tsn: 2, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment' - } - ], - collections: [ - { collection_unit_id: 'Collection Unit', critter_id: '1' }, - { collection_unit_id: 'Herd Unit', critter_id: '2' } - ] - }); - - expect(ids).to.be.deep.equal([1]); - }); - - it('should throw error if response from critterbase is less than provided critters', async () => { - const service = new ImportCrittersService(mockConnection); - - const critterbaseBulkCreateStub = sinon.stub(service.critterbaseService, 'bulkCreate'); - const simsAddSurveyCrittersStub = sinon.stub(service.surveyCritterService, 'addCrittersToSurvey'); - - critterbaseBulkCreateStub.resolves({ created: { critters: 1, collections: 1 } } as IBulkCreateResponse); - simsAddSurveyCrittersStub.resolves([1]); - - try { - await service._insertCsvCrittersIntoSimsAndCritterbase(1, critters); - expect.fail(); - } catch (err: any) { - expect(err.message).to.be.equal('Unable to fully import critters from CSV'); - } - - expect(simsAddSurveyCrittersStub).to.not.have.been.called; - }); - }); - - describe('_validateRows', () => { - afterEach(() => { - sinon.restore(); - }); - - const collectionUnitsA = [ - { - collection_unit_id: '1', - collection_category_id: '2', - category_name: 'COLLECTION', - unit_name: 'UNIT_A', - description: 'description' - }, - { - collection_unit_id: '2', - collection_category_id: '3', - category_name: 'COLLECTION', - unit_name: 'UNIT_B', - description: 'description' - } - ]; - - const collectionUnitsB = [ - { - collection_unit_id: '1', - collection_category_id: '2', - category_name: 'HERD', - unit_name: 'UNIT_A', - description: 'description' - }, - { - collection_unit_id: '2', - collection_category_id: '3', - category_name: 'HERD', - unit_name: 'UNIT_B', - description: 'description' - } - ]; - - it('should return successful', async () => { - const service = new ImportCrittersService(mockConnection); - const getRowsStub = sinon.stub(service, '_getRows'); - const getColumnsStub = sinon.stub(service, '_getNonStandardColumns'); - const surveyAliasesStub = sinon.stub(service.surveyCritterService, 'getUniqueSurveyCritterAliases'); - const getValidTsnsStub = sinon.stub(service, '_getValidTsns'); - const collectionMapStub = sinon.stub(service, '_getCollectionUnitMap'); - - getColumnsStub.returns(['COLLECTION', 'HERD']); - surveyAliasesStub.resolves(new Set(['Not Carl', 'Carlita'])); - getValidTsnsStub.resolves(['1', '2']); - collectionMapStub.resolves( - new Map([ - ['COLLECTION', { collectionUnits: collectionUnitsA, tsn: 1 }], - ['HERD', { collectionUnits: collectionUnitsB, tsn: 2 }] - ]) - ); - - const validRows: CsvCritter[] = [ - { - critter_id: 'A', - sex: 'Male', - itis_tsn: 1, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment', - COLLECTION: 'UNIT_A' - }, - { - critter_id: 'B', - sex: 'Male', - itis_tsn: 2, - animal_id: 'Test', - wlh_id: '10-1000', - critter_comment: 'comment', - HERD: 'UNIT_B' - } - ]; - - getRowsStub.returns(validRows); - - const validation = await service._validateRows(1, {} as WorkSheet); - - expect(validation.success).to.be.true; - - if (validation.success) { - expect(validation.data).to.be.deep.equal([ - { - critter_id: 'A', - sex: 'Male', - itis_tsn: 1, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment', - COLLECTION: '1' - }, - { - critter_id: 'B', - sex: 'Male', - itis_tsn: 2, - animal_id: 'Test', - wlh_id: '10-1000', - critter_comment: 'comment', - HERD: '2' - } - ]); - } - }); - - it('should push error when sex is undefined or invalid', async () => { - const service = new ImportCrittersService(mockConnection); - const getRowsStub = sinon.stub(service, '_getRows'); - const getColumnsStub = sinon.stub(service, '_getNonStandardColumns'); - const surveyAliasesStub = sinon.stub(service.surveyCritterService, 'getUniqueSurveyCritterAliases'); - const getValidTsnsStub = sinon.stub(service, '_getValidTsns'); - const collectionMapStub = sinon.stub(service, '_getCollectionUnitMap'); - - getColumnsStub.returns(['COLLECTION', 'HERD']); - surveyAliasesStub.resolves(new Set(['Not Carl', 'Carlita'])); - getValidTsnsStub.resolves(['1', '2']); - collectionMapStub.resolves( - new Map([ - ['COLLECTION', { collectionUnits: collectionUnitsA, tsn: 1 }], - ['HERD', { collectionUnits: collectionUnitsB, tsn: 2 }] - ]) - ); - - const invalidRows: PartialCsvCritter[] = [ - { - critter_id: 'A', - sex: undefined, - itis_tsn: 1, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment', - COLLECTION: 'UNIT_A' - }, - { - critter_id: 'A', - sex: 'Whoops' as any, - itis_tsn: 1, - animal_id: 'Carl2', - wlh_id: '10-1000', - critter_comment: 'comment', - COLLECTION: 'UNIT_A' - } - ]; - - getRowsStub.returns(invalidRows); - - const validation = await service._validateRows(1, {} as WorkSheet); - - expect(validation.success).to.be.false; - if (!validation.success) { - expect(validation.errors.length).to.be.eq(2); - expect(validation.errors).to.be.deep.equal([ - { - message: 'Invalid SEX. Expecting: UNKNOWN, MALE, FEMALE, HERMAPHRODITIC.', - row: 0 - }, - { - message: 'Invalid SEX. Expecting: UNKNOWN, MALE, FEMALE, HERMAPHRODITIC.', - row: 1 - } - ]); - } - }); - - it('should push error when wlh_id is invalid regex / shape', async () => { - const service = new ImportCrittersService(mockConnection); - const getRowsStub = sinon.stub(service, '_getRows'); - const getColumnsStub = sinon.stub(service, '_getNonStandardColumns'); - const surveyAliasesStub = sinon.stub(service.surveyCritterService, 'getUniqueSurveyCritterAliases'); - const getValidTsnsStub = sinon.stub(service, '_getValidTsns'); - const collectionMapStub = sinon.stub(service, '_getCollectionUnitMap'); - - getColumnsStub.returns(['COLLECTION', 'HERD']); - surveyAliasesStub.resolves(new Set(['Not Carl', 'Carlita'])); - getValidTsnsStub.resolves(['1', '2']); - collectionMapStub.resolves( - new Map([ - ['COLLECTION', { collectionUnits: collectionUnitsA, tsn: 1 }], - ['HERD', { collectionUnits: collectionUnitsB, tsn: 2 }] - ]) - ); - - const invalidRows: PartialCsvCritter[] = [ - { - critter_id: 'A', - sex: 'Male', - itis_tsn: 1, - animal_id: 'Carl', - wlh_id: '101000', - critter_comment: 'comment', - COLLECTION: 'UNIT_A' - }, - { - critter_id: 'A', - sex: 'Male', - itis_tsn: 1, - animal_id: 'Carl2', - wlh_id: '1-1000', - critter_comment: 'comment', - COLLECTION: 'UNIT_A' - } - ]; - - getRowsStub.returns(invalidRows); - - const validation = await service._validateRows(1, {} as WorkSheet); - - expect(validation.success).to.be.false; - if (!validation.success) { - expect(validation.errors.length).to.be.eq(2); - expect(validation.errors).to.be.deep.equal([ - { - message: `Invalid WLH_ID. Example format '10-1000R'.`, - row: 0 - }, - { - message: `Invalid WLH_ID. Example format '10-1000R'.`, - row: 1 - } - ]); - } - }); - - it('should push error when itis_tsn undefined or invalid option', async () => { - const service = new ImportCrittersService(mockConnection); - const getRowsStub = sinon.stub(service, '_getRows'); - const getColumnsStub = sinon.stub(service, '_getNonStandardColumns'); - const surveyAliasesStub = sinon.stub(service.surveyCritterService, 'getUniqueSurveyCritterAliases'); - const getValidTsnsStub = sinon.stub(service, '_getValidTsns'); - const collectionMapStub = sinon.stub(service, '_getCollectionUnitMap'); - - getColumnsStub.returns(['COLLECTION', 'HERD']); - surveyAliasesStub.resolves(new Set(['Not Carl', 'Carlita'])); - getValidTsnsStub.resolves(['1', '2']); - collectionMapStub.resolves( - new Map([ - ['COLLECTION', { collectionUnits: collectionUnitsA, tsn: 1 }], - ['HERD', { collectionUnits: collectionUnitsB, tsn: 2 }] - ]) - ); - - const invalidRows: PartialCsvCritter[] = [ - { - critter_id: 'A', - sex: 'Male', - itis_tsn: undefined, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment', - COLLECTION: 'UNIT_A' - }, - { - critter_id: 'A', - sex: 'Male', - itis_tsn: 10, - animal_id: 'Carl2', - wlh_id: '10-1000', - critter_comment: 'comment', - COLLECTION: 'UNIT_A' - } - ]; - - getRowsStub.returns(invalidRows); - - const validation = await service._validateRows(1, {} as WorkSheet); - - expect(validation.success).to.be.false; - if (!validation.success) { - expect(validation.errors.length).to.be.eq(4); - expect(validation.errors).to.be.deep.equal([ - { - message: `Invalid ITIS_TSN.`, - row: 0 - }, - { - message: `Invalid COLLECTION. Cell value not allowed for TSN.`, - row: 0 - }, - { - message: `Invalid ITIS_TSN.`, - row: 1 - }, - { - message: `Invalid COLLECTION. Cell value not allowed for TSN.`, - row: 1 - } - ]); - } - }); - - it('should push error when itis_tsn undefined or invalid option', async () => { - const service = new ImportCrittersService(mockConnection); - const getRowsStub = sinon.stub(service, '_getRows'); - const getColumnsStub = sinon.stub(service, '_getNonStandardColumns'); - const surveyAliasesStub = sinon.stub(service.surveyCritterService, 'getUniqueSurveyCritterAliases'); - const getValidTsnsStub = sinon.stub(service, '_getValidTsns'); - const collectionMapStub = sinon.stub(service, '_getCollectionUnitMap'); - - getColumnsStub.returns(['COLLECTION', 'HERD']); - surveyAliasesStub.resolves(new Set(['Not Carl', 'Carlita'])); - getValidTsnsStub.resolves(['1', '2']); - collectionMapStub.resolves( - new Map([ - ['COLLECTION', { collectionUnits: collectionUnitsA, tsn: 1 }], - ['HERD', { collectionUnits: collectionUnitsB, tsn: 2 }] - ]) - ); - - const invalidRows: PartialCsvCritter[] = [ - { - critter_id: 'A', - sex: 'Male', - itis_tsn: undefined, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment', - COLLECTION: 'UNIT_A' - }, - { - critter_id: 'A', - sex: 'Male', - itis_tsn: 10, - animal_id: 'Carl2', - wlh_id: '10-1000', - critter_comment: 'comment', - COLLECTION: 'UNIT_A' - } - ]; - - getRowsStub.returns(invalidRows); - - const validation = await service._validateRows(1, {} as WorkSheet); - - expect(validation.success).to.be.false; - if (!validation.success) { - expect(validation.errors.length).to.be.eq(4); - expect(validation.errors).to.be.deep.equal([ - { - message: `Invalid ITIS_TSN.`, - row: 0 - }, - { - message: `Invalid COLLECTION. Cell value not allowed for TSN.`, - row: 0 - }, - { - message: `Invalid ITIS_TSN.`, - row: 1 - }, - { - message: `Invalid COLLECTION. Cell value not allowed for TSN.`, - row: 1 - } - ]); - } - }); - - it('should push error if alias undefined, duplicate or exists in survey', async () => { - const service = new ImportCrittersService(mockConnection); - const getRowsStub = sinon.stub(service, '_getRows'); - const getColumnsStub = sinon.stub(service, '_getNonStandardColumns'); - const surveyAliasesStub = sinon.stub(service.surveyCritterService, 'getUniqueSurveyCritterAliases'); - const getValidTsnsStub = sinon.stub(service, '_getValidTsns'); - const collectionMapStub = sinon.stub(service, '_getCollectionUnitMap'); - - getColumnsStub.returns(['COLLECTION', 'HERD']); - surveyAliasesStub.resolves(new Set(['Not Carl', 'Carlita'])); - getValidTsnsStub.resolves(['1', '2']); - collectionMapStub.resolves( - new Map([ - ['COLLECTION', { collectionUnits: collectionUnitsA, tsn: 1 }], - ['HERD', { collectionUnits: collectionUnitsB, tsn: 2 }] - ]) - ); - - const invalidRows: PartialCsvCritter[] = [ - { - critter_id: 'A', - sex: 'Male', - itis_tsn: 1, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment', - COLLECTION: 'UNIT_A' - }, - { - critter_id: 'A', - sex: 'Male', - itis_tsn: 1, - animal_id: 'Carlita', - wlh_id: '10-1000', - critter_comment: 'comment', - COLLECTION: 'UNIT_A' - } - ]; - - getRowsStub.returns(invalidRows); - - const validation = await service._validateRows(1, {} as WorkSheet); - - expect(validation.success).to.be.false; - if (!validation.success) { - expect(validation.errors.length).to.be.eq(1); - expect(validation.errors).to.be.deep.equal([ - { - message: `Invalid ALIAS. Must be unique in Survey and CSV.`, - row: 1 - } - ]); - } - }); - }); - - describe('_validate', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should throw error when csv validation fails', async () => { - const validateCsvStub = sinon.stub(xlsxUtils, 'validateCsvFile').returns(false); - - const service = new ImportCrittersService(mockConnection); - - sinon.stub(service, '_validateRows'); - - try { - await service._validate(1, {} as WorkSheet); - expect.fail(); - } catch (err: any) { - expect(err.message).to.contain('Column validator failed.'); - } - expect(validateCsvStub).to.have.been.calledOnceWithExactly({}, critterStandardColumnValidator); - }); - - it('should call _validateRows if csv validation succeeds', async () => { - const validateCsvStub = sinon.stub(xlsxUtils, 'validateCsvFile'); - - const service = new ImportCrittersService(mockConnection); - - const validateRowsStub = sinon.stub(service, '_validateRows'); - - validateCsvStub.returns(true); - validateRowsStub.resolves({ success: true, data: [] }); - - const data = await service._validate(1, {} as WorkSheet); - expect(validateRowsStub).to.have.been.calledOnceWithExactly(1, {}); - expect(data).to.be.deep.equal([]); - }); - - it('should throw error if row validation fails', async () => { - const validateCsvStub = sinon.stub(xlsxUtils, 'validateCsvFile'); - - const service = new ImportCrittersService(mockConnection); - - const validateRowsStub = sinon.stub(service, '_validateRows'); - - validateCsvStub.returns(true); - validateRowsStub.resolves({ success: false, errors: [] }); - - try { - await service._validate(1, {} as WorkSheet); - - expect.fail(); - } catch (err: any) { - expect(err.message).to.contain('Failed to import Critter CSV.'); - } - expect(validateRowsStub).to.have.been.calledOnceWithExactly(1, {}); - }); - }); - - describe('import', () => { - it('should pass values to correct methods', async () => { - const service = new ImportCrittersService(mockConnection); - const csv = new MediaFile('file', 'mime', Buffer.alloc(1)); - - const critter: CsvCritter = { - critter_id: 'id', - sex: 'Male', - itis_tsn: 1, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment', - COLLECTION: 'Unit' - }; - - const getWorksheetStub = sinon.stub(service, '_getWorksheet').returns({} as unknown as WorkSheet); - const validateStub = sinon.stub(service, '_validate').resolves([critter]); - const insertStub = sinon.stub(service, '_insertCsvCrittersIntoSimsAndCritterbase').resolves([1]); - - const data = await service.import(1, csv); - - expect(getWorksheetStub).to.have.been.calledWithExactly(csv); - expect(validateStub).to.have.been.calledWithExactly(1, {}); - expect(insertStub).to.have.been.calledWithExactly(1, [critter]); - - expect(data).to.be.deep.equal([1]); - }); - }); -}); diff --git a/api/src/services/import-services/import-csv.interface.ts b/api/src/services/import-services/import-csv.interface.ts new file mode 100644 index 0000000000..6b843ab9e9 --- /dev/null +++ b/api/src/services/import-services/import-csv.interface.ts @@ -0,0 +1,102 @@ +import { WorkSheet } from 'xlsx'; +import { z } from 'zod'; +import { IXLSXCSVValidator } from '../../utils/xlsx-utils/worksheet-utils'; + +/** + * Type wrapper for unknown CSV rows/records + * + */ +export type Row = Record; + +/** + * Implementation for CSV Import Strategies. + * + * All CSV import strategies should implement this interface to be used with `CSVImport` function. + * + * Note: When implementing a strategy using this interface the generics will be inferred. + * + * @template ValidatedRow + * @template InsertReturn + */ +export interface CSVImportStrategy, InsertReturn = unknown> { + /** + * Standard column validator - used to validate the column headers and types. + * + * @see '../../utils/xlsx-utils/worksheet-utils.ts' + * @see '../../utils/xlsx-utils/column-cell-utils.ts' + */ + columnValidator: IXLSXCSVValidator; + + /** + * Pre parse the XLSX Worksheet before passing to `validateRows`. + * Optional method to implement if extra parsing needed for worksheet. + * + * @param {WorkSheet} worksheet - XLSX worksheet + * @returns {WorkSheet | Promise} + */ + preParseWorksheet?: (worksheet: WorkSheet) => WorkSheet | Promise; + + /** + * Validate the pre-parsed rows - return either custom Validation or Zod SafeParse. + * + * @param {Row[]} rows - Raw unparsed CSV rows + * @param {WorkSheet} [worksheet] - Xlsx worksheet - useful for calculating non-standard columns + * @returns {Promise | Validation>} Validation + */ + validateRows( + rows: Row[], + worksheet?: WorkSheet + ): Promise | Validation>; + + /** + * Insert the validated rows into database or send to external systems. + * + * @param {ValidatedRows[]} rows - Validated CSV rows + * @returns {Promise} + */ + insert(rows: ValidatedRow[]): Promise; +} + +/** + * CSV validation error + * + */ +export type ValidationError = { + /** + * CSV row index + * + */ + row: number; + /** + * CSV column header + * + */ + col?: string; + /** + * CSV row error message + * + */ + message: string; +}; + +/** + * Conditional validation type similar to Zod SafeParseReturn + * + */ +export type Validation = + /** + * On success (true) return the parsed CSV data + */ + | { + success: true; + data: T[]; + } + /** + * On failure (false) return the parsed CSV validation errors + */ + | { + success: false; + error: { + issues: ValidationError[]; + }; + }; diff --git a/api/src/services/import-services/import-csv.test.ts b/api/src/services/import-services/import-csv.test.ts new file mode 100644 index 0000000000..66955e21ee --- /dev/null +++ b/api/src/services/import-services/import-csv.test.ts @@ -0,0 +1,93 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { MediaFile } from '../../utils/media/media-file'; +import * as worksheetUtils from '../../utils/xlsx-utils/worksheet-utils'; +import { importCSV } from './import-csv'; +import { CSVImportStrategy } from './import-csv.interface'; + +chai.use(sinonChai); + +describe('importCSV', () => { + beforeEach(() => { + sinon.restore(); + }); + + it('should pass correct values through chain', async () => { + const mockCsv = new MediaFile('file', 'file', Buffer.from('')); + const mockWorksheet = {}; + + const importer: CSVImportStrategy = { + columnValidator: { ID: { type: 'string' } }, + validateRows: sinon.stub().resolves({ success: true, data: true }), + insert: sinon.stub().resolves(true) + }; + + const getWorksheetStub = sinon.stub(worksheetUtils, 'getDefaultWorksheet').returns(mockWorksheet); + const validateCsvFileStub = sinon.stub(worksheetUtils, 'validateCsvFile').returns(true); + + const data = await importCSV(mockCsv, importer); + + expect(getWorksheetStub).to.have.been.called.calledOnceWithExactly(worksheetUtils.constructXLSXWorkbook(mockCsv)); + expect(validateCsvFileStub).to.have.been.called.calledOnceWithExactly(mockWorksheet, importer.columnValidator); + expect(importer.insert).to.have.been.called.calledOnceWithExactly(true); + expect(data).to.be.true; + }); + + it('should throw error if column validator fails', async () => { + const mockCsv = new MediaFile('file', 'file', Buffer.from('')); + + const importer: CSVImportStrategy = { + columnValidator: { ID: { type: 'string' } }, + validateRows: sinon.stub().resolves({ success: true, data: true }), + insert: sinon.stub().resolves(true) + }; + + sinon.stub(worksheetUtils, 'validateCsvFile').returns(false); + + try { + await importCSV(mockCsv, importer); + + expect.fail(); + } catch (err: any) { + expect(err.message).to.be.eql(`Column validator failed. Column headers or cell data types are incorrect.`); + expect(err.errors[0]).to.be.eql({ + csv_column_errors: [ + { + columnName: 'ID', + columnType: 'string', + columnAliases: undefined, + optional: undefined + } + ] + }); + } + }); + + it('should throw error if import strategy validateRows fails', async () => { + const mockCsv = new MediaFile('file', 'file', Buffer.from('')); + const mockWorksheet = {}; + const mockValidation = { success: false, error: { issues: [{ row: 1, message: 'invalidated' }] } }; + + const importer: CSVImportStrategy = { + columnValidator: { ID: { type: 'string' } }, + validateRows: sinon.stub().returns(mockValidation), + insert: sinon.stub().resolves(true) + }; + + sinon.stub(worksheetUtils, 'getDefaultWorksheet').returns(mockWorksheet); + sinon.stub(worksheetUtils, 'validateCsvFile').returns(true); + + try { + await importCSV(mockCsv, importer); + expect.fail(); + } catch (err: any) { + expect(importer.validateRows).to.have.been.calledOnceWithExactly([], mockWorksheet); + expect(err.message).to.be.eql(`Failed to import Critter CSV. Column data validator failed.`); + expect(err.errors[0]).to.be.eql({ + csv_row_errors: mockValidation.error.issues + }); + } + }); +}); diff --git a/api/src/services/import-services/import-csv.ts b/api/src/services/import-services/import-csv.ts new file mode 100644 index 0000000000..6cee607e8b --- /dev/null +++ b/api/src/services/import-services/import-csv.ts @@ -0,0 +1,66 @@ +import { ApiGeneralError } from '../../errors/api-error'; +import { MediaFile } from '../../utils/media/media-file'; +import { getColumnValidatorSpecification } from '../../utils/xlsx-utils/column-validator-utils'; +import { + constructXLSXWorkbook, + getDefaultWorksheet, + getWorksheetRowObjects, + validateCsvFile +} from '../../utils/xlsx-utils/worksheet-utils'; +import { CSVImportStrategy } from './import-csv.interface'; + +/** + * Import CSV - Used with `CSVImportStrategy` classes. + * + * How to?: Inject a media-file and import strategy that implements `CSVImportStrategy`. + * + * Flow: + * 1. Get the worksheet from the CSV MediaFile - _getWorksheet + * 2. Validate the standard columns with the `importCsvStrategy` column validator - _validate -> validateCsvFile + * 3. Validate row data with import strategy - _validate -> importCsvService.validateRows + * 4. Insert the data into database or send to external system - import -> importCsvStrategy.insert + * + * @async + * @template ValidatedRow - Validated row object + * @template InsertReturn - Return type of the importer insert method + * @param {MediaFile} csvMediaFile - CSV converted to MediaFile + * @param {CSVImportStrategy} importer - Import strategy + * @throws {ApiGeneralError} - If validation fails + * @returns {Promise} Generic return type + */ +export const importCSV = async ( + csvMediaFile: MediaFile, + importer: CSVImportStrategy +) => { + const _worksheet = getDefaultWorksheet(constructXLSXWorkbook(csvMediaFile)); + + // Optionally pre-parse the worksheet before passing to validator + // Usefull if needing to mutate incomming worksheet data before validation ie: time columns + const worksheet = importer.preParseWorksheet ? importer.preParseWorksheet(_worksheet) : _worksheet; + + // Validate the standard columns in the CSV file + if (!validateCsvFile(worksheet, importer.columnValidator)) { + throw new ApiGeneralError(`Column validator failed. Column headers or cell data types are incorrect.`, [ + { csv_column_errors: getColumnValidatorSpecification(importer.columnValidator) }, + 'importCSV->_validate->validateCsvFile' + ]); + } + + // Convert the worksheet into an array of records + const worksheetRows = getWorksheetRowObjects(worksheet); + + // Validate the CSV rows with reference data + const validation = await importer.validateRows(worksheetRows, worksheet); + + // Throw error is row validation failed and inject validation errors + // The validation errors can be either custom (Validation) or Zod (SafeParseReturn) + if (!validation.success) { + throw new ApiGeneralError(`Failed to import Critter CSV. Column data validator failed.`, [ + { csv_row_errors: validation.error.issues }, + 'importCSV->_validate->_validateRows' + ]); + } + + // Insert the data or send to external systems + return importer.insert(validation.data); +}; diff --git a/api/src/services/import-services/marking/import-markings-strategy.interface.ts b/api/src/services/import-services/marking/import-markings-strategy.interface.ts new file mode 100644 index 0000000000..0917f011b9 --- /dev/null +++ b/api/src/services/import-services/marking/import-markings-strategy.interface.ts @@ -0,0 +1,79 @@ +import { z } from 'zod'; +import { IAsSelectLookup } from '../../critterbase-service'; + +/** + * Get CSV Marking schema. + * + * Note: This getter allows custom values to be injected for validation. + * + * Note: This could be updated to transform the string values into the primary keys + * to prevent Critterbase from having to translate / patch in incomming bulk values. + * + * @param {IAsSelectLookup[]} colours - Array of supported Critterbase colours + * @returns {*} Custom Zod schema for CSV Markings + */ +export const getCsvMarkingSchema = ( + colours: IAsSelectLookup[], + markingTypes: IAsSelectLookup[], + critterBodyLocationsMap: Map +) => { + const colourNames = colours.map((colour) => colour.value.toLowerCase()); + const markingTypeNames = markingTypes.map((markingType) => markingType.value.toLowerCase()); + + const coloursSet = new Set(colourNames); + const markingTypesSet = new Set(markingTypeNames); + + return z + .object({ + critter_id: z.string({ required_error: 'Unable to find matching survey critter with alias' }).uuid(), + capture_id: z.string({ required_error: 'Unable to find matching capture with date and time' }).uuid(), + body_location: z.string(), + marking_type: z + .string() + .refine( + (val) => markingTypesSet.has(val.toLowerCase()), + `Marking type not supported. Allowed values: ${markingTypeNames.join(', ')}` + ) + .optional(), + identifier: z.string().optional(), + primary_colour: z + .string() + .refine( + (val) => coloursSet.has(val.toLowerCase()), + `Colour not supported. Allowed values: ${colourNames.join(', ')}` + ) + .optional(), + secondary_colour: z + .string() + .refine( + (val) => coloursSet.has(val.toLowerCase()), + `Colour not supported. Allowed values: ${colourNames.join(', ')}` + ) + .optional(), + comment: z.string().optional() + }) + .superRefine((schema, ctx) => { + const bodyLocations = critterBodyLocationsMap.get(schema.critter_id); + if (!bodyLocations) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'No taxon body locations found for Critter' + }); + } else if ( + !bodyLocations.filter((location) => location.value.toLowerCase() === schema.body_location.toLowerCase()).length + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid body location for Critter. Allowed values: ${bodyLocations + .map((bodyLocation) => bodyLocation.value) + .join(', ')}` + }); + } + }); +}; + +/** + * A validated CSV Marking object + * + */ +export type CsvMarking = z.infer>; diff --git a/api/src/services/import-services/marking/import-markings-strategy.test.ts b/api/src/services/import-services/marking/import-markings-strategy.test.ts new file mode 100644 index 0000000000..4cfc6e192a --- /dev/null +++ b/api/src/services/import-services/marking/import-markings-strategy.test.ts @@ -0,0 +1,249 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { MediaFile } from '../../../utils/media/media-file'; +import * as worksheetUtils from '../../../utils/xlsx-utils/worksheet-utils'; +import { getMockDBConnection } from '../../../__mocks__/db'; +import { IBulkCreateResponse, ICritterDetailed } from '../../critterbase-service'; +import { importCSV } from '../import-csv'; +import { ImportMarkingsStrategy } from './import-markings-strategy'; +import { CsvMarking } from './import-markings-strategy.interface'; + +describe('ImportMarkingsStrategy', () => { + describe('importCSV marking worksheet', () => { + beforeEach(() => { + sinon.restore(); + }); + it('should validate successfully', async () => { + const worksheet = { + A1: { t: 's', v: 'CAPTURE_DATE' }, // testing order incorrect + B1: { t: 's', v: 'ALIAS' }, + C1: { t: 's', v: 'CAPTURE_TIME' }, + D1: { t: 's', v: 'BODY_LOCATION' }, + E1: { t: 's', v: 'MARKING_TYPE' }, + F1: { t: 's', v: 'IDENTIFIER' }, + G1: { t: 's', v: 'PRIMARY_COLOUR' }, + H1: { t: 's', v: 'SECONDARY_COLOUR' }, + I1: { t: 's', v: 'DESCRIPTION' }, // testing alias works + A2: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + B2: { t: 's', v: 'Carl' }, + C2: { t: 's', v: '10:10:12' }, + D2: { t: 's', v: 'Left ear' }, // testing case insensitivity + E2: { t: 's', v: 'Ear tag' }, + F2: { t: 's', v: 'asdfasdf' }, + G2: { t: 's', v: 'red' }, + H2: { t: 's', v: 'blue' }, + I2: { t: 's', v: 'tagged' }, + '!ref': 'A1:I2' + }; + + const mockDBConnection = getMockDBConnection(); + + const strategy = new ImportMarkingsStrategy(mockDBConnection, 1); + + const getDefaultWorksheetStub = sinon.stub(worksheetUtils, 'getDefaultWorksheet'); + const critterbaseInsertStub = sinon.stub(strategy.surveyCritterService.critterbaseService, 'bulkCreate'); + const aliasMapStub = sinon.stub(strategy.surveyCritterService, 'getSurveyCritterAliasMap'); + const colourStub = sinon.stub(strategy.surveyCritterService.critterbaseService, 'getColours'); + const markingTypeStub = sinon.stub(strategy.surveyCritterService.critterbaseService, 'getMarkingTypes'); + const taxonBodyLocationStub = sinon.stub(strategy, 'getTaxonBodyLocationsCritterIdMap'); + + colourStub.resolves([ + { id: 'A', key: 'colour', value: 'red' }, + { id: 'B', key: 'colour', value: 'blue' } + ]); + + markingTypeStub.resolves([ + { id: 'C', key: 'markingType', value: 'ear tag' }, + { id: 'D', key: 'markingType', value: 'nose band' } + ]); + + taxonBodyLocationStub.resolves( + new Map([ + ['3647cdc9-6fe9-4c32-acfa-6096fe123c4a', [{ id: 'D', key: 'bodylocation', value: 'left ear' }]], + ['4540d43a-7ced-4216-b49e-2a972d25dfdc', [{ id: 'E', key: 'bodylocation', value: 'tail' }]] + ]) + ); + + getDefaultWorksheetStub.returns(worksheet); + aliasMapStub.resolves( + new Map([ + [ + 'carl', + { + critter_id: '3647cdc9-6fe9-4c32-acfa-6096fe123c4a', + captures: [ + { + capture_id: '4647cdc9-6fe9-4c32-acfa-6096fe123c4a', + capture_date: '2024-10-10', + capture_time: '10:10:12' + } + ] + } as ICritterDetailed + ], + [ + 'carlita', + { + critter_id: '3647cdc9-6fe9-4c32-acfa-6096fe123c4a', + captures: [ + { + capture_id: '5647cdc9-6fe9-4c32-acfa-6096fe123c4a', + capture_date: '2024-10-10', + capture_time: '10:10:10' + } + ] + } as ICritterDetailed + ] + ]) + ); + critterbaseInsertStub.resolves({ created: { markings: 2 } } as IBulkCreateResponse); + + try { + const data = await importCSV(new MediaFile('test', 'test', 'test' as unknown as Buffer), strategy); + expect(data).to.deep.equal(2); + } catch (err: any) { + expect.fail(); + } + }); + }); + describe('getTaxonBodyLocationsCritterIdMap', () => { + it('should return a critter_id mapping of body locations', async () => { + const mockDBConnection = getMockDBConnection(); + const strategy = new ImportMarkingsStrategy(mockDBConnection, 1); + + const taxonBodyLocationsStub = sinon.stub( + strategy.surveyCritterService.critterbaseService, + 'getTaxonBodyLocations' + ); + const mockBodyLocationsA = [ + { id: 'A', key: 'column', value: 'Right Ear' }, + { id: 'B', key: 'column', value: 'Antlers' } + ]; + + const mockBodyLocationsB = [ + { id: 'C', key: 'column', value: 'Nose' }, + { id: 'D', key: 'column', value: 'Tail' } + ]; + + taxonBodyLocationsStub.onCall(0).resolves(mockBodyLocationsA); + taxonBodyLocationsStub.onCall(1).resolves(mockBodyLocationsB); + + const critterMap = await strategy.getTaxonBodyLocationsCritterIdMap([ + { critter_id: 'ACRITTER', itis_tsn: 1 }, + { critter_id: 'BCRITTER', itis_tsn: 2 }, + { critter_id: 'CCRITTER', itis_tsn: 2 } + ] as ICritterDetailed[]); + + expect(taxonBodyLocationsStub).to.have.been.calledTwice; + expect(taxonBodyLocationsStub.getCall(0).args[0]).to.be.eql('1'); + expect(taxonBodyLocationsStub.getCall(1).args[0]).to.be.eql('2'); + expect(critterMap).to.be.deep.equal( + new Map([ + ['ACRITTER', mockBodyLocationsA], + ['BCRITTER', mockBodyLocationsB], + ['CCRITTER', mockBodyLocationsB] + ]) + ); + }); + }); + + describe('validateRows', () => { + it('should validate the rows successfully', async () => { + const mockDBConnection = getMockDBConnection(); + const strategy = new ImportMarkingsStrategy(mockDBConnection, 1); + + const mockCritterA = { + critter_id: '4df8fd4c-4d7b-4142-8f03-92d8bf52d8cb', + itis_tsn: 1, + captures: [ + { capture_id: 'e9087545-5b1f-4b86-bf1d-a3372a7b33c7', capture_date: '10-10-2024', capture_time: '10:10:10' } + ] + } as ICritterDetailed; + + const mockCritterB = { + critter_id: '4540d43a-7ced-4216-b49e-2a972d25dfdc', + itis_tsn: 1, + captures: [ + { capture_id: '21f3c699-9017-455b-bd7d-49110ca4b586', capture_date: '10-10-2024', capture_time: '10:10:10' } + ] + } as ICritterDetailed; + + const aliasStub = sinon.stub(strategy.surveyCritterService, 'getSurveyCritterAliasMap'); + const colourStub = sinon.stub(strategy.surveyCritterService.critterbaseService, 'getColours'); + const markingTypeStub = sinon.stub(strategy.surveyCritterService.critterbaseService, 'getMarkingTypes'); + const taxonBodyLocationStub = sinon.stub(strategy, 'getTaxonBodyLocationsCritterIdMap'); + + aliasStub.resolves( + new Map([ + ['carl', mockCritterA], + ['carlita', mockCritterB] + ]) + ); + + colourStub.resolves([ + { id: 'A', key: 'colour', value: 'red' }, + { id: 'B', key: 'colour', value: 'blue' } + ]); + + markingTypeStub.resolves([ + { id: 'C', key: 'markingType', value: 'ear tag' }, + { id: 'D', key: 'markingType', value: 'nose band' } + ]); + + taxonBodyLocationStub.resolves( + new Map([ + ['4df8fd4c-4d7b-4142-8f03-92d8bf52d8cb', [{ id: 'D', key: 'bodylocation', value: 'ear' }]], + ['4540d43a-7ced-4216-b49e-2a972d25dfdc', [{ id: 'E', key: 'bodylocation', value: 'tail' }]] + ]) + ); + + const rows = [ + { + CAPTURE_DATE: '10-10-2024', + CAPTURE_TIME: '10:10:10', + ALIAS: 'carl', + BODY_LOCATION: 'Ear', + MARKING_TYPE: 'ear tag', + IDENTIFIER: 'identifier', + PRIMARY_COLOUR: 'Red', + SECONDARY_COLOUR: 'blue', + DESCRIPTION: 'comment' + } + ]; + + const validation = await strategy.validateRows(rows); + + if (!validation.success) { + expect.fail(); + } else { + expect(validation.success).to.be.true; + expect(validation.data).to.be.deep.equal([ + { + critter_id: '4df8fd4c-4d7b-4142-8f03-92d8bf52d8cb', + capture_id: 'e9087545-5b1f-4b86-bf1d-a3372a7b33c7', + body_location: 'Ear', + marking_type: 'ear tag', + identifier: 'identifier', + primary_colour: 'Red', + secondary_colour: 'blue', + comment: 'comment' + } + ]); + } + }); + }); + describe('insert', () => { + it('should return the count of inserted markings', async () => { + const mockDBConnection = getMockDBConnection(); + const strategy = new ImportMarkingsStrategy(mockDBConnection, 1); + + const bulkCreateStub = sinon.stub(strategy.surveyCritterService.critterbaseService, 'bulkCreate'); + + bulkCreateStub.resolves({ created: { markings: 1 } } as IBulkCreateResponse); + + const data = await strategy.insert([{ critter_id: 'id' } as unknown as CsvMarking]); + + expect(bulkCreateStub).to.have.been.calledWith({ markings: [{ critter_id: 'id' }] }); + expect(data).to.be.eql(1); + }); + }); +}); diff --git a/api/src/services/import-services/marking/import-markings-strategy.ts b/api/src/services/import-services/marking/import-markings-strategy.ts new file mode 100644 index 0000000000..97dbf52237 --- /dev/null +++ b/api/src/services/import-services/marking/import-markings-strategy.ts @@ -0,0 +1,170 @@ +import { z } from 'zod'; +import { IDBConnection } from '../../../database/db'; +import { getLogger } from '../../../utils/logger'; +import { CSV_COLUMN_ALIASES } from '../../../utils/xlsx-utils/column-aliases'; +import { generateColumnCellGetterFromColumnValidator } from '../../../utils/xlsx-utils/column-validator-utils'; +import { IXLSXCSVValidator } from '../../../utils/xlsx-utils/worksheet-utils'; +import { IAsSelectLookup, ICritterDetailed } from '../../critterbase-service'; +import { DBService } from '../../db-service'; +import { SurveyCritterService } from '../../survey-critter-service'; +import { CSVImportStrategy, Row } from '../import-csv.interface'; +import { findCapturesFromDateTime } from '../utils/datetime'; +import { CsvMarking, getCsvMarkingSchema } from './import-markings-strategy.interface'; + +const defaultLog = getLogger('services/import/import-markings-strategy'); + +/** + * + * @class ImportMarkingsStrategy + * @extends DBService + * @see CSVImport + * + */ +export class ImportMarkingsStrategy extends DBService implements CSVImportStrategy { + surveyCritterService: SurveyCritterService; + surveyId: number; + + /** + * An XLSX validation config for the standard columns of a Critterbase Marking CSV. + * + * Note: `satisfies` allows `keyof` to correctly infer keyof type, while also + * enforcing uppercase object keys. + */ + columnValidator = { + ALIAS: { type: 'string', aliases: CSV_COLUMN_ALIASES.ALIAS }, + CAPTURE_DATE: { type: 'date' }, + CAPTURE_TIME: { type: 'string', optional: true }, + BODY_LOCATION: { type: 'string', optional: true }, + MARKING_TYPE: { type: 'string', optional: true }, + IDENTIFIER: { type: 'string', optional: true }, + PRIMARY_COLOUR: { type: 'string', optional: true }, + SECONDARY_COLOUR: { type: 'string', optional: true }, + DESCRIPTION: { type: 'string', aliases: CSV_COLUMN_ALIASES.DESCRIPTION, optional: true } + } satisfies IXLSXCSVValidator; + + /** + * Construct an instance of ImportMarkingsStrategy. + * + * @param {IDBConnection} connection - DB connection + * @param {string} surveyId + */ + constructor(connection: IDBConnection, surveyId: number) { + super(connection); + + this.surveyId = surveyId; + + this.surveyCritterService = new SurveyCritterService(connection); + } + + /** + * Get taxon body locations Map from a list of Critters. + * + * @async + * @param {ICritterDetailed[]} critters - List of detailed critters + * @returns {Promise>} Critter id -> taxon body locations Map + */ + async getTaxonBodyLocationsCritterIdMap(critters: ICritterDetailed[]): Promise> { + const tsnBodyLocationsMap = new Map(); + const critterBodyLocationsMap = new Map(); + + const uniqueTsns = Array.from(new Set(critters.map((critter) => critter.itis_tsn))); + + // Only fetch body locations for unique tsns + const bodyLocations = await Promise.all( + uniqueTsns.map((tsn) => this.surveyCritterService.critterbaseService.getTaxonBodyLocations(String(tsn))) + ); + + // Loop through the flattened responses and set the body locations for each tsn + bodyLocations.flatMap((bodyLocationValues, idx) => { + tsnBodyLocationsMap.set(uniqueTsns[idx], bodyLocationValues); + }); + + // Now loop through the critters and assign the body locations to the critter id + for (const critter of critters) { + const tsnBodyLocations = tsnBodyLocationsMap.get(critter.itis_tsn); + if (tsnBodyLocations) { + critterBodyLocationsMap.set(critter.critter_id, tsnBodyLocations); + } + } + + return critterBodyLocationsMap; + } + + /** + * Validate the CSV rows against zod schema. + * + * @param {Row[]} rows - CSV rows + * @returns {*} + */ + async validateRows(rows: Row[]) { + // Generate type-safe cell getter from column validator + const getColumnCell = generateColumnCellGetterFromColumnValidator(this.columnValidator); + + // Get validation reference data + const [critterAliasMap, colours, markingTypes] = await Promise.all([ + this.surveyCritterService.getSurveyCritterAliasMap(this.surveyId), + this.surveyCritterService.critterbaseService.getColours(), + this.surveyCritterService.critterbaseService.getMarkingTypes() + ]); + + // Used to find critter_id -> taxon body location [] map + const rowCritters: ICritterDetailed[] = []; + + // Rows passed to validator + const rowsToValidate: Partial[] = []; + + for (const row of rows) { + let critterId, captureId; + + const alias = getColumnCell(row, 'ALIAS'); + + // If the alias is included attempt to retrieve the critter_id and capture_id for the row + if (alias.cell) { + const captureDate = getColumnCell(row, 'CAPTURE_DATE'); + const captureTime = getColumnCell(row, 'CAPTURE_TIME'); + + const critter = critterAliasMap.get(alias.cell.toLowerCase()); + + if (critter) { + // Find the capture_id from the date time columns + const captures = findCapturesFromDateTime(critter.captures, captureDate.cell, captureTime.cell); + captureId = captures.length === 1 ? captures[0].capture_id : undefined; + critterId = critter.critter_id; + rowCritters.push(critter); + } + } + + rowsToValidate.push({ + critter_id: critterId, // Found using alias + capture_id: captureId, // Found using capture date and time + body_location: getColumnCell(row, 'BODY_LOCATION').cell, + marking_type: getColumnCell(row, 'MARKING_TYPE').cell, + identifier: getColumnCell(row, 'IDENTIFIER').cell, + primary_colour: getColumnCell(row, 'PRIMARY_COLOUR').cell, + secondary_colour: getColumnCell(row, 'SECONDARY_COLOUR').cell, + comment: getColumnCell(row, 'DESCRIPTION').cell + }); + } + // Get the critter_id -> taxonBodyLocations[] Map + const critterBodyLocationsMap = await this.getTaxonBodyLocationsCritterIdMap(rowCritters); + + // Generate the zod schema with injected reference values + // This allows the zod schema to validate against Critterbase lookup values + return z.array(getCsvMarkingSchema(colours, markingTypes, critterBodyLocationsMap)).safeParseAsync(rowsToValidate); + } + + /** + * Insert markings into Critterbase. + * + * @async + * @param {CsvCapture[]} markings - List of CSV markings to create + * @returns {Promise} Number of created markings + */ + async insert(markings: CsvMarking[]): Promise { + const response = await this.surveyCritterService.critterbaseService.bulkCreate({ markings }); + + defaultLog.debug({ label: 'import markings', markings, insertedCount: response.created.markings }); + + return response.created.markings; + } +} diff --git a/api/src/services/import-services/measurement/import-measurements-strategy.interface.ts b/api/src/services/import-services/measurement/import-measurements-strategy.interface.ts new file mode 100644 index 0000000000..2cfabf887f --- /dev/null +++ b/api/src/services/import-services/measurement/import-measurements-strategy.interface.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +export const CsvQualitativeMeasurementSchema = z.object({ + critter_id: z.string().uuid(), + capture_id: z.string().uuid(), + taxon_measurement_id: z.string().uuid(), + qualitative_option_id: z.string().uuid() +}); + +export const CsvQuantitativeMeasurementSchema = z.object({ + critter_id: z.string().uuid(), + capture_id: z.string().uuid(), + taxon_measurement_id: z.string().uuid(), + value: z.number() +}); + +export const CsvMeasurementSchema = CsvQualitativeMeasurementSchema.or(CsvQuantitativeMeasurementSchema); + +// Zod inferred types +export type CsvMeasurement = z.infer; +export type CsvQuantitativeMeasurement = z.infer; +export type CsvQualitativeMeasurement = z.infer; diff --git a/api/src/services/import-services/measurement/import-measurements-strategy.test.ts b/api/src/services/import-services/measurement/import-measurements-strategy.test.ts new file mode 100644 index 0000000000..9503e0569c --- /dev/null +++ b/api/src/services/import-services/measurement/import-measurements-strategy.test.ts @@ -0,0 +1,650 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { MediaFile } from '../../../utils/media/media-file'; +import * as worksheetUtils from '../../../utils/xlsx-utils/worksheet-utils'; +import { getMockDBConnection } from '../../../__mocks__/db'; +import { + CBQualitativeMeasurementTypeDefinition, + CBQuantitativeMeasurementTypeDefinition, + IBulkCreateResponse +} from '../../critterbase-service'; +import { importCSV } from '../import-csv'; +import { ImportMeasurementsStrategy } from './import-measurements-strategy'; + +describe('importMeasurementsStrategy', () => { + describe('importCSV', () => { + beforeEach(() => { + sinon.restore(); + }); + + it('should import the csv file correctly', async () => { + const worksheet = { + A1: { t: 's', v: 'ALIAS' }, + B1: { t: 's', v: 'CAPTURE_DATE' }, + C1: { t: 's', v: 'CAPTURE_TIME' }, + D1: { t: 's', v: 'tail length' }, + E1: { t: 's', v: 'skull condition' }, + F1: { t: 's', v: 'unknown' }, + A2: { t: 's', v: 'carl' }, + B2: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + C2: { t: 's', v: '10:10:12' }, + D2: { t: 'n', w: '2', v: 2 }, + E2: { t: 'n', w: '0', v: 'good' }, + A3: { t: 's', v: 'carlita' }, + B3: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + C3: { t: 's', v: '10:10:12' }, + D3: { t: 'n', w: '2', v: 2 }, + E3: { t: 'n', w: '0', v: 'good' }, + '!ref': 'A1:F3' + }; + + const conn = getMockDBConnection(); + const strategy = new ImportMeasurementsStrategy(conn, 1); + + const getDefaultWorksheetStub = sinon.stub(worksheetUtils, 'getDefaultWorksheet'); + const critterbaseInsertStub = sinon.stub(strategy.surveyCritterService.critterbaseService, 'bulkCreate'); + const nonStandardColumnsStub = sinon.stub(strategy, '_getNonStandardColumns'); + const critterAliasMapStub = sinon.stub(strategy.surveyCritterService, 'getSurveyCritterAliasMap'); + const getTsnMeasurementMapStub = sinon.stub(strategy, '_getTsnsMeasurementMap'); + + const critterAliasMap = new Map([ + [ + 'carl', + { + critter_id: 'A', + animal_id: 'carl', + itis_tsn: 'tsn1', + captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:12' }] + } as any + ], + [ + 'carlita', + { + critter_id: 'B', + animal_id: 'carlita', + itis_tsn: 'tsn2', + captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:12' }] + } as any + ] + ]); + + getDefaultWorksheetStub.returns(worksheet); + nonStandardColumnsStub.returns(['TAIL LENGTH', 'SKULL CONDITION']); + critterAliasMapStub.resolves(critterAliasMap); + critterbaseInsertStub.resolves({ + created: { qualitative_measurements: 1, quantitative_measurements: 1 } + } as IBulkCreateResponse); + getTsnMeasurementMapStub.resolves( + new Map([ + [ + 'tsn1', + { + qualitative: [ + { + taxon_measurement_id: 'Z', + measurement_name: 'skull condition', + options: [{ qualitative_option_id: 'C', option_label: 'good' }] + } + ], + quantitative: [ + { taxon_measurement_id: 'Z', measurement_name: 'tail length', min_value: 0, max_value: 10 } + ] + } as any + ], + [ + 'tsn2', + { + qualitative: [ + { + taxon_measurement_id: 'Z', + measurement_name: 'skull condition', + + options: [{ qualitative_option_id: 'C', option_label: 'good' }] + } + ], + quantitative: [ + { taxon_measurement_id: 'Z', measurement_name: 'tail length', min_value: 0, max_value: 10 } + ] + } as any + ] + ]) + ); + + try { + const data = await importCSV(new MediaFile('test', 'test', 'test' as unknown as Buffer), strategy); + expect(data).to.be.eql(2); + expect(critterbaseInsertStub).to.have.been.calledOnceWithExactly({ + qualitative_measurements: [ + { + critter_id: 'A', + capture_id: 'B', + taxon_measurement_id: 'Z', + qualitative_option_id: 'C' + }, + { + critter_id: 'B', + capture_id: 'B', + taxon_measurement_id: 'Z', + qualitative_option_id: 'C' + } + ], + quantitative_measurements: [ + { + critter_id: 'A', + capture_id: 'B', + taxon_measurement_id: 'Z', + value: 2 + }, + { + critter_id: 'B', + capture_id: 'B', + taxon_measurement_id: 'Z', + value: 2 + } + ] + }); + } catch (e: any) { + expect.fail(); + } + }); + }); + describe('_getTsnsMeasurementMap', () => { + it('should return correct taxon measurement mapping', async () => { + const conn = getMockDBConnection(); + const strategy = new ImportMeasurementsStrategy(conn, 1); + + const getTaxonMeasurementsStub = sinon.stub( + strategy.surveyCritterService.critterbaseService, + 'getTaxonMeasurements' + ); + + const measurementA: any = { qualitative: [{ tsn: 'tsn1', measurement: 'measurement1' }], quantitative: [] }; + const measurementB: any = { quantitative: [{ tsn: 'tsn2', measurement: 'measurement2', qualitative: [] }] }; + + getTaxonMeasurementsStub.onCall(0).resolves(measurementA); + + getTaxonMeasurementsStub.onCall(1).resolves(measurementB); + + const tsns = ['tsn1', 'tsn2', 'tsn2']; + + const result = await strategy._getTsnsMeasurementMap(tsns); + + const expectedResult = new Map([ + ['tsn1', measurementA], + ['tsn2', measurementB] + ]); + + expect(result).to.be.deep.equal(expectedResult); + }); + }); + + describe('_getRowMeta', () => { + it('should return correct row meta', () => { + const conn = getMockDBConnection(); + const strategy = new ImportMeasurementsStrategy(conn, 1); + + const row = { ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10' }; + const critterAliasMap = new Map([ + [ + 'alias', + { + critter_id: 'A', + animal_id: 'alias', + itis_tsn: 'tsn1', + captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:10' }] + } as any + ] + ]); + + const result = strategy._getRowMeta(row, critterAliasMap); + + expect(result).to.be.deep.equal({ critter_id: 'A', tsn: 'tsn1', capture_id: 'B' }); + }); + + it('should return all undefined properties if unable to match critter', () => { + const conn = getMockDBConnection(); + const strategy = new ImportMeasurementsStrategy(conn, 1); + + const row = { ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10' }; + const critterAliasMap = new Map([ + [ + 'alias2', + { + critter_id: 'A', + animal_id: 'alias2', + itis_tsn: 'tsn1', + captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:10' }] + } as any + ] + ]); + + const result = strategy._getRowMeta(row, critterAliasMap); + + expect(result).to.be.deep.equal({ critter_id: undefined, tsn: undefined, capture_id: undefined }); + }); + + it('should undefined capture_id if unable to match timestamps', () => { + const conn = getMockDBConnection(); + const strategy = new ImportMeasurementsStrategy(conn, 1); + + const row = { ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10' }; + const critterAliasMap = new Map([ + [ + 'alias', + { + critter_id: 'A', + animal_id: 'alias', + itis_tsn: 'tsn1', + captures: [{ capture_id: 'B', capture_date: '11/11/2024', capture_time: '10:10:10' }] + } as any + ] + ]); + + const result = strategy._getRowMeta(row, critterAliasMap); + + expect(result).to.be.deep.equal({ critter_id: 'A', tsn: 'tsn1', capture_id: undefined }); + }); + }); + + describe('_validateQualitativeMeasurementCell', () => { + it('should return option_id when valid', async () => { + const conn = getMockDBConnection(); + const strategy = new ImportMeasurementsStrategy(conn, 1); + + const measurement: CBQualitativeMeasurementTypeDefinition = { + itis_tsn: 1, + taxon_measurement_id: 'A', + measurement_name: 'measurement', + measurement_desc: null, + options: [{ qualitative_option_id: 'C', option_label: 'measurement', option_value: 0, option_desc: 'desc' }] + }; + + const result = strategy._validateQualitativeMeasurementCell('measurement', measurement); + + expect(result.error).to.be.undefined; + expect(result.optionId).to.be.equal('C'); + }); + + it('should return error when invalid value', async () => { + const conn = getMockDBConnection(); + const strategy = new ImportMeasurementsStrategy(conn, 1); + + const measurement: CBQualitativeMeasurementTypeDefinition = { + itis_tsn: 1, + taxon_measurement_id: 'A', + measurement_name: 'measurement', + measurement_desc: null, + options: [{ qualitative_option_id: 'C', option_label: 'measurement', option_value: 0, option_desc: 'desc' }] + }; + + const result = strategy._validateQualitativeMeasurementCell('bad', measurement); + + expect(result.error).to.exist; + expect(result.optionId).to.be.undefined; + }); + }); + + describe('_validateQuantitativeMeasurementCell', () => { + it('should return value when valid', async () => { + const conn = getMockDBConnection(); + const strategy = new ImportMeasurementsStrategy(conn, 1); + + const measurement: CBQuantitativeMeasurementTypeDefinition = { + itis_tsn: 1, + taxon_measurement_id: 'A', + measurement_name: 'measurement', + unit: 'centimeter', + min_value: 0, + max_value: 10, + measurement_desc: null + }; + + const resultA = strategy._validateQuantitativeMeasurementCell(0, measurement); + + expect(resultA.error).to.be.undefined; + expect(resultA.value).to.be.equal(0); + + const resultB = strategy._validateQuantitativeMeasurementCell(10, measurement); + + expect(resultB.error).to.be.undefined; + expect(resultB.value).to.be.equal(10); + }); + + it('should return error when invalid value', async () => { + const conn = getMockDBConnection(); + const strategy = new ImportMeasurementsStrategy(conn, 1); + + const measurement: CBQuantitativeMeasurementTypeDefinition = { + itis_tsn: 1, + taxon_measurement_id: 'A', + measurement_name: 'measurement', + unit: 'centimeter', + min_value: 0, + max_value: 10, + measurement_desc: null + }; + + const resultA = strategy._validateQuantitativeMeasurementCell(-1, measurement); + + expect(resultA.error).to.exist; + expect(resultA.value).to.be.undefined; + + const resultB = strategy._validateQuantitativeMeasurementCell(11, measurement); + + expect(resultB.error).to.exist; + expect(resultB.value).to.be.undefined; + }); + }); + + describe('_validateMeasurementCell', () => { + const critterAliasMap = new Map([ + [ + 'alias', + { + critter_id: 'A', + animal_id: 'alias', + itis_tsn: 'tsn1', + captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:10' }] + } as any + ] + ]); + + it('should return no errors and data when valid rows', async () => { + const conn = getMockDBConnection(); + const strategy = new ImportMeasurementsStrategy(conn, 1); + + const nonStandardColumnsStub = sinon.stub(strategy, '_getNonStandardColumns'); + const critterAliasMapStub = sinon.stub(strategy.surveyCritterService, 'getSurveyCritterAliasMap'); + const getRowMetaStub = sinon.stub(strategy, '_getRowMeta'); + const getTsnMeasurementMapStub = sinon.stub(strategy, '_getTsnsMeasurementMap'); + const validateQualitativeMeasurementCellStub = sinon.stub(strategy, '_validateQualitativeMeasurementCell'); + + nonStandardColumnsStub.returns(['MEASUREMENT']); + critterAliasMapStub.resolves(critterAliasMap); + getRowMetaStub.returns({ critter_id: 'A', tsn: 'tsn1', capture_id: 'B' }); + getTsnMeasurementMapStub.resolves( + new Map([ + [ + 'tsn1', + { qualitative: [{ taxon_measurement_id: 'Z', measurement_name: 'measurement' }], quantitative: [] } as any + ] + ]) + ); + validateQualitativeMeasurementCellStub.returns({ error: undefined, optionId: 'C' }); + + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + + const result = await strategy.validateRows(rows, {}); + + if (!result.success) { + expect.fail(); + } else { + expect(result.data).to.be.deep.equal([ + { critter_id: 'A', capture_id: 'B', taxon_measurement_id: 'Z', qualitative_option_id: 'C' } + ]); + } + }); + + it('should return error when unable to map alias to critter', async () => { + const conn = getMockDBConnection(); + const strategy = new ImportMeasurementsStrategy(conn, 1); + + const nonStandardColumnsStub = sinon.stub(strategy, '_getNonStandardColumns'); + const critterAliasMapStub = sinon.stub(strategy.surveyCritterService, 'getSurveyCritterAliasMap'); + const getRowMetaStub = sinon.stub(strategy, '_getRowMeta'); + const getTsnMeasurementMapStub = sinon.stub(strategy, '_getTsnsMeasurementMap'); + const validateQualitativeMeasurementCellStub = sinon.stub(strategy, '_validateQualitativeMeasurementCell'); + + nonStandardColumnsStub.returns(['MEASUREMENT']); + critterAliasMapStub.resolves(critterAliasMap); + getRowMetaStub.returns({ critter_id: undefined, tsn: undefined, capture_id: undefined }); + getTsnMeasurementMapStub.resolves( + new Map([ + [ + 'tsn1', + { qualitative: [{ taxon_measurement_id: 'Z', measurement_name: 'measurement' }], quantitative: [] } as any + ] + ]) + ); + validateQualitativeMeasurementCellStub.returns({ error: undefined, optionId: 'C' }); + + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + + const result = await strategy.validateRows(rows, {}); + + if (!result.success) { + expect(result.error.issues).to.be.deep.equal([ + { row: 0, message: 'Unable to find matching Critter with alias.' } + ]); + } else { + expect.fail(); + } + }); + + it('should return error when unable to map capture to critter', async () => { + const conn = getMockDBConnection(); + const strategy = new ImportMeasurementsStrategy(conn, 1); + + const nonStandardColumnsStub = sinon.stub(strategy, '_getNonStandardColumns'); + const critterAliasMapStub = sinon.stub(strategy.surveyCritterService, 'getSurveyCritterAliasMap'); + const getRowMetaStub = sinon.stub(strategy, '_getRowMeta'); + const getTsnMeasurementMapStub = sinon.stub(strategy, '_getTsnsMeasurementMap'); + const validateQualitativeMeasurementCellStub = sinon.stub(strategy, '_validateQualitativeMeasurementCell'); + + nonStandardColumnsStub.returns(['MEASUREMENT']); + critterAliasMapStub.resolves(critterAliasMap); + getRowMetaStub.returns({ critter_id: 'A', tsn: 'tsn1', capture_id: undefined }); + getTsnMeasurementMapStub.resolves( + new Map([ + [ + 'tsn1', + { qualitative: [{ taxon_measurement_id: 'Z', measurement_name: 'measurement' }], quantitative: [] } as any + ] + ]) + ); + validateQualitativeMeasurementCellStub.returns({ error: undefined, optionId: 'C' }); + + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + + const result = await strategy.validateRows(rows, {}); + + if (!result.success) { + expect(result.error.issues).to.be.deep.equal([ + { row: 0, message: 'Unable to find matching Capture with date and time.' } + ]); + } else { + expect.fail(); + } + }); + + it('should return error when unable to map tsn to critter', async () => { + const conn = getMockDBConnection(); + const strategy = new ImportMeasurementsStrategy(conn, 1); + + const nonStandardColumnsStub = sinon.stub(strategy, '_getNonStandardColumns'); + const critterAliasMapStub = sinon.stub(strategy.surveyCritterService, 'getSurveyCritterAliasMap'); + const getRowMetaStub = sinon.stub(strategy, '_getRowMeta'); + const getTsnMeasurementMapStub = sinon.stub(strategy, '_getTsnsMeasurementMap'); + const validateQualitativeMeasurementCellStub = sinon.stub(strategy, '_validateQualitativeMeasurementCell'); + + nonStandardColumnsStub.returns(['MEASUREMENT']); + critterAliasMapStub.resolves(critterAliasMap); + getRowMetaStub.returns({ critter_id: 'A', tsn: undefined, capture_id: 'C' }); + getTsnMeasurementMapStub.resolves( + new Map([ + [ + 'tsn1', + { qualitative: [{ taxon_measurement_id: 'Z', measurement_name: 'measurement' }], quantitative: [] } as any + ] + ]) + ); + validateQualitativeMeasurementCellStub.returns({ error: undefined, optionId: 'C' }); + + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + + const result = await strategy.validateRows(rows, {}); + + if (!result.success) { + expect(result.error.issues).to.be.deep.equal([{ row: 0, message: 'Unable to find ITIS TSN for Critter.' }]); + } else { + expect.fail(); + } + }); + + it('should return error when qualitative measurement validation fails', async () => { + const conn = getMockDBConnection(); + const strategy = new ImportMeasurementsStrategy(conn, 1); + + const nonStandardColumnsStub = sinon.stub(strategy, '_getNonStandardColumns'); + const critterAliasMapStub = sinon.stub(strategy.surveyCritterService, 'getSurveyCritterAliasMap'); + const getRowMetaStub = sinon.stub(strategy, '_getRowMeta'); + const getTsnMeasurementMapStub = sinon.stub(strategy, '_getTsnsMeasurementMap'); + const validateQualitativeMeasurementCellStub = sinon.stub(strategy, '_validateQualitativeMeasurementCell'); + + nonStandardColumnsStub.returns(['MEASUREMENT']); + critterAliasMapStub.resolves(critterAliasMap); + getRowMetaStub.returns({ critter_id: 'A', tsn: 'tsn1', capture_id: 'C' }); + getTsnMeasurementMapStub.resolves( + new Map([ + [ + 'tsn1', + { qualitative: [{ taxon_measurement_id: 'Z', measurement_name: 'measurement' }], quantitative: [] } as any + ] + ]) + ); + validateQualitativeMeasurementCellStub.returns({ error: 'qualitative failed', optionId: undefined }); + + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + + const result = await strategy.validateRows(rows, {}); + + if (!result.success) { + expect(result.error.issues).to.be.deep.equal([{ row: 0, col: 'MEASUREMENT', message: 'qualitative failed' }]); + } else { + expect.fail(); + } + }); + + it('should return error when quantitative measurement validation fails', async () => { + const conn = getMockDBConnection(); + const strategy = new ImportMeasurementsStrategy(conn, 1); + + const nonStandardColumnsStub = sinon.stub(strategy, '_getNonStandardColumns'); + const critterAliasMapStub = sinon.stub(strategy.surveyCritterService, 'getSurveyCritterAliasMap'); + const getRowMetaStub = sinon.stub(strategy, '_getRowMeta'); + const getTsnMeasurementMapStub = sinon.stub(strategy, '_getTsnsMeasurementMap'); + const validateQuantitativeMeasurementCellStub = sinon.stub(strategy, '_validateQuantitativeMeasurementCell'); + + nonStandardColumnsStub.returns(['MEASUREMENT']); + critterAliasMapStub.resolves(critterAliasMap); + getRowMetaStub.returns({ critter_id: 'A', tsn: 'tsn1', capture_id: 'C' }); + getTsnMeasurementMapStub.resolves( + new Map([ + [ + 'tsn1', + { quantitative: [{ taxon_measurement_id: 'Z', measurement_name: 'measurement' }], qualitative: [] } as any + ] + ]) + ); + validateQuantitativeMeasurementCellStub.returns({ error: 'quantitative failed', value: undefined }); + + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + + const result = await strategy.validateRows(rows, {}); + + if (!result.success) { + expect(result.error.issues).to.be.deep.equal([{ row: 0, col: 'MEASUREMENT', message: 'quantitative failed' }]); + } else { + expect.fail(); + } + }); + + it('should return error when no measurements exist for taxon', async () => { + const conn = getMockDBConnection(); + const strategy = new ImportMeasurementsStrategy(conn, 1); + + const nonStandardColumnsStub = sinon.stub(strategy, '_getNonStandardColumns'); + const critterAliasMapStub = sinon.stub(strategy.surveyCritterService, 'getSurveyCritterAliasMap'); + const getRowMetaStub = sinon.stub(strategy, '_getRowMeta'); + const getTsnMeasurementMapStub = sinon.stub(strategy, '_getTsnsMeasurementMap'); + + nonStandardColumnsStub.returns(['MEASUREMENT']); + critterAliasMapStub.resolves(critterAliasMap); + getRowMetaStub.returns({ critter_id: 'A', tsn: 'tsn1', capture_id: 'C' }); + getTsnMeasurementMapStub.resolves(new Map([['tsn1', { quantitative: [], qualitative: [] } as any]])); + + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + + const result = await strategy.validateRows(rows, {}); + + if (!result.success) { + expect(result.error.issues).to.be.deep.equal([ + { row: 0, col: 'MEASUREMENT', message: 'No measurements exist for this taxon.' } + ]); + } else { + expect.fail(); + } + }); + + it('should return error when no measurements exist for taxon', async () => { + const conn = getMockDBConnection(); + const strategy = new ImportMeasurementsStrategy(conn, 1); + + const nonStandardColumnsStub = sinon.stub(strategy, '_getNonStandardColumns'); + const critterAliasMapStub = sinon.stub(strategy.surveyCritterService, 'getSurveyCritterAliasMap'); + const getRowMetaStub = sinon.stub(strategy, '_getRowMeta'); + const getTsnMeasurementMapStub = sinon.stub(strategy, '_getTsnsMeasurementMap'); + + nonStandardColumnsStub.returns(['MEASUREMENT']); + critterAliasMapStub.resolves(critterAliasMap); + getRowMetaStub.returns({ critter_id: 'A', tsn: 'tsn1', capture_id: 'C' }); + getTsnMeasurementMapStub.resolves( + new Map([ + [ + 'tsn1', + { quantitative: [{ measurement_name: 'notfound' }], qualitative: [{ measurement_name: 'notfound' }] } as any + ] + ]) + ); + + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + + const result = await strategy.validateRows(rows, {}); + + if (!result.success) { + expect(result.error.issues).to.be.deep.equal([ + { row: 0, col: 'MEASUREMENT', message: 'Unable to match column name to an existing measurement.' } + ]); + } else { + expect.fail(); + } + }); + }); + describe('insert', () => { + it('should correctly format the insert payload for critterbase bulk insert', async () => { + const conn = getMockDBConnection(); + const strategy = new ImportMeasurementsStrategy(conn, 1); + + const bulkCreateStub = sinon.stub(strategy.surveyCritterService.critterbaseService, 'bulkCreate'); + + const rows = [ + { critter_id: 'A', capture_id: 'B', taxon_measurement_id: 'C', qualitative_option_id: 'D' }, + { critter_id: 'E', capture_id: 'F', taxon_measurement_id: 'G', value: 0 } + ]; + + bulkCreateStub.resolves({ + created: { qualitative_measurements: 1, quantitative_measurements: 1 } + } as IBulkCreateResponse); + + const result = await strategy.insert(rows); + + expect(bulkCreateStub).to.have.been.calledOnceWithExactly({ + qualitative_measurements: [ + { critter_id: 'A', capture_id: 'B', taxon_measurement_id: 'C', qualitative_option_id: 'D' } + ], + quantitative_measurements: [{ critter_id: 'E', capture_id: 'F', taxon_measurement_id: 'G', value: 0 }] + }); + expect(result).to.be.eql(2); + }); + }); +}); diff --git a/api/src/services/import-services/measurement/import-measurements-strategy.ts b/api/src/services/import-services/measurement/import-measurements-strategy.ts new file mode 100644 index 0000000000..d39e69f86c --- /dev/null +++ b/api/src/services/import-services/measurement/import-measurements-strategy.ts @@ -0,0 +1,344 @@ +import { uniq } from 'lodash'; +import { WorkSheet } from 'xlsx'; +import { IDBConnection } from '../../../database/db'; +import { getLogger } from '../../../utils/logger'; +import { CSV_COLUMN_ALIASES } from '../../../utils/xlsx-utils/column-aliases'; +import { generateColumnCellGetterFromColumnValidator } from '../../../utils/xlsx-utils/column-validator-utils'; +import { getNonStandardColumnNamesFromWorksheet, IXLSXCSVValidator } from '../../../utils/xlsx-utils/worksheet-utils'; +import { + CBQualitativeMeasurementTypeDefinition, + CBQuantitativeMeasurementTypeDefinition, + ICritterDetailed +} from '../../critterbase-service'; +import { DBService } from '../../db-service'; +import { SurveyCritterService } from '../../survey-critter-service'; +import { CSVImportStrategy, Row, Validation, ValidationError } from '../import-csv.interface'; +import { findCapturesFromDateTime } from '../utils/datetime'; +import { + CsvMeasurement, + CsvQualitativeMeasurement, + CsvQuantitativeMeasurement +} from './import-measurements-strategy.interface'; + +const defaultLog = getLogger('services/import/import-measurements-strategy'); + +/** + * + * ImportMeasurementsStrategy - Injected into importCSV as the CSV import dependency + * + * @example new CSVImport(new ImportMeasurementsStrategy(connection, surveyId)).import(file); + * + * @class ImportMeasurementsStrategy + * @extends DBService + * + */ +export class ImportMeasurementsStrategy extends DBService implements CSVImportStrategy { + surveyCritterService: SurveyCritterService; + + surveyId: number; + + /** + * An XLSX validation config for the standard columns of a Measurement CSV. + * + * Note: `satisfies` allows `keyof` to correctly infer key types, while also + * enforcing uppercase object keys. + */ + columnValidator = { + ALIAS: { type: 'string', aliases: CSV_COLUMN_ALIASES.ALIAS }, + CAPTURE_DATE: { type: 'date' }, + CAPTURE_TIME: { type: 'string', optional: true } + } satisfies IXLSXCSVValidator; + + /** + * Instantiates an instance of ImportMeasurementsStrategy + * + * @param {IDBConnection} connection - Database connection + * @param {number} surveyId - Survey identifier + */ + constructor(connection: IDBConnection, surveyId: number) { + super(connection); + + this.surveyId = surveyId; + + this.surveyCritterService = new SurveyCritterService(connection); + } + + /** + * Get non-standard columns (measurement columns) from worksheet. + * + * @param {WorkSheet} worksheet - Xlsx worksheet + * @returns {string[]} Array of non-standard headers from CSV (worksheet) + */ + _getNonStandardColumns(worksheet: WorkSheet) { + return uniq(getNonStandardColumnNamesFromWorksheet(worksheet, this.columnValidator)); + } + + /** + * Get TSN measurement map for validation. + * + * For a list of TSNS return all measurements inherited or directly assigned. + * + * @async + * @param {string[]} tsns - List of ITIS TSN's + * @returns {*} + */ + async _getTsnsMeasurementMap(tsns: string[]) { + const tsnMeasurementMap = new Map< + string, + { + qualitative: CBQualitativeMeasurementTypeDefinition[]; + quantitative: CBQuantitativeMeasurementTypeDefinition[]; + } + >(); + + const uniqueTsns = [...new Set(tsns)]; + + const measurements = await Promise.all( + uniqueTsns.map((tsn) => this.surveyCritterService.critterbaseService.getTaxonMeasurements(tsn)) + ); + + uniqueTsns.forEach((tsn, index) => { + tsnMeasurementMap.set(tsn, measurements[index]); + }); + + return tsnMeasurementMap; + } + + /** + * Get row meta data for validation. + * + * @param {Row} row - CSV row + * @param {Map} critterAliasMap - Survey critter alias mapping + * @returns {{ capture_id?: string; critter_id?: string; tsn?: string }} + */ + _getRowMeta( + row: Row, + critterAliasMap: Map + ): { capture_id?: string; critter_id?: string; tsn?: string } { + const getColumnCell = generateColumnCellGetterFromColumnValidator(this.columnValidator); + + const alias = getColumnCell(row, 'ALIAS'); + const captureDate = getColumnCell(row, 'CAPTURE_DATE'); + const captureTime = getColumnCell(row, 'CAPTURE_TIME'); + + let capture_id, critter_id, tsn; + + if (alias.cell) { + const critter = critterAliasMap.get(alias.cell.toLowerCase()); + + if (critter) { + const captures = findCapturesFromDateTime(critter.captures, captureDate.cell, captureTime.cell); + critter_id = critter.critter_id; + capture_id = captures.length === 1 ? captures[0].capture_id : undefined; + tsn = String(critter.itis_tsn); // Cast to string for convienience + } + } + + return { critter_id, capture_id, tsn }; + } + + /** + * Validate qualitative measurement. + * + * @param {string} cell - CSV measurement cell value + * @param {CBQualitativeMeasurementTypeDefinition} measurement - Found qualitative measurement match + * @returns {*} + */ + _validateQualitativeMeasurementCell(cell: string, measurement: CBQualitativeMeasurementTypeDefinition) { + if (typeof cell !== 'string') { + return { error: 'Qualitative measurement expecting text value.', optionId: undefined }; + } + + const matchingOptionValue = measurement.options.find( + (option) => option.option_label.toLowerCase() === cell.toLowerCase() + ); + + // Validate cell value is an alowed qualitative measurement option + if (!matchingOptionValue) { + return { + error: `Incorrect qualitative measurement value. Allowed: ${measurement.options.map((option) => + option.option_label.toLowerCase() + )}`, + optionId: undefined + }; + } + + return { error: undefined, optionId: matchingOptionValue.qualitative_option_id }; + } + + /** + * Validate quantitative measurement + * + * @param {number} cell - CSV measurement cell value + * @param {CBQuantitativeMeasurementTypeDefinition} measurement - Found quantitative measurement match + * @returns {*} + */ + _validateQuantitativeMeasurementCell(cell: number, measurement: CBQuantitativeMeasurementTypeDefinition) { + if (typeof cell !== 'number') { + return { error: 'Quantitative measurement expecting number value.', value: undefined }; + } + + // Validate cell value is withing the measurement min max bounds + if (measurement.max_value != null && cell > measurement.max_value) { + return { error: 'Quantitative measurement out of bounds. Too small.', value: undefined }; + } + + if (measurement.min_value != null && cell < measurement.min_value) { + return { error: 'Quantitative measurement out of bounds. Too small.' }; + } + + return { error: undefined, value: cell }; + } + + /** + * Validate CSV worksheet rows against reference data. + * + * Note: This function is longer than I would like, but moving logic into seperate methods + * made the flow more complex and equally as long. + * + * @async + * @param {Row[]} rows - Invalidated CSV rows + * @param {WorkSheet} worksheet - Xlsx worksheet + * @returns {*} + */ + async validateRows(rows: Row[], worksheet: WorkSheet): Promise> { + // Generate type-safe cell getter from column validator + const nonStandardColumns = this._getNonStandardColumns(worksheet); + + // Get Critterbase reference data + const critterAliasMap = await this.surveyCritterService.getSurveyCritterAliasMap(this.surveyId); + const rowTsns = rows.map((row) => this._getRowMeta(row, critterAliasMap).tsn).filter(Boolean) as string[]; + const tsnMeasurementsMap = await this._getTsnsMeasurementMap(rowTsns); + + const rowErrors: ValidationError[] = []; + const validatedRows: CsvMeasurement[] = []; + + rows.forEach((row, index) => { + const { critter_id, capture_id, tsn } = this._getRowMeta(row, critterAliasMap); + + // Validate critter can be matched via alias + if (!critter_id) { + rowErrors.push({ row: index, message: 'Unable to find matching Critter with alias.' }); + return; + } + + // Validate capture can be matched with date and time + if (!capture_id) { + rowErrors.push({ row: index, message: 'Unable to find matching Capture with date and time.' }); + return; + } + + // This will only be triggered with an invalid alias + if (!tsn) { + rowErrors.push({ row: index, message: 'Unable to find ITIS TSN for Critter.' }); + return; + } + + // Loop through all non-standard (measurement) columns + for (const column of nonStandardColumns) { + // Get the cell value from the row (case insensitive) + const cellValue = row[column] ?? row[column.toLowerCase()] ?? row[column.toUpperCase()]; + + // If the cell value is null or undefined - skip validation + if (cellValue == null) { + continue; + } + + const measurements = tsnMeasurementsMap.get(tsn); + + // Validate taxon has reference measurements in Critterbase + if (!measurements || (!measurements.quantitative.length && !measurements.qualitative.length)) { + rowErrors.push({ row: index, col: column, message: 'No measurements exist for this taxon.' }); + continue; + } + + const qualitativeMeasurement = measurements?.qualitative.find( + (measurement) => measurement.measurement_name.toLowerCase() === column.toLowerCase() + ); + + // QUALITATIVE MEASUREMENT VALIDATION + if (qualitativeMeasurement) { + const { error, optionId } = this._validateQualitativeMeasurementCell(cellValue, qualitativeMeasurement); + + if (error !== undefined) { + rowErrors.push({ row: index, col: column, message: error }); + } else { + // Assign qualitative measurement to validated rows + validatedRows.push({ + critter_id, + capture_id, + taxon_measurement_id: qualitativeMeasurement.taxon_measurement_id, + qualitative_option_id: optionId + }); + } + + continue; + } + + const quantitativeMeasurement = measurements?.quantitative.find( + (measurement) => measurement.measurement_name.toLowerCase() === column.toLowerCase() + ); + + // QUANTITATIVE MEASUREMENT VALIDATION + if (quantitativeMeasurement) { + const { error, value } = this._validateQuantitativeMeasurementCell(cellValue, quantitativeMeasurement); + + if (error !== undefined) { + rowErrors.push({ row: index, col: column, message: error }); + } else { + // Assign quantitative measurement to validated rows + validatedRows.push({ + critter_id, + capture_id, + taxon_measurement_id: quantitativeMeasurement.taxon_measurement_id, + value: value + }); + } + + continue; + } + + // Validate the column header is a known Critterbase measurement + rowErrors.push({ + row: index, + col: column, + message: 'Unable to match column name to an existing measurement.' + }); + } + }); + + if (!rowErrors.length) { + return { success: true, data: validatedRows }; + } + + return { success: false, error: { issues: rowErrors } }; + } + + /** + * Insert CSV measurements into Critterbase. + * + * @async + * @param {CsvCritter[]} measurements - CSV row measurements + * @returns {Promise} List of inserted measurements + */ + async insert(measurements: CsvMeasurement[]): Promise { + const qualitative_measurements = measurements.filter( + (measurement): measurement is CsvQualitativeMeasurement => 'qualitative_option_id' in measurement + ); + + const quantitative_measurements = measurements.filter( + (measurement): measurement is CsvQuantitativeMeasurement => 'value' in measurement + ); + + const response = await this.surveyCritterService.critterbaseService.bulkCreate({ + qualitative_measurements, + quantitative_measurements + }); + + const measurementCount = response.created.qualitative_measurements + response.created.quantitative_measurements; + + defaultLog.debug({ label: 'import measurements', measurements, insertedCount: measurementCount }); + + return measurementCount; + } +} diff --git a/api/src/services/import-services/utils/datetime.test.ts b/api/src/services/import-services/utils/datetime.test.ts new file mode 100644 index 0000000000..24e0452a13 --- /dev/null +++ b/api/src/services/import-services/utils/datetime.test.ts @@ -0,0 +1,38 @@ +import { expect } from 'chai'; +import { areDatesEqual, formatTimeString } from './datetime'; + +describe('formatTimeString', () => { + it('should correctly prepend leading 0 for 24 hour time', () => { + expect(formatTimeString('9:10:10')).to.be.eql('09:10:10'); + }); + + it('should correctly append 00 for missing seconds', () => { + expect(formatTimeString('10:10')).to.be.eql('10:10:00'); + }); + + it('should correctly append 00 for missing seconds and prepend 0 for 24 hour time', () => { + expect(formatTimeString('9:10')).to.be.eql('09:10:00'); + }); + + it('should return undefined if cannot format time', () => { + expect(formatTimeString('BLAH')).to.be.undefined; + }); + + it('should return undefined if dates are null', () => { + expect(formatTimeString(null)).to.be.undefined; + expect(formatTimeString(undefined)).to.be.undefined; + }); +}); + +describe('areDatesEqual', () => { + it('should be true when dates are equal in all formats', () => { + expect(areDatesEqual('10-10-2024', '10-10-2024')).to.be.true; + expect(areDatesEqual('10-10-2024', '10/10/2024')).to.be.true; + expect(areDatesEqual('10-10-2024', '10/10/24')).to.be.true; + expect(areDatesEqual('10-10-2024', '2024-10-10')).to.be.true; + }); + + it('should fail if dates are incorrect format', () => { + expect(areDatesEqual('BAD DATE BAD', '10/10/2024')).to.be.false; + }); +}); diff --git a/api/src/services/import-services/utils/datetime.ts b/api/src/services/import-services/utils/datetime.ts new file mode 100644 index 0000000000..8ab52447e4 --- /dev/null +++ b/api/src/services/import-services/utils/datetime.ts @@ -0,0 +1,70 @@ +import dayjs from 'dayjs'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; + +dayjs.extend(customParseFormat); + +// Simplified Capture interface +interface ICaptureStub { + capture_id: string; + capture_date: string; + capture_time?: string | null; +} + +/** + * Format time strings to correct format for Zod and external systems. + * + * `10:10` -> `10:10:00` appends seconds if missing + * `9:10:10` -> `09:10:10` prepends `0` for 24 hour time + * `9:10` -> `09:10:00` does both + * + * @param {string} [time] - Time string + * @returns {string | undefined} + */ +export const formatTimeString = (time?: string | null): string | undefined => { + const fullTime = dayjs(time, 'HH:mm:ss'); + const shortTime = dayjs(time, 'HH:mm'); + + if (fullTime.isValid()) { + return fullTime.format('HH:mm:ss'); + } + + if (shortTime.isValid()) { + return shortTime.format('HH:mm:ss'); + } +}; + +/** + * Checks if two date strings are equal. + * + * Note: This will attempt to unify the formatting between the dates. + * ie: 2024-01-01 === 01-01-2024 + * + * @param {string} _dateA - Date string + * @param {string} _dateB - Date string + * @returns {string | undefined} + */ +export const areDatesEqual = (_dateA: string, _dateB: string): boolean => { + return dayjs(_dateA).isSame(dayjs(_dateB)); +}; + +/** + * Find Captures from Capture date and time fields. + * + * @template {T} Capture stub + * @param {ICapture[]} captures - Array of Critterbase captures + * @param {string} captureDate - String date + * @param {string} captureTime - String time + * @returns {T[]} Capture ID or undefined + */ +export const findCapturesFromDateTime = ( + captures: T[], + captureDate: string, + captureTime: string +): T[] => { + return captures.filter((capture) => { + return ( + formatTimeString(capture.capture_time) === formatTimeString(captureTime) && + areDatesEqual(capture.capture_date, captureDate) + ); + }); +}; diff --git a/api/src/services/observation-service.test.ts b/api/src/services/observation-service.test.ts index 157c613664..245df3f0b2 100644 --- a/api/src/services/observation-service.test.ts +++ b/api/src/services/observation-service.test.ts @@ -186,4 +186,25 @@ describe('ObservationService', () => { expect(response).to.eql(mockObservationCount); }); }); + + describe('getObservedSpeciesForSurvey', () => { + it('Gets the species observed in a survey', async () => { + const mockDBConnection = getMockDBConnection(); + + const mockTsns = [1, 2, 3]; + const surveyId = 1; + const mockSpecies = mockTsns.map((tsn) => ({ itis_tsn: tsn })); + + const getObservedSpeciesForSurveyStub = sinon + .stub(ObservationRepository.prototype, 'getObservedSpeciesForSurvey') + .resolves(mockSpecies); + + const observationService = new ObservationService(mockDBConnection); + + const response = await observationService.getObservedSpeciesForSurvey(surveyId); + + expect(getObservedSpeciesForSurveyStub).to.be.calledOnceWith(surveyId); + expect(response).to.eql(mockSpecies); + }); + }); }); diff --git a/api/src/services/observation-service.ts b/api/src/services/observation-service.ts index 5c0a660d26..3d44a837bd 100644 --- a/api/src/services/observation-service.ts +++ b/api/src/services/observation-service.ts @@ -7,6 +7,7 @@ import { ObservationRecord, ObservationRecordWithSamplingAndSubcountData, ObservationRepository, + ObservationSpecies, ObservationSubmissionRecord, UpdateObservation } from '../repositories/observation-repository/observation-repository'; @@ -41,20 +42,14 @@ import { TsnMeasurementTypeDefinitionMap, validateMeasurements } from '../utils/observation-xlsx-utils/measurement-column-utils'; -import { - getCountFromRow, - getDateFromRow, - getLatitudeFromRow, - getLongitudeFromRow, - getTimeFromRow, - getTsnFromRow, - observationStandardColumnValidator -} from '../utils/xlsx-utils/column-cell-utils'; +import { CSV_COLUMN_ALIASES } from '../utils/xlsx-utils/column-aliases'; +import { generateColumnCellGetterFromColumnValidator } from '../utils/xlsx-utils/column-validator-utils'; import { constructXLSXWorkbook, getDefaultWorksheet, getNonStandardColumnNamesFromWorksheet, getWorksheetRowObjects, + IXLSXCSVValidator, validateCsvFile } from '../utils/xlsx-utils/worksheet-utils'; import { ApiPaginationOptions } from '../zod-schema/pagination'; @@ -72,6 +67,23 @@ import { SubCountService } from './subcount-service'; const defaultLog = getLogger('services/observation-service'); +/** + * An XLSX validation config for the standard columns of an Observation CSV. + * + * Note: `satisfies` allows `keyof` to correctly infer key types, while also + * enforcing uppercase object keys. + */ +export const observationStandardColumnValidator = { + ITIS_TSN: { type: 'number', aliases: CSV_COLUMN_ALIASES.ITIS_TSN }, + COUNT: { type: 'number' }, + DATE: { type: 'date' }, + TIME: { type: 'string' }, + LATITUDE: { type: 'number', aliases: CSV_COLUMN_ALIASES.LATITUDE }, + LONGITUDE: { type: 'number', aliases: CSV_COLUMN_ALIASES.LONGITUDE } +} satisfies IXLSXCSVValidator; + +export const getColumnCellValue = generateColumnCellGetterFromColumnValidator(observationStandardColumnValidator); + export interface InsertSubCount { observation_subcount_id: number | null; subcount: number; @@ -235,6 +247,17 @@ export class ObservationService extends DBService { return this.observationRepository.getAllSurveyObservations(surveyId); } + /** + * Retrieves all species observed in a given survey + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof ObservationRepository + */ + async getObservedSpeciesForSurvey(surveyId: number): Promise { + return this.observationRepository.getObservedSpeciesForSurvey(surveyId); + } + /** * Retrieves a single observation records by ID * @@ -567,7 +590,7 @@ export class ObservationService extends DBService { const newRowData: InsertUpdateObservations[] = worksheetRowObjects.map((row) => { const newSubcount: InsertSubCount = { observation_subcount_id: null, - subcount: getCountFromRow(row), + subcount: getColumnCellValue(row, 'COUNT').cell as number, qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], @@ -593,16 +616,16 @@ export class ObservationService extends DBService { return { standardColumns: { survey_id: surveyId, - itis_tsn: getTsnFromRow(row), + itis_tsn: getColumnCellValue(row, 'ITIS_TSN').cell as number, itis_scientific_name: null, survey_sample_site_id: samplePeriodHierarchyIds?.survey_sample_site_id ?? null, survey_sample_method_id: samplePeriodHierarchyIds?.survey_sample_method_id ?? null, survey_sample_period_id: samplePeriodHierarchyIds?.survey_sample_period_id ?? null, - latitude: getLatitudeFromRow(row), - longitude: getLongitudeFromRow(row), - count: getCountFromRow(row), - observation_time: getTimeFromRow(row), - observation_date: getDateFromRow(row) + latitude: getColumnCellValue(row, 'LATITUDE').cell as number, + longitude: getColumnCellValue(row, 'LONGITUDE').cell as number, + count: getColumnCellValue(row, 'COUNT').cell as number, + observation_time: getColumnCellValue(row, 'TIME').cell as string, + observation_date: getColumnCellValue(row, 'DATE').cell as string }, subcounts: [newSubcount] }; @@ -648,7 +671,7 @@ export class ObservationService extends DBService { } const measurement = getMeasurementFromTsnMeasurementTypeDefinitionMap( - getTsnFromRow(row), + getColumnCellValue(row, 'ITIS_TSN').cell as string, mColumn, tsnMeasurements ); @@ -770,7 +793,7 @@ export class ObservationService extends DBService { return recordsToPatch.map((recordToPatch: RecordWithTaxonFields) => { recordToPatch.itis_scientific_name = - taxonomyResponse.find((taxonItem) => Number(taxonItem.tsn) === recordToPatch.itis_tsn)?.scientificName ?? null; + taxonomyResponse.find((taxonItem) => taxonItem.tsn === recordToPatch.itis_tsn)?.scientificName ?? null; return recordToPatch; }); diff --git a/api/src/services/platform-service.ts b/api/src/services/platform-service.ts index 5243bb7fe6..506c2d74ec 100644 --- a/api/src/services/platform-service.ts +++ b/api/src/services/platform-service.ts @@ -12,6 +12,7 @@ import { isFeatureFlagPresent } from '../utils/feature-flag-utils'; import { getFileFromS3 } from '../utils/file-utils'; import { getLogger } from '../utils/logger'; import { AttachmentService } from './attachment-service'; +import { IPostCollectionUnit } from './critterbase-service'; import { DBService } from './db-service'; import { HistoryPublishService } from './history-publish-service'; import { KeycloakService } from './keycloak-service'; @@ -44,7 +45,7 @@ export interface IArtifact { } export interface IItisSearchResult { - tsn: string; + tsn: number; commonNames?: string[]; scientificName: string; } @@ -57,6 +58,10 @@ export interface ITaxonomy { kingdom: string; } +export interface ITaxonomyWithEcologicalUnits extends ITaxonomy { + ecological_units: IPostCollectionUnit[]; +} + const getBackboneInternalApiHost = () => process.env.BACKBONE_INTERNAL_API_HOST || ''; const getBackboneArtifactIntakePath = () => process.env.BACKBONE_ARTIFACT_INTAKE_PATH || ''; const getBackboneSurveyIntakePath = () => process.env.BACKBONE_INTAKE_PATH || ''; diff --git a/api/src/services/project-service.test.ts b/api/src/services/project-service.test.ts index 7e1bd0722e..6147ca87e1 100644 --- a/api/src/services/project-service.test.ts +++ b/api/src/services/project-service.test.ts @@ -32,7 +32,8 @@ describe('ProjectService', () => { end_date: '2021-12-31', regions: [], focal_species: [], - types: [1, 2, 3] + types: [1, 2, 3], + members: [{ system_user_id: 1, display_name: 'John Doe' }] }, { project_id: 456, @@ -41,7 +42,8 @@ describe('ProjectService', () => { end_date: '2021-12-31', regions: [], focal_species: [], - types: [1, 2, 3] + types: [1, 2, 3], + members: [{ system_user_id: 1, display_name: 'John Doe' }] } ]; diff --git a/api/src/services/sample-location-service.test.ts b/api/src/services/sample-location-service.test.ts index c1b23aba2a..57a17b7dcc 100644 --- a/api/src/services/sample-location-service.test.ts +++ b/api/src/services/sample-location-service.test.ts @@ -173,10 +173,13 @@ describe('SampleLocationService', () => { .stub(SampleStratumService.prototype, 'deleteSampleStratumRecords') .resolves(); + const mockSurveySampleSiteId = 1; + const mockSurveyId = 1; + // Site sinon.stub(SampleLocationRepository.prototype, 'deleteSampleSiteRecord').resolves({ - survey_sample_site_id: 1, - survey_id: 1, + survey_sample_site_id: mockSurveySampleSiteId, + survey_id: mockSurveyId, name: 'Sample Site 1', description: '', geometry: null, @@ -189,18 +192,17 @@ describe('SampleLocationService', () => { revision_count: 0 }); - const mockSurveyId = 1; const { survey_sample_site_id } = await service.deleteSampleSiteRecord(mockSurveyId, 1); - expect(getSampleBlocksForSurveySampleSiteIdStub).to.be.calledOnceWith(survey_sample_site_id); - expect(deleteSampleBlockRecordsStub).to.be.calledOnceWith([survey_sample_site_id]); + expect(getSampleBlocksForSurveySampleSiteIdStub).to.be.calledOnceWith(mockSurveySampleSiteId); + expect(deleteSampleBlockRecordsStub).to.be.calledOnceWith([mockSurveySampleSiteId]); - expect(getSampleStratumsForSurveySampleSiteIdStub).to.be.calledOnceWith(survey_sample_site_id); - expect(deleteSampleStratumRecordsStub).to.be.calledOnceWith([survey_sample_site_id]); + expect(getSampleStratumsForSurveySampleSiteIdStub).to.be.calledOnceWith(mockSurveySampleSiteId); + expect(deleteSampleStratumRecordsStub).to.be.calledOnceWith([mockSurveySampleSiteId]); - expect(survey_sample_site_id).to.be.eq(survey_sample_site_id); - expect(getSampleMethodsForSurveySampleSiteIdStub).to.be.calledOnceWith(survey_sample_site_id); - expect(deleteSampleMethodRecordStub).to.be.calledOnceWith(survey_sample_site_id); + expect(survey_sample_site_id).to.be.eq(mockSurveySampleSiteId); + expect(getSampleMethodsForSurveySampleSiteIdStub).to.be.calledOnceWith(mockSurveySampleSiteId); + expect(deleteSampleMethodRecordStub).to.be.calledOnceWith(mockSurveySampleSiteId); }); }); diff --git a/api/src/services/sample-method-service.test.ts b/api/src/services/sample-method-service.test.ts index 496b03ead1..4ca9bf77b7 100644 --- a/api/src/services/sample-method-service.test.ts +++ b/api/src/services/sample-method-service.test.ts @@ -64,6 +64,28 @@ describe('SampleMethodService', () => { }); }); + describe('getSampleMethodsCountForTechniqueIds', () => { + afterEach(() => { + sinon.restore(); + }); + + it('Gets a count of sample methods for a method technique ID', async () => { + const mockDBConnection = getMockDBConnection(); + + const getSampleMethodsCountForTechniqueIdsStub = sinon + .stub(SampleMethodRepository.prototype, 'getSampleMethodsCountForTechniqueIds') + .resolves(0); + + const techniqueIds = [1, 2]; + const sampleMethodService = new SampleMethodService(mockDBConnection); + + const response = await sampleMethodService.getSampleMethodsCountForTechniqueIds(techniqueIds); + + expect(getSampleMethodsCountForTechniqueIdsStub).to.be.calledOnceWith(techniqueIds); + expect(response).to.eql(0); + }); + }); + describe('deleteSampleMethodRecord', () => { afterEach(() => { sinon.restore(); diff --git a/api/src/services/sample-method-service.ts b/api/src/services/sample-method-service.ts index 79cd434bd1..ad6e2f7a0f 100644 --- a/api/src/services/sample-method-service.ts +++ b/api/src/services/sample-method-service.ts @@ -40,6 +40,17 @@ export class SampleMethodService extends DBService { return this.sampleMethodRepository.getSampleMethodsForSurveySampleSiteId(surveyId, surveySampleSiteId); } + /** + * Gets count of sample methods associated with one or more method technique Ids + * + * @param {number[]} techniqueIds + * @return {*} {Promise} + * @memberof SampleMethodService + */ + async getSampleMethodsCountForTechniqueIds(techniqueIds: number[]): Promise { + return this.sampleMethodRepository.getSampleMethodsCountForTechniqueIds(techniqueIds); + } + /** * Deletes a survey Sample Method. * diff --git a/api/src/services/standards-service.test.ts b/api/src/services/standards-service.test.ts index aad35dfbd7..c59038faf4 100644 --- a/api/src/services/standards-service.test.ts +++ b/api/src/services/standards-service.test.ts @@ -27,11 +27,11 @@ describe('StandardsService', () => { const getTaxonomyByTsnsStub = sinon .stub(standardsService.platformService, 'getTaxonomyByTsns') - .resolves([{ tsn: String(mockTsn), scientificName: 'caribou' }]); + .resolves([{ tsn: mockTsn, scientificName: 'caribou' }]); const getTaxonBodyLocationsStub = sinon .stub(standardsService.critterbaseService, 'getTaxonBodyLocations') - .resolves({ markingBodyLocations: [{ id: '', key: '', value: 'left ear' }] }); + .resolves([{ id: '', key: '', value: 'left ear' }]); const getTaxonMeasurementsStub = sinon .stub(standardsService.critterbaseService, 'getTaxonMeasurements') @@ -68,4 +68,73 @@ describe('StandardsService', () => { expect(response.measurements.qualitative[0].measurement_desc).to.eql('description'); }); }); + + describe('getEnvironmentStandards', async () => { + const mockData = { + qualitative: [{ name: 'name', description: 'name', options: [{ name: 'name', description: 'description' }] }], + quantitative: [ + { name: 'name', description: 'description', unit: 'unit' }, + { name: 'name', description: 'description', unit: 'unit' } + ] + }; + const mockDbConnection = getMockDBConnection(); + + const standardsService = new StandardsService(mockDbConnection); + + const getEnvironmentStandardsStub = sinon + .stub(standardsService.standardsRepository, 'getEnvironmentStandards') + .resolves(mockData); + + const response = await standardsService.getEnvironmentStandards(); + + expect(getEnvironmentStandardsStub).to.be.calledOnce; + expect(response).to.eql(mockData); + }); + + describe('getMethodStandards', async () => { + const mockData = [ + { + method_lookup_id: 1, + name: 'Method 1', + description: ' Description 1', + attributes: { + quantitative: [ + { name: 'Method Standard 1', description: 'Description 1', unit: 'Unit 1' }, + { name: 'Method Standard 2', description: 'Description 2', unit: 'Unit 2' } + ], + qualitative: [ + { + name: 'Qualitative 1', + description: 'Description 1', + options: [ + { name: 'Option 1', description: 'Option 1 Description' }, + { name: 'Option 2', description: 'Option 2 Description' } + ] + }, + { + name: 'Qualitative 2', + description: 'Description 2', + options: [ + { name: 'Option 3', description: 'Option 3 Description' }, + { name: 'Option 4', description: 'Option 4 Description' } + ] + } + ] + } + } + ]; + + const mockDbConnection = getMockDBConnection(); + + const standardsService = new StandardsService(mockDbConnection); + + const getMethodStandardsStub = sinon + .stub(standardsService.standardsRepository, 'getMethodStandards') + .resolves(mockData); + + const response = await standardsService.getMethodStandards(); + + expect(getMethodStandardsStub).to.be.calledOnce; + expect(response).to.eql(mockData); + }); }); diff --git a/api/src/services/standards-service.ts b/api/src/services/standards-service.ts index 76bea02f87..eed1217efb 100644 --- a/api/src/services/standards-service.ts +++ b/api/src/services/standards-service.ts @@ -1,32 +1,21 @@ import { IDBConnection } from '../database/db'; -import { - CBQualitativeMeasurementTypeDefinition, - CBQuantitativeMeasurementTypeDefinition, - CritterbaseService -} from './critterbase-service'; +import { EnvironmentStandards, ISpeciesStandards, MethodStandard } from '../models/standards-view'; +import { StandardsRepository } from '../repositories/standards-repository'; +import { CritterbaseService } from './critterbase-service'; import { DBService } from './db-service'; import { PlatformService } from './platform-service'; -export interface ISpeciesStandardsResponse { - tsn: number; - scientificName: string; - measurements: { - quantitative: CBQuantitativeMeasurementTypeDefinition[]; - qualitative: CBQualitativeMeasurementTypeDefinition[]; - }; - markingBodyLocations: { id: string; key: string; value: string }[]; -} - /** - * Sample Stratum Repository + * Standards Repository * * @export - * @class SampleStratumService + * @class StandardsService * @extends {DBService} */ export class StandardsService extends DBService { platformService: PlatformService; critterbaseService: CritterbaseService; + standardsRepository: StandardsRepository; constructor(connection: IDBConnection) { super(connection); @@ -35,16 +24,17 @@ export class StandardsService extends DBService { keycloak_guid: this.connection.systemUserGUID(), username: this.connection.systemUserIdentifier() }); + this.standardsRepository = new StandardsRepository(connection); } /** - * Gets all survey Sample Stratums. + * Gets species standards * - * @param {number} surveySampleSiteId - * @return {*} {Promise} + * @param {number} tsn + * @return {ISpeciesStandards} * @memberof standardsService */ - async getSpeciesStandards(tsn: number): Promise { + async getSpeciesStandards(tsn: number): Promise { // Fetch all measurement type definitions from Critterbase for the unique taxon_measurement_ids const response = await Promise.all([ this.platformService.getTaxonomyByTsns([tsn]), @@ -59,4 +49,29 @@ export class StandardsService extends DBService { measurements: response[2] }; } + + /** + * Gets environment standards + * + * @param {string} keyword - search term for filtering the response based on environemntal variable name + * @return {EnvironmentStandard[]} + * @memberof standardsService + */ + async getEnvironmentStandards(keyword?: string): Promise { + const response = await this.standardsRepository.getEnvironmentStandards(keyword); + + return response; + } + + /** + * Gets standards for method lookups + * + * @param {string} keyword - search term for filtering the response based on method lookup name + * @return {MethodStandards} + * @memberof standardsService + */ + async getMethodStandards(keyword?: string): Promise { + const response = await this.standardsRepository.getMethodStandards(keyword); + return response; + } } diff --git a/api/src/services/survey-critter-service.test.ts b/api/src/services/survey-critter-service.test.ts index 0a3230b75a..352e24847b 100644 --- a/api/src/services/survey-critter-service.test.ts +++ b/api/src/services/survey-critter-service.test.ts @@ -62,20 +62,6 @@ describe('SurveyService', () => { }); }); - describe('addDeployment', () => { - it('returns the first row on success', async () => { - const dbConnection = getMockDBConnection(); - const service = new SurveyCritterService(dbConnection); - - const repoStub = sinon.stub(SurveyCritterRepository.prototype, 'upsertDeployment').resolves(); - - const response = await service.upsertDeployment(1, 'deployment_id'); - - expect(repoStub).to.be.calledOnce; - expect(response).to.be.undefined; - }); - }); - describe('updateCritter', () => { it('updates critter, returns nothing', async () => { const dbConnection = getMockDBConnection(); @@ -89,15 +75,4 @@ describe('SurveyService', () => { expect(response).to.be.undefined; }); }); - - describe('removeDeployment', () => { - it('removes deployment and returns void', async () => { - const dbConnection = getMockDBConnection(); - const service = new SurveyCritterService(dbConnection); - const repoStub = sinon.stub(SurveyCritterRepository.prototype, 'removeDeployment').resolves(); - const response = await service.removeDeployment(1, 'deployment_id'); - expect(repoStub).to.be.calledOnce; - expect(response).to.be.undefined; - }); - }); }); diff --git a/api/src/services/survey-critter-service.ts b/api/src/services/survey-critter-service.ts index fc1ac5c142..db5470df70 100644 --- a/api/src/services/survey-critter-service.ts +++ b/api/src/services/survey-critter-service.ts @@ -1,11 +1,14 @@ import { IDBConnection } from '../database/db'; import { IAnimalAdvancedFilters } from '../models/animal-view'; -import { ITelemetryAdvancedFilters } from '../models/telemetry-view'; +import { IAllTelemetryAdvancedFilters } from '../models/telemetry-view'; import { SurveyCritterRecord, SurveyCritterRepository } from '../repositories/survey-critter-repository'; +import { getLogger } from '../utils/logger'; import { ApiPaginationOptions } from '../zod-schema/pagination'; -import { CritterbaseService, ICritter } from './critterbase-service'; +import { CritterbaseService, ICritter, ICritterDetailed } from './critterbase-service'; import { DBService } from './db-service'; +const defaultLog = getLogger('SurveyCritterService'); + export type FindCrittersResponse = Pick< ICritter, 'wlh_id' | 'animal_id' | 'sex' | 'itis_tsn' | 'itis_scientific_name' | 'critter_comment' @@ -45,6 +48,19 @@ export class SurveyCritterService extends DBService { return this.critterRepository.getCrittersInSurvey(surveyId); } + /** + * Get all critter associations for the given survey. This only gets you critter ids, which can be used to fetch + * details from the external system. + * + * @param {number} surveyId + * @param {number} critterId + * @return {*} {Promise} + * @memberof SurveyCritterService + */ + async getCritterById(surveyId: number, critterId: number): Promise { + return this.critterRepository.getCritterById(surveyId, critterId); + } + /** * Retrieves all critters that are available to the user, based on their permissions. * @@ -115,14 +131,14 @@ export class SurveyCritterService extends DBService { * * @param {boolean} isUserAdmin * @param {(number | null)} systemUserId The system user id of the user making the request - * @param {ITelemetryAdvancedFilters} [filterFields] + * @param {IAllTelemetryAdvancedFilters} [filterFields] * @return {*} {Promise} * @memberof SurveyCritterService */ async findCrittersCount( isUserAdmin: boolean, systemUserId: number | null, - filterFields?: ITelemetryAdvancedFilters + filterFields?: IAllTelemetryAdvancedFilters ): Promise { return this.critterRepository.findCrittersCount(isUserAdmin, systemUserId, filterFields); } @@ -178,45 +194,74 @@ export class SurveyCritterService extends DBService { } /** - * Upsert a deployment row into SIMS. + * Get survey Critterbase critters. * - * @param {number} critterId - * @param {string} deplyomentId - * @return {*} {Promise} + * @param {number} surveyId + * @return {*} {Promise} * @memberof SurveyCritterService */ - async upsertDeployment(critterId: number, deplyomentId: string): Promise { - return this.critterRepository.upsertDeployment(critterId, deplyomentId); - } + async getCritterbaseSurveyCritters(surveyId: number): Promise { + const surveyCritters = await this.getCrittersInSurvey(surveyId); - /** - * Removes the deployment in SIMS. - * - * @param {number} critterId - * @param {string} deploymentId the bctw deployment uuid - * @return {*} {Promise} - * @memberof SurveyCritterService - */ - async removeDeployment(critterId: number, deploymentId: string): Promise { - return this.critterRepository.removeDeployment(critterId, deploymentId); + const critterbaseCritterIds = surveyCritters.map((critter) => critter.critterbase_critter_id); + + return this.critterbaseService.getMultipleCrittersByIdsDetailed(critterbaseCritterIds); } /** * Get unique Set of critter aliases (animal id / nickname) of a survey. * + * Note: Business expects unique critter alias' in Surveys effective 01/06/2024 + * * @param {number} surveyId * @return {*} {Promise} * @memberof SurveyCritterService */ async getUniqueSurveyCritterAliases(surveyId: number): Promise> { - const surveyCritters = await this.getCrittersInSurvey(surveyId); - - const critterbaseCritterIds = surveyCritters.map((critter) => critter.critterbase_critter_id); - - const critters = await this.critterbaseService.getMultipleCrittersByIds(critterbaseCritterIds); + const critters = await this.getCritterbaseSurveyCritters(surveyId); // Return a unique Set of non-null critterbase aliases of a Survey // Note: The type from filtered critters should be Set not Set return new Set(critters.filter(Boolean).map((critter) => critter.animal_id)) as Set; } + + /** + * Get mapping of `alias` (animal_id) -> critterbase critter for a survey. + * + * Note: Aliases are converted to lowercase, when accessing the value use .toLowerCase() + * Note: Business expects unique critter alias' in Surveys effective 01/06/2024 + * + * TODO: Update this function to handle duplicate, not found and found critter aliases ie: `alias -> critter[]` + * + * @async + * @param {number} surveyId + * @returns {Promise>} Critter alias -> Detailed critter + */ + async getSurveyCritterAliasMap(surveyId: number): Promise> { + const critters = await this.getCritterbaseSurveyCritters(surveyId); + + // Create mapping of alias -> critter_id + const critterAliasMap = new Map(); + + for (const critter of critters) { + if (critter.animal_id) { + const alias = critter.animal_id.toLowerCase(); + // Do not allow existing duplicate aliases to map over eachother + if (critterAliasMap.get(alias)) { + defaultLog.debug({ + label: 'critter alias map', + message: 'duplicate critter alias for survey', + details: { surveyId: surveyId, critter: critter } + }); + + // Duplicate alias found, overwrite as undefined + critterAliasMap.set(alias, undefined); + } else { + critterAliasMap.set(alias, critter); + } + } + } + + return critterAliasMap; + } } diff --git a/api/src/services/survey-service.test.ts b/api/src/services/survey-service.test.ts index 1fca184b50..b10e809880 100644 --- a/api/src/services/survey-service.test.ts +++ b/api/src/services/survey-service.test.ts @@ -10,7 +10,6 @@ import { GetReportAttachmentsData } from '../models/project-view'; import { PostProprietorData, PostSurveyObject } from '../models/survey-create'; import { PostSurveyLocationData, PutSurveyObject, PutSurveyPermitData } from '../models/survey-update'; import { - GetAncillarySpeciesData, GetAttachmentsData, GetFocalSpeciesData, GetSurveyData, @@ -22,10 +21,10 @@ import { FundingSourceRepository } from '../repositories/funding-source-reposito import { IPermitModel } from '../repositories/permit-repository'; import { SurveyLocationRecord, SurveyLocationRepository } from '../repositories/survey-location-repository'; import { - IGetSpeciesData, ISurveyProprietorModel, SurveyRecord, SurveyRepository, + SurveyTaxonomyWithEcologicalUnits, SurveyTypeRecord } from '../repositories/survey-repository'; import { getMockDBConnection } from '../__mocks__/db'; @@ -143,9 +142,6 @@ describe('SurveyService', () => { const dbConnectionObj = getMockDBConnection(); const updateSurveyDetailsDataStub = sinon.stub(SurveyService.prototype, 'updateSurveyDetailsData').resolves(); - const updateSurveyVantageCodesDataStub = sinon - .stub(SurveyService.prototype, 'updateSurveyVantageCodesData') - .resolves(); const updateSurveySpeciesDataStub = sinon.stub(SurveyService.prototype, 'updateSurveySpeciesData').resolves(); const updateSurveyPermitDataStub = sinon.stub(SurveyService.prototype, 'updateSurveyPermitData').resolves(); const upsertSurveyFundingSourceDataStub = sinon @@ -173,7 +169,6 @@ describe('SurveyService', () => { await surveyService.updateSurvey(surveyId, putSurveyData); expect(updateSurveyDetailsDataStub).not.to.have.been.called; - expect(updateSurveyVantageCodesDataStub).not.to.have.been.called; expect(updateSurveySpeciesDataStub).not.to.have.been.called; expect(updateSurveyPermitDataStub).not.to.have.been.called; expect(upsertSurveyFundingSourceDataStub).to.have.been.calledOnce; @@ -188,9 +183,6 @@ describe('SurveyService', () => { const updateSurveyDetailsDataStub = sinon.stub(SurveyService.prototype, 'updateSurveyDetailsData').resolves(); const updateSurveyTypesDataStub = sinon.stub(SurveyService.prototype, 'updateSurveyTypesData').resolves(); - const updateSurveyVantageCodesDataStub = sinon - .stub(SurveyService.prototype, 'updateSurveyVantageCodesData') - .resolves(); const updateSurveyIntendedOutcomesStub = sinon .stub(SurveyService.prototype, 'updateSurveyIntendedOutcomes') .resolves(); @@ -236,7 +228,6 @@ describe('SurveyService', () => { expect(updateSurveyDetailsDataStub).to.have.been.calledOnce; expect(updateSurveyTypesDataStub).to.have.been.calledOnce; - expect(updateSurveyVantageCodesDataStub).to.have.been.calledOnce; expect(updateSurveySpeciesDataStub).to.have.been.calledOnce; expect(updateSurveyPermitDataStub).to.have.been.calledOnce; expect(upsertSurveyFundingSourceDataStub).to.have.been.calledOnce; @@ -367,23 +358,44 @@ describe('SurveyService', () => { }); describe('getSpeciesData', () => { - it('returns the first row on success', async () => { + it('returns combined species and taxonomy data on success', async () => { const dbConnection = getMockDBConnection(); const service = new SurveyService(dbConnection); - const data = { id: 1 } as unknown as IGetSpeciesData; + const mockEcologicalUnits = [ + { critterbase_collection_category_id: 'abc', critterbase_collection_unit_id: 'xyz' } + ]; + const mockSpeciesData = [ + { itis_tsn: 123, ecological_units: [] }, + { + itis_tsn: 456, + ecological_units: mockEcologicalUnits + } + ] as unknown as SurveyTaxonomyWithEcologicalUnits[]; + const mockTaxonomyData = [ + { tsn: 123, scientificName: 'Species 1' }, + { tsn: 456, scientificName: 'Species 2' } + ]; + const mockResponse = new GetFocalSpeciesData([ + { tsn: 123, scientificName: 'Species 1', ecological_units: [] }, + { + tsn: 456, + scientificName: 'Species 2', + ecological_units: mockEcologicalUnits + } + ]); - const repoStub = sinon.stub(SurveyRepository.prototype, 'getSpeciesData').resolves([data]); - const getTaxonomyByTsnsStub = sinon.stub(PlatformService.prototype, 'getTaxonomyByTsns').resolves([]); + const repoStub = sinon.stub(SurveyRepository.prototype, 'getSpeciesData').resolves(mockSpeciesData); + const getTaxonomyByTsnsStub = sinon + .stub(PlatformService.prototype, 'getTaxonomyByTsns') + .resolves(mockTaxonomyData); const response = await service.getSpeciesData(1); + // Assertions expect(repoStub).to.be.calledOnce; - expect(getTaxonomyByTsnsStub).to.be.calledTwice; - expect(response).to.eql({ - ...new GetFocalSpeciesData([]), - ...new GetAncillarySpeciesData([]) - }); + expect(getTaxonomyByTsnsStub).to.be.calledOnceWith([123, 456]); + expect(response.focal_species).to.eql(mockResponse.focal_species); }); }); @@ -585,35 +597,32 @@ describe('SurveyService', () => { }); }); - describe('insertAncillarySpecies', () => { + describe('insertFocalSpeciesWithUnits', () => { it('returns the first row on success', async () => { const dbConnection = getMockDBConnection(); const service = new SurveyService(dbConnection); - const data = 1; - - const repoStub = sinon.stub(SurveyRepository.prototype, 'insertAncillarySpecies').resolves(data); - - const response = await service.insertAncillarySpecies(1, 1); - - expect(repoStub).to.be.calledOnce; - expect(response).to.eql(data); - }); - }); - - describe('insertVantageCodes', () => { - it('returns the first row on success', async () => { - const dbConnection = getMockDBConnection(); - const service = new SurveyService(dbConnection); - - const data = 1; - - const repoStub = sinon.stub(SurveyRepository.prototype, 'insertVantageCodes').resolves(data); - - const response = await service.insertVantageCodes(1, 1); - - expect(repoStub).to.be.calledOnce; - expect(response).to.eql(data); + const mockFocalSpeciesId = 1; + const mockFocalSpeciesData = { + tsn: mockFocalSpeciesId, + scientificName: 'name', + commonNames: [], + rank: 'species', + kingdom: 'Animalia', + ecological_units: [{ critterbase_collection_category_id: 'abc', critterbase_collection_unit_id: 'xyz' }] + }; + const insertFocalSpeciesStub = sinon + .stub(SurveyRepository.prototype, 'insertFocalSpecies') + .resolves(mockFocalSpeciesId); + const insertFocalSpeciesUnitsStub = sinon + .stub(SurveyRepository.prototype, 'insertFocalSpeciesUnits') + .resolves(mockFocalSpeciesId); + + const response = await service.insertFocalSpeciesWithUnits(mockFocalSpeciesData, 1); + + expect(insertFocalSpeciesStub).to.be.calledOnce; + expect(insertFocalSpeciesUnitsStub).to.be.calledOnce; + expect(response).to.eql(mockFocalSpeciesId); }); }); @@ -726,9 +735,9 @@ describe('SurveyService', () => { }); it('returns data if response is not null', async () => { + sinon.stub(SurveyService.prototype, 'deleteSurveySpeciesUnitData').resolves(); sinon.stub(SurveyService.prototype, 'deleteSurveySpeciesData').resolves(); - sinon.stub(SurveyService.prototype, 'insertFocalSpecies').resolves(1); - sinon.stub(SurveyService.prototype, 'insertAncillarySpecies').resolves(1); + sinon.stub(SurveyService.prototype, 'insertFocalSpeciesWithUnits').resolves(1); const mockQueryResponse = { response: 'something', rowCount: 1 } as unknown as QueryResult; @@ -737,10 +746,10 @@ describe('SurveyService', () => { const response = await surveyService.updateSurveySpeciesData(1, { survey_details: 'details', - species: { focal_species: [1], ancillary_species: [1] } + species: { focal_species: [1] } } as unknown as PutSurveyObject); - expect(response).to.eql([1, 1]); + expect(response).to.eql([1]); }); }); @@ -912,60 +921,6 @@ describe('SurveyService', () => { }); }); - describe('updateSurveyVantageCodesData', () => { - afterEach(() => { - sinon.restore(); - }); - - it('returns [] if not vantage_code_ids is given', async () => { - sinon.stub(SurveyService.prototype, 'deleteSurveyVantageCodes').resolves(); - - const mockQueryResponse = undefined as unknown as QueryResult; - - const mockDBConnection = getMockDBConnection({ sql: async () => mockQueryResponse }); - const surveyService = new SurveyService(mockDBConnection); - - const response = await surveyService.updateSurveyVantageCodesData(1, { - permit: { permit_number: '1', permit_type: 'type' }, - purpose_and_methodology: { vantage_code_ids: undefined } - } as unknown as PutSurveyObject); - - expect(response).to.eql([]); - }); - - it('returns data if response is not null', async () => { - sinon.stub(SurveyService.prototype, 'deleteSurveyVantageCodes').resolves(); - sinon.stub(SurveyService.prototype, 'insertVantageCodes').resolves(1); - - const mockQueryResponse = undefined as unknown as QueryResult; - - const mockDBConnection = getMockDBConnection({ sql: async () => mockQueryResponse }); - const surveyService = new SurveyService(mockDBConnection); - - const response = await surveyService.updateSurveyVantageCodesData(1, { - permit: { permit_number: '1', permit_type: 'type' }, - proprietor: { survey_data_proprietary: 'asd' }, - purpose_and_methodology: { vantage_code_ids: [1] } - } as unknown as PutSurveyObject); - - expect(response).to.eql([1]); - }); - }); - - describe('deleteSurveyVantageCodes', () => { - it('returns the first row on success', async () => { - const dbConnection = getMockDBConnection(); - const service = new SurveyService(dbConnection); - - const repoStub = sinon.stub(SurveyRepository.prototype, 'deleteSurveyVantageCodes').resolves(); - - const response = await service.deleteSurveyVantageCodes(1); - - expect(repoStub).to.be.calledOnce; - expect(response).to.eql(undefined); - }); - }); - describe('deleteSurvey', () => { it('should delete the survey and return nothing', async () => { const dbConnection = getMockDBConnection(); diff --git a/api/src/services/survey-service.ts b/api/src/services/survey-service.ts index ccba496253..c414dbf420 100644 --- a/api/src/services/survey-service.ts +++ b/api/src/services/survey-service.ts @@ -4,7 +4,6 @@ import { PostProprietorData, PostSurveyObject } from '../models/survey-create'; import { PostSurveyLocationData, PutPartnershipsData, PutSurveyObject } from '../models/survey-update'; import { FindSurveysResponse, - GetAncillarySpeciesData, GetAttachmentsData, GetFocalSpeciesData, GetPermitData, @@ -18,7 +17,6 @@ import { SurveyObject, SurveySupplementaryData } from '../models/survey-view'; -import { AttachmentRepository } from '../repositories/attachment-repository'; import { PostSurveyBlock, SurveyBlockRecordWithCount } from '../repositories/survey-block-repository'; import { SurveyLocationRecord } from '../repositories/survey-location-repository'; import { ISurveyProprietorModel, SurveyBasicFields, SurveyRepository } from '../repositories/survey-repository'; @@ -27,7 +25,7 @@ import { DBService } from './db-service'; import { FundingSourceService } from './funding-source-service'; import { HistoryPublishService } from './history-publish-service'; import { PermitService } from './permit-service'; -import { ITaxonomy, PlatformService } from './platform-service'; +import { ITaxonomyWithEcologicalUnits, PlatformService } from './platform-service'; import { RegionService } from './region-service'; import { SiteSelectionStrategyService } from './site-selection-strategy-service'; import { SurveyBlockService } from './survey-block-service'; @@ -35,7 +33,6 @@ import { SurveyLocationService } from './survey-location-service'; import { SurveyParticipationService } from './survey-participation-service'; export class SurveyService extends DBService { - attachmentRepository: AttachmentRepository; surveyRepository: SurveyRepository; platformService: PlatformService; historyPublishService: HistoryPublishService; @@ -47,7 +44,6 @@ export class SurveyService extends DBService { constructor(connection: IDBConnection) { super(connection); - this.attachmentRepository = new AttachmentRepository(connection); this.surveyRepository = new SurveyRepository(connection); this.platformService = new PlatformService(connection); this.historyPublishService = new HistoryPublishService(connection); @@ -168,33 +164,27 @@ export class SurveyService extends DBService { * Get associated species data for a survey from the taxonomic service for a given Survey ID * * @param {number} surveyId - * @returns {*} {Promise} + * @returns {*} {Promise} * @memberof SurveyService */ - async getSpeciesData(surveyId: number): Promise { + async getSpeciesData(surveyId: number): Promise { + // Fetch species data for the survey const studySpeciesResponse = await this.surveyRepository.getSpeciesData(surveyId); - const [focalSpeciesIds, ancillarySpeciesIds] = studySpeciesResponse.reduce( - ([focal, ancillary]: [number[], number[]], studySpecies) => { - if (studySpecies.is_focal) { - focal.push(studySpecies.itis_tsn); - } else { - ancillary.push(studySpecies.itis_tsn); - } - - return [focal, ancillary]; - }, - [[], []] + // Fetch taxonomy data for each survey species + const taxonomyResponse = await this.platformService.getTaxonomyByTsns( + studySpeciesResponse.map((species) => species.itis_tsn) ); - const platformService = new PlatformService(this.connection); + const focalSpecies = []; - const [focalSpecies, ancillarySpecies] = await Promise.all([ - platformService.getTaxonomyByTsns(focalSpeciesIds), - platformService.getTaxonomyByTsns(ancillarySpeciesIds) - ]); + for (const species of studySpeciesResponse) { + const taxon = taxonomyResponse.find((taxonomy) => Number(taxonomy.tsn) === species.itis_tsn) ?? {}; + focalSpecies.push({ ...taxon, tsn: species.itis_tsn, ecological_units: species.ecological_units }); + } - return { ...new GetFocalSpeciesData(focalSpecies), ...new GetAncillarySpeciesData(ancillarySpecies) }; + // Return the combined data + return new GetFocalSpeciesData(focalSpecies); } /** @@ -310,7 +300,7 @@ export class SurveyService extends DBService { const decoratedSurveys: SurveyBasicFields[] = []; for (const survey of surveys) { const matchingFocalSpeciesNames = focalSpecies - .filter((item) => survey.focal_species.includes(Number(item.tsn))) + .filter((item) => survey.focal_species.includes(item.tsn)) .map((item) => [item.commonNames, `(${item.scientificName})`].filter(Boolean).join(' ')); decoratedSurveys.push({ ...survey, focal_species_names: matchingFocalSpeciesNames }); @@ -390,18 +380,16 @@ export class SurveyService extends DBService { ); // Handle focal species associated to this survey + // If there are ecological units, insert them promises.push( Promise.all( - postSurveyData.species.focal_species.map((species: ITaxonomy) => this.insertFocalSpecies(species.tsn, surveyId)) - ) - ); - - // Handle ancillary species associated to this survey - promises.push( - Promise.all( - postSurveyData.species.ancillary_species.map((species: ITaxonomy) => - this.insertAncillarySpecies(species.tsn, surveyId) - ) + postSurveyData.species.focal_species.map((species: ITaxonomyWithEcologicalUnits) => { + if (species.ecological_units.length) { + this.insertFocalSpeciesWithUnits(species, surveyId); + } else { + this.insertFocalSpecies(species.tsn, surveyId); + } + }) ) ); @@ -454,15 +442,6 @@ export class SurveyService extends DBService { // Handle survey proprietor data postSurveyData.proprietor && promises.push(this.insertSurveyProprietor(postSurveyData.proprietor, surveyId)); - //Handle vantage codes associated to this survey - promises.push( - Promise.all( - postSurveyData.purpose_and_methodology.vantage_code_ids.map((vantageCode: number) => - this.insertVantageCodes(vantageCode, surveyId) - ) - ) - ); - if (postSurveyData.locations) { // Insert survey locations promises.push(Promise.all(postSurveyData.locations.map((item) => this.insertSurveyLocations(surveyId, item)))); @@ -584,27 +563,23 @@ export class SurveyService extends DBService { } /** - * Inserts a new record and associates ancillary species to a survey + * Inserts a new focal species record and associates it with ecological units for a survey. * - * @param {number} ancillary_species_id - * @param {number} surveyId - * @returns {*} {Promise} + * @param {ITaxonomyWithEcologicalUnits[]} taxonWithUnits - Array of species with ecological unit objects to associate. + * @param {number} surveyId - ID of the survey. + * @returns {Promise} - The ID of the newly created focal species. * @memberof SurveyService */ - async insertAncillarySpecies(ancillary_species_id: number, surveyId: number): Promise { - return this.surveyRepository.insertAncillarySpecies(ancillary_species_id, surveyId); - } + async insertFocalSpeciesWithUnits(taxonWithUnits: ITaxonomyWithEcologicalUnits, surveyId: number): Promise { + // Insert the new focal species and get its ID + const studySpeciesId = await this.surveyRepository.insertFocalSpecies(taxonWithUnits.tsn, surveyId); - /** - * Inserts new record and associated a vantage code to a survey - * - * @param {number} vantage_code_id - * @param {number} surveyId - * @returns {*} {Promise} - * @memberof SurveyService - */ - async insertVantageCodes(vantage_code_id: number, surveyId: number): Promise { - return this.surveyRepository.insertVantageCodes(vantage_code_id, surveyId); + // Insert ecological units associated with the newly created focal species + await Promise.all( + taxonWithUnits.ecological_units.map((unit) => this.surveyRepository.insertFocalSpeciesUnits(unit, studySpeciesId)) + ); + + return studySpeciesId; } /** @@ -673,7 +648,6 @@ export class SurveyService extends DBService { } if (putSurveyData?.purpose_and_methodology) { - promises.push(this.updateSurveyVantageCodesData(surveyId, putSurveyData)); promises.push(this.updateSurveyIntendedOutcomes(surveyId, putSurveyData)); } @@ -827,16 +801,14 @@ export class SurveyService extends DBService { * @memberof SurveyService */ async updateSurveySpeciesData(surveyId: number, surveyData: PutSurveyObject) { + // Delete any ecological units associated with the focal species record + await this.deleteSurveySpeciesUnitData(surveyId); await this.deleteSurveySpeciesData(surveyId); const promises: Promise[] = []; - surveyData.species.focal_species.forEach((focalSpecies: ITaxonomy) => - promises.push(this.insertFocalSpecies(focalSpecies.tsn, surveyId)) - ); - - surveyData.species.ancillary_species.forEach((ancillarySpecies: ITaxonomy) => - promises.push(this.insertAncillarySpecies(ancillarySpecies.tsn, surveyId)) + surveyData.species.focal_species.forEach((focalSpecies: ITaxonomyWithEcologicalUnits) => + promises.push(this.insertFocalSpeciesWithUnits(focalSpecies, surveyId)) ); return Promise.all(promises); @@ -853,6 +825,17 @@ export class SurveyService extends DBService { return this.surveyRepository.deleteSurveySpeciesData(surveyId); } + /** + * Delete focal ecological units for a given survey ID + * + * @param {number} surveyId + * @returns {*} {Promise} + * @memberof SurveyService + */ + async deleteSurveySpeciesUnitData(surveyId: number) { + return this.surveyRepository.deleteSurveySpeciesUnitData(surveyId); + } + /** * Updates survey participants * @@ -1148,39 +1131,6 @@ export class SurveyService extends DBService { return this.surveyRepository.insertStakeholderPartnerships(stakeholderPartners, surveyId); } - /** - * Updates vantage codes associated to a survey - * - * @param {number} surveyId - * @param {PutSurveyObject} surveyData - * @returns {*} {Promise} - * @memberof SurveyService - */ - async updateSurveyVantageCodesData(surveyId: number, surveyData: PutSurveyObject) { - await this.deleteSurveyVantageCodes(surveyId); - - const promises: Promise[] = []; - - if (surveyData.purpose_and_methodology.vantage_code_ids) { - surveyData.purpose_and_methodology.vantage_code_ids.forEach((vantageCodeId: number) => - promises.push(this.insertVantageCodes(vantageCodeId, surveyId)) - ); - } - - return Promise.all(promises); - } - - /** - * Breaks link between vantage codes and a survey fora given survey Id - * - * @param {number} surveyId - * @returns {*} {Promise} - * @memberof SurveyService - */ - async deleteSurveyVantageCodes(surveyId: number): Promise { - return this.surveyRepository.deleteSurveyVantageCodes(surveyId); - } - /** * Deletes a survey for a given ID * diff --git a/api/src/services/telemetry-service.ts b/api/src/services/telemetry-service.ts index a064741af2..1d235ca823 100644 --- a/api/src/services/telemetry-service.ts +++ b/api/src/services/telemetry-service.ts @@ -1,11 +1,12 @@ import { default as dayjs } from 'dayjs'; import { IDBConnection } from '../database/db'; import { ApiGeneralError } from '../errors/api-error'; -import { ITelemetryAdvancedFilters } from '../models/telemetry-view'; +import { IAllTelemetryAdvancedFilters } from '../models/telemetry-view'; import { SurveyCritterRecord } from '../repositories/survey-critter-repository'; import { Deployment, TelemetryRepository, TelemetrySubmissionRecord } from '../repositories/telemetry-repository'; import { generateS3FileKey, getFileFromS3 } from '../utils/file-utils'; import { parseS3File } from '../utils/media/media-utils'; +import { CSV_COLUMN_ALIASES } from '../utils/xlsx-utils/column-aliases'; import { constructXLSXWorkbook, getDefaultWorksheet, @@ -14,35 +15,42 @@ import { validateCsvFile } from '../utils/xlsx-utils/worksheet-utils'; import { ApiPaginationOptions } from '../zod-schema/pagination'; -import { BctwService, IAllTelemetry, ICreateManualTelemetry, IDeploymentRecord } from './bctw-service'; +import { AttachmentService } from './attachment-service'; +import { BctwDeploymentRecord, BctwDeploymentService } from './bctw-service/bctw-deployment-service'; +import { BctwTelemetryService, IAllTelemetry, ICreateManualTelemetry } from './bctw-service/bctw-telemetry-service'; import { ICritter, ICritterbaseUser } from './critterbase-service'; import { DBService } from './db-service'; +import { DeploymentService } from './deployment-service'; import { SurveyCritterService } from './survey-critter-service'; export type FindTelemetryResponse = { telemetry_id: string } & Pick< IAllTelemetry, 'acquisition_date' | 'latitude' | 'longitude' | 'telemetry_type' > & - Pick & + Pick & Pick & Pick & Pick; const telemetryCSVColumnValidator: IXLSXCSVValidator = { - columnNames: ['DEVICE_ID', 'DATE', 'TIME', 'LATITUDE', 'LONGITUDE'], - columnTypes: ['number', 'date', 'string', 'number', 'number'], - columnAliases: { - LATITUDE: ['LAT'], - LONGITUDE: ['LON', 'LONG', 'LNG'] - } + DEVICE_ID: { type: 'number' }, + DATE: { type: 'date' }, + TIME: { type: 'string' }, + LATITUDE: { type: 'number', aliases: CSV_COLUMN_ALIASES.LATITUDE }, + LONGITUDE: { type: 'number', aliases: CSV_COLUMN_ALIASES.LONGITUDE } }; export class TelemetryService extends DBService { telemetryRepository: TelemetryRepository; + attachmentService: AttachmentService; + constructor(connection: IDBConnection) { super(connection); + this.telemetryRepository = new TelemetryRepository(connection); + + this.attachmentService = new AttachmentService(connection); } /** @@ -101,13 +109,13 @@ export class TelemetryService extends DBService { const worksheetRowObjects = getWorksheetRowObjects(xlsxWorksheet); // step 7 fetch survey deployments - const bctwService = new BctwService(user); - const critterService = new SurveyCritterService(this.connection); - - const critters = await critterService.getCrittersInSurvey(submission.survey_id); - const critterIds = critters.map((item) => item.critterbase_critter_id); + const deploymentService = new DeploymentService(this.connection); + const bctwDeploymentService = new BctwDeploymentService(user); - const deployments = await bctwService.getDeploymentsByCritterId(critterIds); + const surveyDeployments = await deploymentService.getDeploymentsForSurveyId(submission.survey_id); + const deployments = await bctwDeploymentService.getDeploymentsByIds( + surveyDeployments.map((deployment) => deployment.bctw_deployment_id) + ); // step 8 parse file data and find deployment ids based on device id and attachment dates const itemsToAdd: ICreateManualTelemetry[] = []; @@ -153,9 +161,12 @@ export class TelemetryService extends DBService { }); // step 9 create telemetries + + const bctwTelemetryService = new BctwTelemetryService(user); + if (itemsToAdd.length > 0) { try { - return bctwService.createManualTelemetry(itemsToAdd); + return await bctwTelemetryService.createManualTelemetry(itemsToAdd); } catch (error) { throw new ApiGeneralError('Error adding Manual Telemetry'); } @@ -182,13 +193,27 @@ export class TelemetryService extends DBService { return this.telemetryRepository.getDeploymentsByCritterIds(critterIds); } + /** + * Get deployments for the provided survey id. + * + * Note: SIMS does not store deployment information, beyond an ID. Deployment details must be fetched from the + * external BCTW API. + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof TelemetryService + */ + async getDeploymentsBySurveyId(surveyId: number): Promise { + return this.telemetryRepository.getDeploymentsBySurveyId(surveyId); + } + /** * Retrieves the paginated list of all telemetry records that are available to the user, based on their permissions * and provided filter criteria. * * @param {boolean} isUserAdmin * @param {(number | null)} systemUserId The system user id of the user making the request - * @param {ITelemetryAdvancedFilters} [filterFields] + * @param {IAllTelemetryAdvancedFilters} [filterFields] * @param {ApiPaginationOptions} [pagination] * @return {*} {Promise} * @memberof TelemetryService @@ -196,7 +221,7 @@ export class TelemetryService extends DBService { async findTelemetry( isUserAdmin: boolean, systemUserId: number | null, - filterFields?: ITelemetryAdvancedFilters, + filterFields?: IAllTelemetryAdvancedFilters, pagination?: ApiPaginationOptions ): Promise { // --- Step 1 ----------------------------- @@ -248,14 +273,18 @@ export class TelemetryService extends DBService { return []; } - const bctwService = new BctwService({ + const user = { keycloak_guid: this.connection.systemUserGUID(), username: this.connection.systemUserIdentifier() - }); + }; + + const bctwDeploymentService = new BctwDeploymentService(user); + const bctwTelemetryService = new BctwTelemetryService(user); + // The detailed deployment records from BCTW // Note: This may include records the user does not have acces to (A critter may have multiple deployments over its // lifespan, but the user may only have access to a subset of them). - const allBctwDeploymentsForCritters = await bctwService.getDeploymentsByCritterId(critterbaseCritterIds); + const allBctwDeploymentsForCritters = await bctwDeploymentService.getDeploymentsByCritterId(critterbaseCritterIds); // Remove records the user does not have access to const usersBctwDeployments = allBctwDeploymentsForCritters.filter((deployment) => @@ -271,7 +300,7 @@ export class TelemetryService extends DBService { // --- Step 4 ------------------------------ // The telemetry records for the deployments the user has access to - const allTelemetryRecords = await bctwService.getAllTelemetryByDeploymentIds(usersBctwDeploymentIds); + const allTelemetryRecords = await bctwTelemetryService.getAllTelemetryByDeploymentIds(usersBctwDeploymentIds); // --- Step 5 ------------------------------ @@ -309,7 +338,7 @@ export class TelemetryService extends DBService { latitude: telemetryRecord.latitude, longitude: telemetryRecord.longitude, telemetry_type: telemetryRecord.telemetry_type, - // IDeploymentRecord + // BctwDeploymentRecord device_id: usersBctwDeployment.device_id, // Deployment bctw_deployment_id: telemetryRecord.deployment_id, diff --git a/api/src/utils/file-utils.test.ts b/api/src/utils/file-utils.test.ts index 84c4cf315e..4783d97742 100644 --- a/api/src/utils/file-utils.test.ts +++ b/api/src/utils/file-utils.test.ts @@ -55,17 +55,22 @@ describe('generateS3FileKey', () => { it('returns project folder file path', async () => { process.env.S3_KEY_PREFIX = 'some/s3/prefix'; - const result = generateS3FileKey({ projectId: 1, folder: 'folder', fileName: 'testFileName' }); + const result = generateS3FileKey({ projectId: 1, folder: 'reports', fileName: 'testFileName' }); - expect(result).to.equal('some/s3/prefix/projects/1/folder/testFileName'); + expect(result).to.equal('some/s3/prefix/projects/1/reports/testFileName'); }); it('returns survey folder file path', async () => { process.env.S3_KEY_PREFIX = 'some/s3/prefix'; - const result = generateS3FileKey({ projectId: 1, surveyId: 2, folder: 'folder', fileName: 'testFileName' }); + const result = generateS3FileKey({ + projectId: 1, + surveyId: 2, + folder: 'telemetry-credentials', + fileName: 'testFileName' + }); - expect(result).to.equal('some/s3/prefix/projects/1/surveys/2/folder/testFileName'); + expect(result).to.equal('some/s3/prefix/projects/1/surveys/2/telemetry-credentials/testFileName'); }); it('returns survey submission folder file path when a submission ID is passed', async () => { diff --git a/api/src/utils/file-utils.ts b/api/src/utils/file-utils.ts index dc4e661f91..63f2c1f49a 100644 --- a/api/src/utils/file-utils.ts +++ b/api/src/utils/file-utils.ts @@ -253,10 +253,32 @@ export async function getS3SignedURL(key: string): Promise { } export interface IS3FileKey { + /** + * The project ID the file is associated with. + */ projectId: number; + /** + * The survey ID the file is associated with. + */ surveyId?: number; + /** + * The template submission ID the file is associated with. + * + * @deprecated + */ submissionId?: number; - folder?: string; + /** + * The sub-folder in the project/survey where the file is stored. + * + * Note: For regular/generic file attachments, leave this undefined. + */ + folder?: 'reports' | 'telemetry-credentials'; + /** + * The name of the file. + * + * @type {string} + * @memberof IS3FileKey + */ fileName: string; } diff --git a/api/src/utils/logger.test.ts b/api/src/utils/logger.test.ts index 1af556d09f..1fc7585831 100644 --- a/api/src/utils/logger.test.ts +++ b/api/src/utils/logger.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { describe } from 'mocha'; -import { getLogger, setLogLevel } from './logger'; +import { getLogger, setLogLevel, setLogLevelFile } from './logger'; describe('logger', () => { describe('getLogger', () => { @@ -12,18 +12,62 @@ describe('logger', () => { }); describe('setLogLevel', () => { - it('sets the log level for all loggers', () => { - const myLogger1 = getLogger('myLogger'); - expect(myLogger1.transports[0].level).to.equal('info'); + let currentLogLevel: string | undefined; + + beforeEach(() => { + currentLogLevel = process.env.LOG_LEVEL; + + // Set initial log level value + process.env.LOG_LEVEL = 'info'; + }); + + afterEach(() => { + // Restore the original log level + process.env.LOG_LEVEL = currentLogLevel; + }); + + it('sets the log level for the console transport', () => { + const myLogger1 = getLogger('myLoggerA'); + expect(myLogger1.transports[1].level).to.equal('info'); setLogLevel('debug'); - const myLogger2 = getLogger('myLogger'); - expect(myLogger2.transports[0].level).to.equal('debug'); + const myLogger2 = getLogger('myLoggerA'); + expect(myLogger2.transports[1].level).to.equal('debug'); + + const myNewLogger3 = getLogger('myNewLoggerA'); + + expect(myNewLogger3.transports[1].level).to.equal('debug'); + }); + }); + + describe('setLogLevelFile', () => { + let currentLogLevelFile: string | undefined; + + beforeEach(() => { + currentLogLevelFile = process.env.LOG_LEVEL_FILE; + + // Set initial log level file value + process.env.LOG_LEVEL_FILE = 'warn'; + }); + + afterEach(() => { + // Restore the original log level + process.env.LOG_LEVEL_FILE = currentLogLevelFile; + }); + + it('sets the log level for the file transport', () => { + const myLogger4 = getLogger('myLoggerB'); + expect(myLogger4.transports[0].level).to.equal('warn'); + + setLogLevelFile('error'); + + const myLogger5 = getLogger('myLoggerB'); + expect(myLogger5.transports[0].level).to.equal('error'); - const myNewLogger = getLogger('myNewLogger'); + const myNewLogger6 = getLogger('myNewLoggerB'); - expect(myNewLogger.transports[0].level).to.equal('debug'); + expect(myNewLogger6.transports[0].level).to.equal('error'); }); }); }); diff --git a/api/src/utils/logger.ts b/api/src/utils/logger.ts index e9d493fb21..cefe86ba19 100644 --- a/api/src/utils/logger.ts +++ b/api/src/utils/logger.ts @@ -1,4 +1,5 @@ import winston from 'winston'; +import DailyRotateFile from 'winston-daily-rotate-file'; /** * Get or create a logger for the given `logLabel`. @@ -43,17 +44,46 @@ import winston from 'winston'; * * ...etc * - * Valid `LOG_LEVEL` values (from least logging to most logging) (default: info): - * silent, error, warn, info, debug, silly + * Environment Variables: + * + * LOG_LEVEL - Defines the level of logging that the logger will output to the console. (default: debug) + * + * LOG_LEVEL_FILE - Defines the level of logging that the logger will output to persistent log files. (default: debug) + * + * Valid logging level values (from least logging to most logging) - silent, error, warn, info, debug, silly * * @param {string} logLabel common label for the instance of the logger. * @returns */ export const getLogger = function (logLabel: string) { - return winston.loggers.get(logLabel || 'default', { - transports: [ + const transports = []; + + // Output logs to file + transports.push( + new DailyRotateFile({ + dirname: process.env.LOG_FILE_DIR || 'data/logs', + filename: process.env.LOG_FILE_NAME || 'sims-api-%DATE%.log', + datePattern: process.env.LOG_FILE_DATE_PATTERN || 'YYYY-MM-DD-HH', + maxSize: process.env.LOG_FILE_MAX_SIZE || '50m', + maxFiles: process.env.LOG_FILE_MAX_FILES || '10', + level: process.env.LOG_LEVEL_FILE || 'debug', + format: winston.format.combine( + winston.format((info) => { + const { timestamp, level, ...rest } = info; + // Return the properties of info in a specific order + return { timestamp, level, logger: logLabel, ...rest }; + })(), + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.prettyPrint({ colorize: false, depth: 10 }) + ) + }) + ); + + if (process.env.NODE_ENV !== 'production') { + // Additionally output logs to console in non-production environments + transports.push( new winston.transports.Console({ - level: process.env.LOG_LEVEL || 'info', + level: process.env.LOG_LEVEL || 'debug', format: winston.format.combine( winston.format((info) => { const { timestamp, level, ...rest } = info; @@ -61,11 +91,13 @@ export const getLogger = function (logLabel: string) { return { timestamp, level, logger: logLabel, ...rest }; })(), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - winston.format.prettyPrint({ colorize: true, depth: 5 }) + winston.format.prettyPrint({ colorize: true, depth: 10 }) ) }) - ] - }); + ); + } + + return winston.loggers.get(logLabel || 'default', { transports: transports }); }; export const WinstonLogLevels = ['silent', 'error', 'warn', 'info', 'debug', 'silly'] as const; @@ -73,7 +105,7 @@ export const WinstonLogLevels = ['silent', 'error', 'warn', 'info', 'debug', 'si export type WinstonLogLevel = (typeof WinstonLogLevels)[number]; /** - * Set the winston logger log level. + * Set the winston logger log level for the console transport * * @param {WinstonLogLevel} logLevel */ @@ -81,8 +113,25 @@ export const setLogLevel = (logLevel: WinstonLogLevel) => { // Update env var for future loggers process.env.LOG_LEVEL = logLevel; - // Update existing loggers + if (process.env.NODE_ENV !== 'production') { + // Update console transport log level, which is the second transport in non-production environments + winston.loggers.loggers.forEach((logger) => { + logger.transports[1].level = logLevel; + }); + } +}; + +/** + * Set the winston logger log level for the file transport. + * + * @param {WinstonLogLevel} logLevel + */ +export const setLogLevelFile = (logLevelFile: WinstonLogLevel) => { + // Update env var for future loggers + process.env.LOG_LEVEL_FILE = logLevelFile; + + // Update file transport log level, which is the first transport in all environments winston.loggers.loggers.forEach((logger) => { - logger.transports[0].level = logLevel; + logger.transports[0].level = logLevelFile; }); }; diff --git a/api/src/utils/media/media-utils.test.ts b/api/src/utils/media/media-utils.test.ts index 7300f60996..2ccef9cd5b 100644 --- a/api/src/utils/media/media-utils.test.ts +++ b/api/src/utils/media/media-utils.test.ts @@ -4,6 +4,7 @@ import chai, { expect } from 'chai'; import { describe } from 'mocha'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; +import { TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE } from '../../constants/attachments'; import { ArchiveFile, MediaFile } from './media-file'; import * as media_utils from './media-utils'; @@ -212,51 +213,296 @@ describe('parseS3File', () => { }); }); -describe('checkFileForKeyx', () => { - const validKeyxFile = { - originalname: 'test.keyx', - mimetype: 'application/octet-stream', - buffer: Buffer.alloc(0) - } as unknown as Express.Multer.File; - - const invalidFile = { - originalname: 'test.txt', - mimetype: 'text/plain', - buffer: Buffer.alloc(0) - } as unknown as Express.Multer.File; - - const zipFile = { - originalname: 'test.zip', - mimetype: 'application/zip', - buffer: Buffer.alloc(0) - } as unknown as Express.Multer.File; +describe('isValidTelementryCredentialFile', () => { + it('should return true if the file extension is .keyx', () => { + const validKeyxFile = { + originalname: 'test.keyx', + mimetype: 'application/octet-stream', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + + expect(media_utils.checkFileForKeyx(validKeyxFile)).to.eql({ + type: TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.KEYX + }); + }); + + it('should return false if the file is not a .keyx or zip mimetype', () => { + const invalidFile = { + originalname: 'test.txt', + mimetype: 'text/plain', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + + const multerFile = { ...invalidFile, buffer: Buffer.alloc(0) }; + expect(media_utils.checkFileForKeyx(multerFile)).to.eql({ + type: 'unknown', + error: 'File is neither a .keyx file, nor an archive containing only .keyx files' + }); + }); + + it('should return false if the file is an empty zip file', () => { + const zipFile = { + originalname: 'test.zip', + mimetype: 'application/zip', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + + const emptyZipFile = new AdmZip(); + const multerFile = { ...zipFile, buffer: emptyZipFile.toBuffer() }; + expect(media_utils.checkFileForKeyx(multerFile)).to.eql({ + type: 'unknown', + error: 'File is an archive that contains no content' + }); + }); + + it('should return false if the zip file contains any non .keyx files', () => { + const zipFile = { + originalname: 'test.zip', + mimetype: 'application/zip', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + + const invalidZipFile = new AdmZip(); + invalidZipFile.addFile('test.txt', Buffer.alloc(0)); + const multerFile = { ...zipFile, buffer: invalidZipFile.toBuffer() }; + expect(media_utils.checkFileForKeyx(multerFile)).to.eql({ + type: 'unknown', + error: 'File is an archive that contains non .keyx files' + }); + }); + + it('should return true if the zip file contains only .keyx files', () => { + const zipFile = { + originalname: 'test.zip', + mimetype: 'application/zip', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + + const validZipFile = new AdmZip(); + validZipFile.addFile('test.keyx', Buffer.alloc(0)); + const multerFile = { ...zipFile, buffer: validZipFile.toBuffer() }; + expect(media_utils.checkFileForKeyx(multerFile)).to.eql({ + type: TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.KEYX + }); + }); + + it('should return true if the file extension is .cfg', () => { + const validCfgFile = { + originalname: 'test.cfg', + mimetype: 'application/octet-stream', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + + expect(media_utils.checkFileForCfg(validCfgFile)).to.eql({ + type: TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.CFG + }); + }); + + it('should return false if the file is not a .cfg or zip mimetype', () => { + const invalidFile = { + originalname: 'test.txt', + mimetype: 'text/plain', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + + const multerFile = { ...invalidFile, buffer: Buffer.alloc(0) }; + expect(media_utils.checkFileForCfg(multerFile)).to.eql({ + type: 'unknown', + error: 'File is neither a .cfg file, nor an archive containing only .cfg files' + }); + }); + + it('should return false if the file is an empty zip file', () => { + const zipFile = { + originalname: 'test.zip', + mimetype: 'application/zip', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + + const emptyZipFile = new AdmZip(); + const multerFile = { ...zipFile, buffer: emptyZipFile.toBuffer() }; + expect(media_utils.checkFileForCfg(multerFile)).to.eql({ + type: 'unknown', + error: 'File is an archive that contains no content' + }); + }); + + it('should return false if the zip file contains any non .cfg files', () => { + const zipFile = { + originalname: 'test.zip', + mimetype: 'application/zip', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + + const invalidZipFile = new AdmZip(); + invalidZipFile.addFile('test.txt', Buffer.alloc(0)); + const multerFile = { ...zipFile, buffer: invalidZipFile.toBuffer() }; + expect(media_utils.checkFileForCfg(multerFile)).to.eql({ + type: 'unknown', + error: 'File is an archive that contains non .cfg files' + }); + }); + it('should return true if the zip file contains only .cfg files', () => { + const zipFile = { + originalname: 'test.zip', + mimetype: 'application/zip', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + + const validZipFile = new AdmZip(); + validZipFile.addFile('test.cfg', Buffer.alloc(0)); + const multerFile = { ...zipFile, buffer: validZipFile.toBuffer() }; + expect(media_utils.checkFileForCfg(multerFile)).to.eql({ + type: TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.CFG + }); + }); +}); + +describe('checkFileForKeyx', () => { it('should return true if the file extension is .keyx', () => { - expect(media_utils.checkFileForKeyx(validKeyxFile)).to.equal(true); + const validKeyxFile = { + originalname: 'test.keyx', + mimetype: 'application/octet-stream', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + + expect(media_utils.checkFileForKeyx(validKeyxFile)).to.eql({ + type: TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.KEYX + }); }); it('should return false if the file is not a .keyx or zip mimetype', () => { + const invalidFile = { + originalname: 'test.txt', + mimetype: 'text/plain', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + const multerFile = { ...invalidFile, buffer: Buffer.alloc(0) }; - expect(media_utils.checkFileForKeyx(multerFile)).to.equal(false); + expect(media_utils.checkFileForKeyx(multerFile)).to.eql({ + type: 'unknown', + error: 'File is neither a .keyx file, nor an archive containing only .keyx files' + }); }); it('should return false if the file is an empty zip file', () => { + const zipFile = { + originalname: 'test.zip', + mimetype: 'application/zip', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + const emptyZipFile = new AdmZip(); const multerFile = { ...zipFile, buffer: emptyZipFile.toBuffer() }; - expect(media_utils.checkFileForKeyx(multerFile)).to.equal(false); + expect(media_utils.checkFileForKeyx(multerFile)).to.eql({ + type: 'unknown', + error: 'File is an archive that contains no content' + }); }); - it('should return false if the zip file contains any non-keyx files', () => { + it('should return false if the zip file contains any non .keyx files', () => { + const zipFile = { + originalname: 'test.zip', + mimetype: 'application/zip', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + const invalidZipFile = new AdmZip(); invalidZipFile.addFile('test.txt', Buffer.alloc(0)); const multerFile = { ...zipFile, buffer: invalidZipFile.toBuffer() }; - expect(media_utils.checkFileForKeyx(multerFile)).to.equal(false); + expect(media_utils.checkFileForKeyx(multerFile)).to.eql({ + type: 'unknown', + error: 'File is an archive that contains non .keyx files' + }); }); it('should return true if the zip file contains only .keyx files', () => { + const zipFile = { + originalname: 'test.zip', + mimetype: 'application/zip', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + const validZipFile = new AdmZip(); validZipFile.addFile('test.keyx', Buffer.alloc(0)); const multerFile = { ...zipFile, buffer: validZipFile.toBuffer() }; - expect(media_utils.checkFileForKeyx(multerFile)).to.equal(true); + expect(media_utils.checkFileForKeyx(multerFile)).to.eql({ + type: TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.KEYX + }); + }); +}); + +describe('checkFileForCfg', () => { + it('should return true if the file extension is .cfg', () => { + const validCfgFile = { + originalname: 'test.cfg', + mimetype: 'application/octet-stream', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + + expect(media_utils.checkFileForCfg(validCfgFile)).to.eql({ + type: TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.CFG + }); + }); + + it('should return false if the file is not a .cfg or zip mimetype', () => { + const invalidFile = { + originalname: 'test.txt', + mimetype: 'text/plain', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + + const multerFile = { ...invalidFile, buffer: Buffer.alloc(0) }; + expect(media_utils.checkFileForCfg(multerFile)).to.eql({ + type: 'unknown', + error: 'File is neither a .cfg file, nor an archive containing only .cfg files' + }); + }); + + it('should return false if the file is an empty zip file', () => { + const zipFile = { + originalname: 'test.zip', + mimetype: 'application/zip', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + + const emptyZipFile = new AdmZip(); + const multerFile = { ...zipFile, buffer: emptyZipFile.toBuffer() }; + expect(media_utils.checkFileForCfg(multerFile)).to.eql({ + type: 'unknown', + error: 'File is an archive that contains no content' + }); + }); + + it('should return false if the zip file contains any non .cfg files', () => { + const zipFile = { + originalname: 'test.zip', + mimetype: 'application/zip', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + + const invalidZipFile = new AdmZip(); + invalidZipFile.addFile('test.txt', Buffer.alloc(0)); + const multerFile = { ...zipFile, buffer: invalidZipFile.toBuffer() }; + expect(media_utils.checkFileForCfg(multerFile)).to.eql({ + type: 'unknown', + error: 'File is an archive that contains non .cfg files' + }); + }); + + it('should return true if the zip file contains only .cfg files', () => { + const zipFile = { + originalname: 'test.zip', + mimetype: 'application/zip', + buffer: Buffer.alloc(0) + } as unknown as Express.Multer.File; + + const validZipFile = new AdmZip(); + validZipFile.addFile('test.cfg', Buffer.alloc(0)); + const multerFile = { ...zipFile, buffer: validZipFile.toBuffer() }; + expect(media_utils.checkFileForCfg(multerFile)).to.eql({ + type: TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.CFG + }); }); }); diff --git a/api/src/utils/media/media-utils.ts b/api/src/utils/media/media-utils.ts index 8f6f724051..72487a5627 100644 --- a/api/src/utils/media/media-utils.ts +++ b/api/src/utils/media/media-utils.ts @@ -1,6 +1,7 @@ import { GetObjectCommandOutput } from '@aws-sdk/client-s3'; import AdmZip from 'adm-zip'; import mime from 'mime'; +import { TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE } from '../../constants/attachments'; import { ArchiveFile, MediaFile } from './media-file'; /** @@ -125,30 +126,127 @@ export const isZipMimetype = (mimetype: string): boolean => { ); }; +/** + * Checks if the file is a valid telemetry credential file. + * + * @param {Express.Multer.File} file + * @return {*} {({ + * type: 'unknown' | TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE; + * error?: string; + * })} + */ +export const isValidTelementryCredentialFile = ( + file: Express.Multer.File +): { + type: 'unknown' | TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE; + error?: string; +} => { + const isKeyX = checkFileForKeyx(file); + + if (isKeyX.error === undefined) { + return isKeyX; + } + + const isCfg = checkFileForCfg(file); + + if (isCfg.error === undefined) { + return isCfg; + } + + return { + type: 'unknown', + error: 'The file is neither a .keyx or .cfg file, nor is it an archive containing only files of these types.' + }; +}; + /** * Returns true if the file is a keyx file, or a zip that contains only keyx files. * * @export * @param {Express.Multer.File} file - * @return {*} {boolean} + * @return {*} {({ + * type: 'unknown' | 'keyx'; + * error?: string; + * })} */ -export function checkFileForKeyx(file: Express.Multer.File): boolean { +export const checkFileForKeyx = ( + file: Express.Multer.File +): { + type: 'unknown' | TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.KEYX; + error?: string; +} => { // File is a KeyX file if it ends in '.keyx' - if (file?.originalname.endsWith('.keyx')) { - return true; + if (file.originalname.endsWith('.keyx')) { + return { type: TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.KEYX }; } + const mimeType = mime.getType(file.originalname) ?? ''; if (!isZipMimetype(mimeType)) { // File cannot be a KeyX file, since it is not an archive nor does it have a .keyx extension - return false; + return { + type: 'unknown', + error: 'File is neither a .keyx file, nor an archive containing only .keyx files' + }; } const zipEntries = parseUnknownZipFile(file.buffer); if (zipEntries.length === 0) { // File is a zip file, but it is empty - return false; + return { type: 'unknown', error: 'File is an archive that contains no content' }; } // Return false if any of the files in the zip are not keyx files - return zipEntries.every((zipEntry) => zipEntry.fileName.endsWith('.keyx')); -} + const result = zipEntries.every((zipEntry) => zipEntry.fileName.endsWith('.keyx')); + + if (!result) { + return { type: 'unknown', error: 'File is an archive that contains non .keyx files' }; + } + + return { type: TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.KEYX }; +}; + +/** + * Returns true if the file is a cfg file, or a zip that contains only cfg files. + * + * @export + * @param {Express.Multer.File} file + * @return {*} {({ + * type: 'unknown' | TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.CFG; + * error?: string; + * })} + */ +export const checkFileForCfg = ( + file: Express.Multer.File +): { + type: 'unknown' | TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.CFG; + error?: string; +} => { + // File is a Cfg file if it ends in '.cfg' + if (file?.originalname.endsWith('.cfg')) { + return { type: TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.CFG }; + } + + const mimeType = mime.getType(file.originalname) ?? ''; + if (!isZipMimetype(mimeType)) { + // File cannot be a Cfg file, since it is not an archive nor does it have a .cfg extension + return { + type: 'unknown', + error: 'File is neither a .cfg file, nor an archive containing only .cfg files' + }; + } + + const zipEntries = parseUnknownZipFile(file.buffer); + if (zipEntries.length === 0) { + // File is a zip file, but it is empty + return { type: 'unknown', error: 'File is an archive that contains no content' }; + } + + // Return false if any of the files in the zip are not cfg files + const result = zipEntries.every((zipEntry) => zipEntry.fileName.endsWith('.cfg')); + + if (!result) { + return { type: 'unknown', error: 'File is an archive that contains non .cfg files' }; + } + + return { type: TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.CFG }; +}; diff --git a/api/src/utils/spatial-utils.test.ts b/api/src/utils/spatial-utils.test.ts index e602ac6384..fd03427cd9 100644 --- a/api/src/utils/spatial-utils.test.ts +++ b/api/src/utils/spatial-utils.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { describe } from 'mocha'; -import { parseLatLongString, parseUTMString, utmToLatLng } from './spatial-utils'; +import { parseUTMString, utmToLatLng } from './spatial-utils'; describe('parseUTMString', () => { it('returns null when no UTM string provided', async () => { @@ -95,53 +95,6 @@ describe('parseUTMString', () => { }); }); -describe('parseLatLongString', () => { - it('returns null when no LatLong string provided', async () => { - expect(parseLatLongString(null as unknown as string)).to.be.null; - expect(parseLatLongString('')).to.be.null; - }); - - it('returns null when provided LatLong string has invalid format', () => { - expect(parseLatLongString('49.1.2 -120')).to.be.null; - expect(parseLatLongString('49.49 -120.1.2')).to.be.null; - expect(parseLatLongString('badLatitude 120')).to.be.null; - expect(parseLatLongString('-49 badLongitude')).to.be.null; - expect(parseLatLongString('49 -120 extra')).to.be.null; - expect(parseLatLongString('')).to.be.null; - expect(parseLatLongString('not a lat long string')).to.be.null; - }); - - it('returns null when latitude is too small', async () => { - const result = parseLatLongString('-91 0'); - - expect(result).to.be.null; - }); - - it('returns null when latitude is too large', async () => { - const result = parseLatLongString('91 0'); - - expect(result).to.be.null; - }); - - it('returns null when longitude is too small', async () => { - const result = parseLatLongString('0 -181'); - - expect(result).to.be.null; - }); - - it('returns null when longitude is too large', async () => { - const result = parseLatLongString('0 181'); - - expect(result).to.be.null; - }); - - it('returns parsed lat long when lat long string is valid', async () => { - const result = parseLatLongString('49.123 -120.123'); - - expect(result).to.eql({ lat: 49.123, long: -120.123 }); - }); -}); - describe('utmToLatLng', () => { it('returns lat, long when zone_letter is provided', async () => { const verbatimCoordinates = { diff --git a/api/src/utils/spatial-utils.ts b/api/src/utils/spatial-utils.ts index c25d6cb12d..8aa377022c 100644 --- a/api/src/utils/spatial-utils.ts +++ b/api/src/utils/spatial-utils.ts @@ -90,38 +90,6 @@ export interface ILatLong { long: number; } -const LAT_LONG_STRING_FORMAT = RegExp(/^[+-]?(\d*[.])?\d+ [+-]?(\d*[.])?\d+$/i); - -/** - * Parses a `latitude longitude` string of the form: `49.116906 -122.62887` - * - * @export - * @param {string} latLong - * @return {*} {(ILatLong | null)} - */ -export function parseLatLongString(latLong: string): ILatLong | null { - if (!latLong || !LAT_LONG_STRING_FORMAT.test(latLong)) { - // latLong string is null or does not match the expected format - return null; - } - - const latLongParts = latLong.split(' '); - - const lat = Number(latLongParts[0]); - if (lat < -90 || lat > 90) { - // latitude is invalid - return null; - } - - const long = Number(latLongParts[1]); - if (long < -180 || long > 180) { - // longitude is invalid - return null; - } - - return { lat, long }; -} - /** * Function to generate the SQL for insertion of a geometry collection * diff --git a/api/src/utils/xlsx-utils/cell-utils.ts b/api/src/utils/xlsx-utils/cell-utils.ts index d6ef3b16ab..eb2e75f704 100644 --- a/api/src/utils/xlsx-utils/cell-utils.ts +++ b/api/src/utils/xlsx-utils/cell-utils.ts @@ -51,7 +51,7 @@ export function replaceCellDates(cell: CellObject) { } if (isTimeFormatCell(cell)) { - const TimeFormat = 'HH:mm'; + const TimeFormat = 'HH:mm:ss'; cell.v = cellDate.format(TimeFormat); return cell; } diff --git a/api/src/utils/xlsx-utils/column-aliases.ts b/api/src/utils/xlsx-utils/column-aliases.ts new file mode 100644 index 0000000000..6ea8db046f --- /dev/null +++ b/api/src/utils/xlsx-utils/column-aliases.ts @@ -0,0 +1,8 @@ +export const CSV_COLUMN_ALIASES: Record, Uppercase[]> = { + ITIS_TSN: ['TAXON', 'SPECIES', 'TSN'], + LATITUDE: ['LAT'], + LONGITUDE: ['LON', 'LONG', 'LNG'], + DESCRIPTION: ['COMMENT'], + ALIAS: ['NICKNAME'], + MARKING_TYPE: ['TYPE'] +}; diff --git a/api/src/utils/xlsx-utils/column-cell-utils.test.ts b/api/src/utils/xlsx-utils/column-cell-utils.test.ts deleted file mode 100644 index 58afdc0730..0000000000 --- a/api/src/utils/xlsx-utils/column-cell-utils.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { expect } from 'chai'; -import { generateCellValueGetter, getColumnValidatorSpecification } from './column-cell-utils'; -import { IXLSXCSVValidator } from './worksheet-utils'; - -describe('column-validators', () => { - describe('generateCellValueGetter', () => { - it('should return value if property exists in object', () => { - const getValue = generateCellValueGetter('test', 'property'); - const object = { property: true }; - expect(getValue(object)).to.be.true; - }); - - it('should return undefined if property does not exist in object', () => { - const getValue = generateCellValueGetter('test', 'property'); - const object = { bad: true }; - expect(getValue(object)).to.be.undefined; - }); - }); - - describe('getColumnValidatorSpecification', () => { - it('should return specification format', () => { - const columnValidator: IXLSXCSVValidator = { - columnNames: ['TEST', 'COLUMN'], - columnTypes: ['number', 'string'], - columnAliases: { - TEST: ['ALSO TEST'], - COLUMN: ['ALSO COLUMN'] - } - }; - - const spec = getColumnValidatorSpecification(columnValidator); - - expect(spec).to.be.deep.equal([ - { - columnName: 'TEST', - columnType: 'number', - columnAliases: ['ALSO TEST'] - }, - { - columnName: 'COLUMN', - columnType: 'string', - columnAliases: ['ALSO COLUMN'] - } - ]); - }); - - it('should return specification format without aliases if not defined', () => { - const columnValidator: IXLSXCSVValidator = { - columnNames: ['TEST'], - columnTypes: ['number'] - }; - - const spec = getColumnValidatorSpecification(columnValidator); - - expect(spec).to.be.deep.equal([ - { - columnAliases: undefined, - columnName: 'TEST', - columnType: 'number' - } - ]); - }); - }); -}); diff --git a/api/src/utils/xlsx-utils/column-cell-utils.ts b/api/src/utils/xlsx-utils/column-cell-utils.ts deleted file mode 100644 index b90218609f..0000000000 --- a/api/src/utils/xlsx-utils/column-cell-utils.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { IXLSXCSVValidator } from './worksheet-utils'; - -type Row = Record; - -// Taxon / species aliases -const ITIS_TSN = 'ITIS_TSN'; -const TAXON = 'TAXON'; -const SPECIES = 'SPECIES'; -const TSN = 'TSN'; - -// DateTime -const DATE = 'DATE'; -const TIME = 'TIME'; - -// Latitude and aliases -const LATITUDE = 'LATITUDE'; -const LAT = 'LAT'; - -// Longitude and aliases -const LONGITUDE = 'LONGITUDE'; -const LON = 'LON'; -const LONG = 'LONG'; -const LNG = 'LNG'; - -// Comment aliases -const COMMENT = 'COMMENT'; -const DESCRIPTION = 'DESCRIPTION'; - -// Critter nickname and aliases -const ALIAS = 'ALIAS'; -const NICKNAME = 'NICKNAME'; - -// Critter sex -const SEX = 'SEX'; - -// Critter Wildlife Health ID and aliases -const WLH_ID = 'WLH_ID'; -const WILDLIFE_HEALTH_ID = 'WILDLIFE_HEALTH_ID'; - -// Observation sub-count -const COUNT = 'COUNT'; - -/** - * An XLSX validation config for the standard columns of an observation CSV. - */ -export const observationStandardColumnValidator: IXLSXCSVValidator = { - columnNames: [ITIS_TSN, COUNT, DATE, TIME, LATITUDE, LONGITUDE], - columnTypes: ['number', 'number', 'date', 'string', 'number', 'number'], - columnAliases: { - ITIS_TSN: [TAXON, SPECIES, TSN], - LATITUDE: [LAT], - LONGITUDE: [LON, LONG, LNG] - } -}; - -/** - * An XLSX validation config for the standard columns of a critter CSV. - */ -export const critterStandardColumnValidator: IXLSXCSVValidator = { - columnNames: [ITIS_TSN, SEX, ALIAS, WLH_ID, DESCRIPTION], - columnTypes: ['number', 'string', 'string', 'string', 'string'], - columnAliases: { - ITIS_TSN: [TAXON, SPECIES, TSN], - DESCRIPTION: [COMMENT], - ALIAS: [NICKNAME] - } -}; - -/** - * Get column validator specification as a readable format. Useful for error handling and logging. - * - * @param {IXLSXCSVValidator} columnValidator - Standard column validator - * @returns {*} - */ -export const getColumnValidatorSpecification = (columnValidator: IXLSXCSVValidator) => { - return columnValidator.columnNames.map((column, index) => ({ - columnName: column, - columnType: columnValidator.columnTypes[index], - columnAliases: columnValidator.columnAliases?.[column] - })); -}; - -/** - * Generate a row value getter function from array of allowed column values. - * - * @example const getTsnFromRow = generateCellValueGetter(ITIS_TSN, TSN, TAXON, SPECIES); - * - * @param {...string[]} headers - Column headers - * @returns {(row: Row) => T | undefined} Row value getter function - */ -export const generateCellValueGetter = (...headers: string[]) => { - return (row: Row) => { - for (const header of headers) { - if (row[header]) { - return row[header] as T; - } - } - }; -}; - -/** - * Row cell value getters. Attempt to retrive a cell value from a list of known column headers. - * - */ -export const getTsnFromRow = generateCellValueGetter(ITIS_TSN, TSN, TAXON, SPECIES); - -export const getCountFromRow = generateCellValueGetter(COUNT); - -export const getDateFromRow = generateCellValueGetter(DATE); - -export const getTimeFromRow = generateCellValueGetter(TIME); - -export const getLatitudeFromRow = generateCellValueGetter(LATITUDE, LAT); - -export const getLongitudeFromRow = generateCellValueGetter(LONGITUDE, LONG, LON, LNG); - -export const getDescriptionFromRow = generateCellValueGetter(DESCRIPTION, COMMENT); - -export const getAliasFromRow = generateCellValueGetter(ALIAS, NICKNAME); - -export const getWlhIdFromRow = generateCellValueGetter(WLH_ID, WILDLIFE_HEALTH_ID); - -export const getSexFromRow = generateCellValueGetter(SEX); diff --git a/api/src/utils/xlsx-utils/column-validator-utils.test.ts b/api/src/utils/xlsx-utils/column-validator-utils.test.ts new file mode 100644 index 0000000000..46ff4044b0 --- /dev/null +++ b/api/src/utils/xlsx-utils/column-validator-utils.test.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai'; +import { + generateColumnCellGetterFromColumnValidator, + getColumnAliasesFromValidator, + getColumnNamesFromValidator +} from './column-validator-utils'; +import { IXLSXCSVValidator } from './worksheet-utils'; + +const columnValidator = { + NAME: { type: 'string' }, + ID: { type: 'number', aliases: ['IDENTIFIER'] }, + AGE: { type: 'number' }, + BIRTH_DATE: { type: 'date' } +} satisfies IXLSXCSVValidator; + +describe('column-validator-utils', () => { + describe('getColumnNamesFromValidator', () => { + it('should return all column names from validator', () => { + expect(getColumnNamesFromValidator(columnValidator)).to.be.eql(['NAME', 'ID', 'AGE', 'BIRTH_DATE']); + }); + }); + + describe('getColumnAliasesFromValidator', () => { + it('should return all column aliases from validator', () => { + expect(getColumnAliasesFromValidator(columnValidator)).to.be.eql(['IDENTIFIER']); + }); + }); + + describe('generateColumnCellGetterFromColumnValidator', () => { + const getCellValue = generateColumnCellGetterFromColumnValidator(columnValidator); + + it('should return the cell value for a known column name', () => { + expect(getCellValue({ NAME: 'Dr. Steve Brule' }, 'NAME').cell).to.be.eql('Dr. Steve Brule'); + }); + + it('should return the cell value for a known alias name', () => { + expect(getCellValue({ IDENTIFIER: 1 }, 'ID').cell).to.be.eql(1); + }); + + it('should return undefined if row cannot find cell value', () => { + expect(getCellValue({ BAD_NAME: 1 }, 'NAME').cell).to.be.eql(undefined); + }); + + it('should return column name', () => { + expect(getCellValue({ NAME: 1 }, 'NAME').column).to.be.eql('NAME'); + }); + + it('should return column alias name', () => { + expect(getCellValue({ IDENTIFIER: 1 }, 'ID').column).to.be.eql('IDENTIFIER'); + }); + }); +}); diff --git a/api/src/utils/xlsx-utils/column-validator-utils.ts b/api/src/utils/xlsx-utils/column-validator-utils.ts new file mode 100644 index 0000000000..c1f4ff5384 --- /dev/null +++ b/api/src/utils/xlsx-utils/column-validator-utils.ts @@ -0,0 +1,94 @@ +import { Row } from '../../services/import-services/import-csv.interface'; +import { IXLSXCSVColumn, IXLSXCSVValidator } from './worksheet-utils'; + +// TODO: Move the IXLSXCSVValidator type to this file + +/** + * Get column names / headers from column validator. + * + * Note: This actually returns Uppercase[] but for convenience we define the return as string[] + * + * @param {IXLSXCSVValidator} columnValidator + * @returns {*} {string[]} Column names / headers + */ +export const getColumnNamesFromValidator = (columnValidator: IXLSXCSVValidator): string[] => { + return Object.keys(columnValidator); +}; + +/** + * Get flattened list of ALL column aliases from column validator. + * + * Note: This actually returns Uppercase[] but for convenience we define the return as string[] + * + * @param {IXLSXCSVValidator} columnValidator + * @returns {*} {string[]} Column aliases + */ +export const getColumnAliasesFromValidator = (columnValidator: IXLSXCSVValidator): string[] => { + const columnNames = getColumnNamesFromValidator(columnValidator); + + // Return flattened list of column validator aliases + return columnNames.flatMap((columnName) => (columnValidator[columnName] as IXLSXCSVColumn).aliases ?? []); +}; + +/** + * Get column validator specification as a readable format. Useful for error handling and logging. + * + * @param {IXLSXCSVValidator} columnValidator - Standard column validator + * @returns {*} + */ +export const getColumnValidatorSpecification = (columnValidator: IXLSXCSVValidator) => { + // Expected formats of date/time columns + + return Object.keys(columnValidator).map((columnName) => { + const columnSpec: IXLSXCSVColumn = columnValidator[columnName]; + return { + columnName: columnName, + columnType: columnSpec.type, + columnAliases: columnSpec.aliases, + optional: columnSpec.optional + }; + }); +}; + +/** + * Generate a column + cell getter from a column validator. + * + * Note: This will attempt to retrive the column header and cell value from the row by the known header first. + * If not found, it will then attempt to retrieve the value by the column header aliases. + * + * @example + * const getColumnCell = generateColumnCellGetterFromColumnValidator(columnValidator) + * + * const itis_tsn = getColumnCell(row, 'ITIS_TSN').cell + * const tsnColumn = getColumnCell(row, 'ITIS_TSN').column + * + * @template T + * @param {T} columnValidator - Column validator + * @returns {*} + */ +export const generateColumnCellGetterFromColumnValidator = (columnValidator: T) => { + return (row: Row, validatorKey: keyof T): { column: string; cell: J | undefined } => { + // Cast the columnValidatorKey to a string for convienience + const key = validatorKey as string; + + // Attempt to retrieve the column and cell value from the default column name + if (row[key]) { + return { column: key, cell: row[key] }; + } + + const columnSpec = columnValidator[validatorKey] as IXLSXCSVColumn; + + // Get the column aliases + const aliases = columnSpec.aliases ?? []; + + // Loop through the aliases and attempt to retrieve the column and cell value + for (const alias of aliases) { + if (row[alias]) { + return { column: alias, cell: row[alias] }; + } + } + + // Returning the provided key when no match + return { column: key, cell: undefined }; + }; +}; diff --git a/api/src/utils/xlsx-utils/worksheet-utils.test.ts b/api/src/utils/xlsx-utils/worksheet-utils.test.ts index f358b99c3e..067e1c9063 100644 --- a/api/src/utils/xlsx-utils/worksheet-utils.test.ts +++ b/api/src/utils/xlsx-utils/worksheet-utils.test.ts @@ -5,43 +5,43 @@ import xlsx from 'xlsx'; import { IXLSXCSVValidator } from '../xlsx-utils/worksheet-utils'; import * as worksheet_utils from './worksheet-utils'; +const xlsxWorksheet: xlsx.WorkSheet = { + A1: { t: 's', v: 'Species' }, + B1: { t: 's', v: 'Count' }, + C1: { t: 's', v: 'Date' }, + D1: { t: 's', v: 'Time' }, + E1: { t: 's', v: 'Latitude' }, + F1: { t: 's', v: 'Longitude' }, + G1: { t: 's', v: 'Antler Configuration' }, + H1: { t: 's', v: 'Wind Direction' }, + A2: { t: 'n', w: '180703', v: 180703 }, + B2: { t: 'n', w: '1', v: 1 }, + C2: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, + D2: { t: 's', v: '9:01' }, + E2: { t: 'n', w: '-58', v: -58 }, + F2: { t: 'n', w: '-123', v: -123 }, + G2: { t: 's', v: 'more than 3 points' }, + H2: { t: 's', v: 'North' }, + A3: { t: 'n', w: '180596', v: 180596 }, + B3: { t: 'n', w: '2', v: 2 }, + C3: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, + D3: { t: 's', v: '9:02' }, + E3: { t: 'n', w: '-57', v: -57 }, + F3: { t: 'n', w: '-122', v: -122 }, + H3: { t: 's', v: 'North' }, + A4: { t: 'n', w: '180713', v: 180713 }, + B4: { t: 'n', w: '3', v: 3 }, + C4: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, + D4: { t: 's', v: '9:03' }, + E4: { t: 'n', w: '-56', v: -56 }, + F4: { t: 'n', w: '-121', v: -121 }, + H4: { t: 's', v: 'North' }, + '!ref': 'A1:H9' +}; + describe('worksheet-utils', () => { describe('getHeadersUpperCase', () => { it('returns the column headers in UPPERCASE', () => { - const xlsxWorksheet: xlsx.WorkSheet = { - A1: { t: 's', v: 'Species' }, - B1: { t: 's', v: 'Count' }, - C1: { t: 's', v: 'Date' }, - D1: { t: 's', v: 'Time' }, - E1: { t: 's', v: 'Latitude' }, - F1: { t: 's', v: 'Longitude' }, - G1: { t: 's', v: 'Antler Configuration' }, - H1: { t: 's', v: 'Wind Direction' }, - A2: { t: 'n', w: '180703', v: 180703 }, - B2: { t: 'n', w: '1', v: 1 }, - C2: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, - D2: { t: 's', v: '9:01' }, - E2: { t: 'n', w: '-58', v: -58 }, - F2: { t: 'n', w: '-123', v: -123 }, - G2: { t: 's', v: 'more than 3 points' }, - H2: { t: 's', v: 'North' }, - A3: { t: 'n', w: '180596', v: 180596 }, - B3: { t: 'n', w: '2', v: 2 }, - C3: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, - D3: { t: 's', v: '9:02' }, - E3: { t: 'n', w: '-57', v: -57 }, - F3: { t: 'n', w: '-122', v: -122 }, - H3: { t: 's', v: 'North' }, - A4: { t: 'n', w: '180713', v: 180713 }, - B4: { t: 'n', w: '3', v: 3 }, - C4: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, - D4: { t: 's', v: '9:03' }, - E4: { t: 'n', w: '-56', v: -56 }, - F4: { t: 'n', w: '-121', v: -121 }, - H4: { t: 's', v: 'North' }, - '!ref': 'A1:H9' - }; - const result = worksheet_utils.getHeadersUpperCase(xlsxWorksheet); expect(result).to.eql([ @@ -64,13 +64,56 @@ describe('worksheet-utils', () => { it('should validate aliases', () => { const observationCSVColumnValidator: IXLSXCSVValidator = { - columnNames: ['SPECIES', 'COUNT', 'DATE', 'TIME', 'LATITUDE', 'LONGITUDE'], - columnTypes: ['number', 'number', 'date', 'string', 'number', 'number'], - columnAliases: { - LATITUDE: ['LAT'], - LONGITUDE: ['LON', 'LONG', 'LNG'], - SPECIES: ['TAXON'] - } + SPECIES: { type: 'string', aliases: ['TAXON'] }, + COUNT: { type: 'number' }, + DATE: { type: 'string' }, + TIME: { type: 'string' }, + LATITUDE: { type: 'number', aliases: ['LAT'] }, + LONGITUDE: { type: 'number', aliases: ['LON', 'LONG', 'LNG'] } + }; + + const mockWorksheet = {} as unknown as xlsx.WorkSheet; + + const getHeadersUpperCaseStub = sinon + .stub(worksheet_utils, 'getHeadersUpperCase') + .callsFake(() => ['TAXON', 'COUNT', 'DATE', 'TIME', 'LAT', 'LON']); + + const result = worksheet_utils.validateWorksheetHeaders(mockWorksheet, observationCSVColumnValidator); + + expect(getHeadersUpperCaseStub).to.be.calledOnce; + expect(result).to.equal(true); + }); + + it('should validate for missing optional headers', () => { + const observationCSVColumnValidator: IXLSXCSVValidator = { + SPECIES: { type: 'string', aliases: ['TAXON'], optional: true }, + COUNT: { type: 'number', optional: true }, + DATE: { type: 'string' }, + TIME: { type: 'string' }, + LATITUDE: { type: 'number', aliases: ['LAT'] }, + LONGITUDE: { type: 'number', aliases: ['LON', 'LONG', 'LNG'] } + }; + + const mockWorksheet = {} as unknown as xlsx.WorkSheet; + + const getHeadersUpperCaseStub = sinon + .stub(worksheet_utils, 'getHeadersUpperCase') + .callsFake(() => ['DATE', 'TIME', 'LAT', 'LON']); + + const result = worksheet_utils.validateWorksheetHeaders(mockWorksheet, observationCSVColumnValidator); + + expect(getHeadersUpperCaseStub).to.be.calledOnce; + expect(result).to.equal(true); + }); + + it('should succeed for header thats optional but provided', () => { + const observationCSVColumnValidator: IXLSXCSVValidator = { + SPECIES: { type: 'string', aliases: ['TAXON'], optional: true }, + COUNT: { type: 'number' }, + DATE: { type: 'string' }, + TIME: { type: 'string' }, + LATITUDE: { type: 'number', aliases: ['LAT'] }, + LONGITUDE: { type: 'number', aliases: ['LON', 'LONG', 'LNG'] } }; const mockWorksheet = {} as unknown as xlsx.WorkSheet; @@ -87,12 +130,12 @@ describe('worksheet-utils', () => { it('should fail for unknown aliases', () => { const observationCSVColumnValidator: IXLSXCSVValidator = { - columnNames: ['SPECIES', 'COUNT', 'DATE', 'TIME', 'LATITUDE', 'LONGITUDE'], - columnTypes: ['number', 'number', 'date', 'string', 'number', 'number'], - columnAliases: { - LATITUDE: ['LAT'], - LONGITUDE: ['LON', 'LONG', 'LNG'] - } + SPECIES: { type: 'string', aliases: ['TAXON'] }, + COUNT: { type: 'number' }, + DATE: { type: 'string' }, + TIME: { type: 'string' }, + LATITUDE: { type: 'number', aliases: ['LAT'] }, + LONGITUDE: { type: 'number', aliases: ['LON', 'LONG', 'LNG'] } }; const mockWorksheet = {} as unknown as xlsx.WorkSheet; diff --git a/api/src/utils/xlsx-utils/worksheet-utils.ts b/api/src/utils/xlsx-utils/worksheet-utils.ts index 45b68e460b..2023fb46af 100644 --- a/api/src/utils/xlsx-utils/worksheet-utils.ts +++ b/api/src/utils/xlsx-utils/worksheet-utils.ts @@ -1,35 +1,48 @@ import { default as dayjs } from 'dayjs'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; +import { intersection, isUndefined } from 'lodash'; import xlsx, { CellObject } from 'xlsx'; import { getLogger } from '../logger'; import { MediaFile } from '../media/media-file'; import { DEFAULT_XLSX_SHEET_NAME } from '../media/xlsx/xlsx-file'; import { safeToLowerCase } from '../string-utils'; import { replaceCellDates, trimCellWhitespace } from './cell-utils'; +import { + generateColumnCellGetterFromColumnValidator, + getColumnAliasesFromValidator, + getColumnNamesFromValidator +} from './column-validator-utils'; + +dayjs.extend(customParseFormat); const defaultLog = getLogger('src/utils/xlsx-utils/worksheet-utils'); -export interface IXLSXCSVValidator { +export interface IXLSXCSVColumn { /** - * Uppercase column headers - * - * @see column-cell-utils.ts + * Supported column cell types * + * time: HH:mm:ss */ - columnNames: Uppercase[]; + type: 'string' | 'number' | 'date'; /** - * Supported column cell types + * Allowed aliases / mappings for column headers. * */ - columnTypes: Array<'string' | 'number' | 'date'>; + aliases?: Uppercase[]; /** - * Allowed aliases / mappings for column headers. + * Column is optional. * */ - columnAliases?: Record, Uppercase[]>; + optional?: true; +} + +// Record with column name and column spec +export interface IXLSXCSVValidator { + [columnName: Uppercase]: IXLSXCSVColumn; } /** - * Returns true if the given cell is a date type cell. + * Construct the XLSX workbook. * * @export * @param {MediaFile} file @@ -195,15 +208,21 @@ export const getWorksheetRowObjects = (worksheet: xlsx.WorkSheet): Record { - const { columnNames, columnAliases } = columnValidator; + // Get column names and aliases from validator + const validatorHeaders = getColumnNamesFromValidator(columnValidator); + // Get column names from actual worksheet const worksheetHeaders = getHeadersUpperCase(worksheet); - return columnNames.every((expectedHeader) => { - return ( - columnAliases?.[expectedHeader]?.some((alias) => worksheetHeaders.includes(alias)) || - worksheetHeaders.includes(expectedHeader) - ); + // Check that every validator header has matching header or alias in worksheet + return validatorHeaders.every((header) => { + const columnSpec = columnValidator[header as keyof typeof columnValidator]; + + const aliases = columnSpec?.aliases ?? []; + const columnHeaderAndAliases = [header, ...aliases]; + + // All column headers exist or only missing optional headers + return intersection(columnHeaderAndAliases, worksheetHeaders).length || columnSpec.optional; }); }; @@ -219,22 +238,44 @@ export const validateWorksheetColumnTypes = ( worksheet: xlsx.WorkSheet, columnValidator: IXLSXCSVValidator ): boolean => { - const rowValueTypes: string[] = columnValidator.columnTypes; - const worksheetRows = getWorksheetRows(worksheet); + const worksheetRows = getWorksheetRowObjects(worksheet); + const columnNames = getColumnNamesFromValidator(columnValidator); + const getCellValue = generateColumnCellGetterFromColumnValidator(columnValidator); return worksheetRows.every((row) => { - return Object.values(columnValidator.columnNames).every((_, index) => { - const value = row[index]; + return columnNames.every((columnName, index) => { + const value = getCellValue(row, columnName.toUpperCase() as Uppercase).cell; const type = typeof value; - if (rowValueTypes[index] === 'date') { - return dayjs(value).isValid(); + const columnSpec: IXLSXCSVColumn = columnValidator[columnName]; + + let validated = false; + + if (columnSpec.type === 'date') { + validated = dayjs(value).isValid(); + } + + if (columnSpec.type === type) { + validated = true; + } + + // Undefined values only allowed if column spec is set to optional + if (isUndefined(value)) { + validated = Boolean(columnSpec.optional); } - if (rowValueTypes[index] === type) { - return true; + if (!validated) { + defaultLog.debug({ + label: 'validateWorksheetColumnTypes', + details: { + columnName, + columnType: columnSpec.type, + cellValue: value, + rowIndex: index + } + }); } - return false; + return validated; }); }); }; @@ -280,13 +321,16 @@ export const getWorksheetRange = (worksheet: xlsx.WorkSheet): xlsx.Range | undef return xlsx.utils.decode_range(ref); }; + /** * Iterates over the cells in the worksheet and: * - Trims whitespace from cell values. * - Converts `Date` objects to ISO strings. * * https://stackoverflow.com/questions/61789174/how-can-i-remove-all-the-spaces-in-the-cells-of-excelsheet-using-nodejs-code - * @param worksheet + * + * @param {xlsx.WorkSheet} worksheet + * @returns {xlsx.WorkSheet} */ export const prepareWorksheetCells = (worksheet: xlsx.WorkSheet) => { const range = getWorksheetRange(worksheet); @@ -305,11 +349,14 @@ export const prepareWorksheetCells = (worksheet: xlsx.WorkSheet) => { continue; } + // Replace date and time cells cell = replaceCellDates(cell); cell = trimCellWhitespace(cell); } } + + return worksheet; }; /** @@ -349,16 +396,13 @@ export function getNonStandardColumnNamesFromWorksheet( ): string[] { const columns = getHeadersUpperCase(xlsxWorksheet); - let aliasColumns: string[] = []; - - // Create a list of all column names and aliases - if (columnValidator.columnAliases) { - aliasColumns = Object.values(columnValidator.columnAliases).flat(); - } + // Get column headers and aliases + const columnValidatorHeaders = getColumnNamesFromValidator(columnValidator); + const columnValidatorAliases = getColumnAliasesFromValidator(columnValidator); // Combine the column validator headers and all aliases - const standardColumNames = [...columnValidator.columnNames, ...aliasColumns]; + const standardColumnNames = new Set([...columnValidatorHeaders, ...columnValidatorAliases]); // Only return column names not in the validation CSV Column validator (ie: only return the non-standard columns) - return columns.filter((column) => !standardColumNames.includes(column)); + return columns.filter((column) => !standardColumnNames.has(column)); } diff --git a/app/.docker/app/Dockerfile b/app/.docker/app/Dockerfile index e77e9cf6f9..2b4f6e4c55 100644 --- a/app/.docker/app/Dockerfile +++ b/app/.docker/app/Dockerfile @@ -1,5 +1,5 @@ # ######################################################################################################## -# This DockerFile is used for local development (via docker-compose) only. +# This DockerFile is used for local development (via compose.yml) only. # ######################################################################################################## FROM node:20 diff --git a/app/.pipeline/config.js b/app/.pipeline/config.js index dc8b453bc6..df25c0403c 100644 --- a/app/.pipeline/config.js +++ b/app/.pipeline/config.js @@ -121,6 +121,33 @@ const phases = { biohubTaxonPath: '/api/taxonomy/taxon', biohubTaxonTsnPath: '/api/taxonomy/taxon/tsn' }, + 'test-spi': { + namespace: 'af2668-test', + name: `${name}-spi`, + phase: 'test-spi', + changeId: deployChangeId, + suffix: `-test-spi`, + instance: `${name}-test-spi`, + version: `${version}`, + tag: `test-spi-${version}`, + host: staticUrls['test-spi'], + apiHost: staticUrlsAPI['test-spi'], + siteminderLogoutURL: config.siteminderLogoutURL['test-spi'], + maxUploadNumFiles, + maxUploadFileSize, + nodeEnv: 'production', + sso: config.sso['test-spi'], + featureFlags: '', + cpuRequest: '50m', + cpuLimit: '500m', + memoryRequest: '100Mi', + memoryLimit: '500Mi', + replicas: '2', + replicasMax: '2', + backbonePublicApiHost: 'https://api-test-biohub-platform.apps.silver.devops.gov.bc.ca', + biohubTaxonPath: '/api/taxonomy/taxon', + biohubTaxonTsnPath: '/api/taxonomy/taxon/tsn' + }, prod: { namespace: 'af2668-prod', name: `${name}`, diff --git a/app/.vscode/settings.json b/app/.vscode/settings.json index 65a1965328..5e59a3ee78 100644 --- a/app/.vscode/settings.json +++ b/app/.vscode/settings.json @@ -1,3 +1,4 @@ { + "typescript.preferences.importModuleSpecifier": "non-relative", "editor.defaultFormatter": "esbenp.prettier-vscode" } diff --git a/app/package-lock.json b/app/package-lock.json index 7aeb9f5dd4..f114441c7b 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -30,6 +30,7 @@ "@turf/center-of-mass": "^6.5.0", "@turf/centroid": "^6.5.0", "@turf/truncate": "^6.5.0", + "ajv": "^8.16.0", "axios": "^1.6.8", "dayjs": "^1.11.10", "express": "^4.19.2", @@ -55,7 +56,7 @@ "react-router-dom": "^5.3.3", "react-window": "^1.8.6", "request": "^2.88.2", - "shpjs": "^3.6.3", + "shpjs": "^5.0.2", "typescript": "^4.7.4", "uuid": "^8.3.2", "yup": "^0.32.9" @@ -81,7 +82,7 @@ "@types/react-router": "^5.1.20", "@types/react-router-dom": "^5.3.3", "@types/react-window": "^1.8.2", - "@types/shpjs": "^3.4.0", + "@types/shpjs": "^3.4.7", "@types/uuid": "^8.3.0", "@typescript-eslint/eslint-plugin": "~7.6.0", "@typescript-eslint/parser": "~7.6.0", @@ -108,19 +109,10 @@ "npm": ">= 10.0.0" } }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/@adobe/css-tools": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.3.tgz", - "integrity": "sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.0.tgz", + "integrity": "sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==", "dev": true }, "node_modules/@alloc/quick-lru": { @@ -147,12 +139,29 @@ "node": ">=6.0.0" } }, + "node_modules/@apideck/better-ajv-errors": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz", + "integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==", + "dev": true, + "dependencies": { + "json-schema": "^0.4.0", + "jsonpointer": "^5.0.0", + "leven": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, "node_modules/@babel/code-frame": { - "version": "7.24.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", - "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", + "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", "dependencies": { - "@babel/highlight": "^7.24.2", + "@babel/highlight": "^7.24.7", "picocolors": "^1.0.0" }, "engines": { @@ -160,28 +169,28 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.4.tgz", - "integrity": "sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.7.tgz", + "integrity": "sha512-qJzAIcv03PyaWqxRgO4mSU3lihncDT296vnyuE2O8uA4w3UHWI4S3hgeZd1L8W1Bft40w9JxJ2b412iDUFFRhw==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.4.tgz", - "integrity": "sha512-MBVlMXP+kkl5394RBLSxxk/iLTeVGuXTV3cIDXavPpMMqnSnt6apKgan/U8O3USWZCWZT/TbgfEpKa4uMgN4Dg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.7.tgz", + "integrity": "sha512-nykK+LEK86ahTkX/3TgauT0ikKoNCfKHEaZYTUVupJdTLzGNvrblu4u6fa7DhZONAltdf8e662t/abY8idrd/g==", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.24.2", - "@babel/generator": "^7.24.4", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.24.4", - "@babel/parser": "^7.24.4", - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0", + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.24.7", + "@babel/helper-compilation-targets": "^7.24.7", + "@babel/helper-module-transforms": "^7.24.7", + "@babel/helpers": "^7.24.7", + "@babel/parser": "^7.24.7", + "@babel/template": "^7.24.7", + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -197,9 +206,9 @@ } }, "node_modules/@babel/eslint-parser": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.24.1.tgz", - "integrity": "sha512-d5guuzMlPeDfZIbpQ8+g1NaCNuAGBBGNECh0HVqz1sjOeVLh2CEaifuOysCH18URW6R7pqXINvf5PaR/dC6jLQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.24.7.tgz", + "integrity": "sha512-SO5E3bVxDuxyNxM5agFv480YA2HO6ohZbGxbazZdIk3KQOPOGVNw6q78I9/lbviIf95eq6tPozeYnJLbjnC8IA==", "dev": true, "dependencies": { "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", @@ -211,7 +220,7 @@ }, "peerDependencies": { "@babel/core": "^7.11.0", - "eslint": "^7.5.0 || ^8.0.0" + "eslint": "^7.5.0 || ^8.0.0 || ^9.0.0" } }, "node_modules/@babel/eslint-parser/node_modules/eslint-visitor-keys": { @@ -224,11 +233,11 @@ } }, "node_modules/@babel/generator": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.4.tgz", - "integrity": "sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.7.tgz", + "integrity": "sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA==", "dependencies": { - "@babel/types": "^7.24.0", + "@babel/types": "^7.24.7", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" @@ -238,36 +247,37 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", - "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.24.7.tgz", + "integrity": "sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.15.tgz", - "integrity": "sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.24.7.tgz", + "integrity": "sha512-xZeCVVdwb4MsDBkkyZ64tReWYrLRHlMN72vP7Bdm3OUOuyFZExhsHUUnuWnm2/XOlAJzR0LfPpB56WXZn0X/lA==", "dev": true, "dependencies": { - "@babel/types": "^7.22.15" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", - "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.24.7.tgz", + "integrity": "sha512-ctSdRHBi20qWOfy27RUb4Fhp07KSJ3sXcuSvTrXrc4aG8NSYDo1ici3Vhg9bg69y5bj0Mr1lh0aeEgTvc12rMg==", "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-validator-option": "^7.23.5", + "@babel/compat-data": "^7.24.7", + "@babel/helper-validator-option": "^7.24.7", "browserslist": "^4.22.2", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -277,19 +287,19 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.4.tgz", - "integrity": "sha512-lG75yeuUSVu0pIcbhiYMXBXANHrpUPaOfu7ryAzskCgKUHuAxRQI5ssrtmF0X9UXldPlvT0XM/A4F44OXRt6iQ==", - "dev": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-member-expression-to-functions": "^7.23.0", - "@babel/helper-optimise-call-expression": "^7.22.5", - "@babel/helper-replace-supers": "^7.24.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.7.tgz", + "integrity": "sha512-kTkaDl7c9vO80zeX1rJxnuRpEsD5tA81yh11X1gQo+PhSti3JS+7qeZo9U4RHobKRiFPKaGK3svUAeb8D0Q7eg==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-environment-visitor": "^7.24.7", + "@babel/helper-function-name": "^7.24.7", + "@babel/helper-member-expression-to-functions": "^7.24.7", + "@babel/helper-optimise-call-expression": "^7.24.7", + "@babel/helper-replace-supers": "^7.24.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", + "@babel/helper-split-export-declaration": "^7.24.7", "semver": "^6.3.1" }, "engines": { @@ -300,12 +310,12 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.15.tgz", - "integrity": "sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.24.7.tgz", + "integrity": "sha512-03TCmXy2FtXJEZfbXDTSqq1fRJArk7lX9DOFC/47VthYcxyIOx+eXQmdo6DOQvrbpIix+KfXwvuXdFDZHxt+rA==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-annotate-as-pure": "^7.24.7", "regexpu-core": "^5.3.1", "semver": "^6.3.1" }, @@ -317,9 +327,9 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.1.tgz", - "integrity": "sha512-o7SDgTJuvx5vLKD6SFvkydkSMBvahDKGiNJzG22IZYXhiqoe9efY7zocICBgzHV4IRg5wdgl2nEL/tulKIEIbA==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.2.tgz", + "integrity": "sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==", "dev": true, "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", @@ -333,69 +343,74 @@ } }, "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz", + "integrity": "sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==", + "dependencies": { + "@babel/types": "^7.24.7" + }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.24.7.tgz", + "integrity": "sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==", "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" + "@babel/template": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz", + "integrity": "sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz", - "integrity": "sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.24.7.tgz", + "integrity": "sha512-LGeMaf5JN4hAT471eJdBs/GK1DoYIJ5GCtZN/EsL6KUiiDZOvO/eKE11AMZJa2zP4zk4qe9V2O/hxAmkRc8p6w==", "dev": true, "dependencies": { - "@babel/types": "^7.23.0" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.24.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz", - "integrity": "sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz", + "integrity": "sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==", "dependencies": { - "@babel/types": "^7.24.0" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", - "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.7.tgz", + "integrity": "sha512-1fuJEwIrp+97rM4RWdO+qrRsZlAeL1lQJoPqtCYWv0NL115XM93hIH4CSRln2w52SqvmY5hqdtauB6QFCDiZNQ==", "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.20" + "@babel/helper-environment-visitor": "^7.24.7", + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-simple-access": "^7.24.7", + "@babel/helper-split-export-declaration": "^7.24.7", + "@babel/helper-validator-identifier": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -405,35 +420,35 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz", - "integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.24.7.tgz", + "integrity": "sha512-jKiTsW2xmWwxT1ixIdfXUZp+P5yURx2suzLZr5Hi64rURpDYdMW0pv+Uf17EYk2Rd428Lx4tLsnjGJzYKDM/6A==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.0.tgz", - "integrity": "sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.7.tgz", + "integrity": "sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.20.tgz", - "integrity": "sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.24.7.tgz", + "integrity": "sha512-9pKLcTlZ92hNZMQfGCHImUpDOlAgkkpqalWEeftW5FBya75k8Li2ilerxkM/uBEj01iBZXcCIB/bwvDYgWyibA==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-wrap-function": "^7.22.20" + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-environment-visitor": "^7.24.7", + "@babel/helper-wrap-function": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -443,14 +458,14 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.24.1.tgz", - "integrity": "sha512-QCR1UqC9BzG5vZl8BMicmZ28RuUBnHhAMddD8yHFHDRH9lLTZ9uUPehX8ctVPT8l0TKblJidqcgUUKGVrePleQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.24.7.tgz", + "integrity": "sha512-qTAxxBM81VEyoAY0TtLrx1oAEJc09ZK67Q9ljQToqCnA+55eNwCORaxlKyu+rNfX86o8OXRUSNUnrtsAZXM9sg==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-member-expression-to-functions": "^7.23.0", - "@babel/helper-optimise-call-expression": "^7.22.5" + "@babel/helper-environment-visitor": "^7.24.7", + "@babel/helper-member-expression-to-functions": "^7.24.7", + "@babel/helper-optimise-call-expression": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -460,96 +475,98 @@ } }, "node_modules/@babel/helper-simple-access": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", - "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz", + "integrity": "sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz", - "integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.24.7.tgz", + "integrity": "sha512-IO+DLT3LQUElMbpzlatRASEyQtfhSE0+m465v++3jyyXeBTBUjtVZg28/gHeV5mrTJqvEKhKroBGAvhW+qPHiQ==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", + "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz", - "integrity": "sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz", + "integrity": "sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", + "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", - "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.7.tgz", + "integrity": "sha512-yy1/KvjhV/ZCL+SM7hBrvnZJ3ZuT9OuZgIJAGpPEToANvc3iM6iDvBnRjtElWibHU6n8/LPR/EjX9EtIEYO3pw==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz", - "integrity": "sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.24.7.tgz", + "integrity": "sha512-N9JIYk3TD+1vq/wn77YnJOqMtfWhNewNE+DJV4puD2X7Ew9J4JvrzrFDfTfyv5EgEXVy9/Wt8QiOErzEmv5Ifw==", "dev": true, "dependencies": { - "@babel/helper-function-name": "^7.22.5", - "@babel/template": "^7.22.15", - "@babel/types": "^7.22.19" + "@babel/helper-function-name": "^7.24.7", + "@babel/template": "^7.24.7", + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.4.tgz", - "integrity": "sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.7.tgz", + "integrity": "sha512-NlmJJtvcw72yRJRcnCmGvSi+3jDEg8qFu3z0AFoymmzLx5ERVWyzd9kVXr7Th9/8yIJi2Zc6av4Tqz3wFs8QWg==", "dependencies": { - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0" + "@babel/template": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/highlight": { - "version": "7.24.2", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.2.tgz", - "integrity": "sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", + "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-validator-identifier": "^7.24.7", "chalk": "^2.4.2", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" @@ -559,9 +576,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.4.tgz", - "integrity": "sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz", + "integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==", "bin": { "parser": "bin/babel-parser.js" }, @@ -570,13 +587,13 @@ } }, "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.24.4.tgz", - "integrity": "sha512-qpl6vOOEEzTLLcsuqYYo8yDtrTocmu2xkGvgNebvPjT9DTtfFYGmgDqY+rBYXNlqL4s9qLDn6xkrJv4RxAPiTA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.24.7.tgz", + "integrity": "sha512-TiT1ss81W80eQsN+722OaeQMY/G4yTb4G9JrqeiDADs3N8lbPMGldWi9x8tyqCW5NLx1Jh2AvkE6r6QvEltMMQ==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-environment-visitor": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -586,12 +603,12 @@ } }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.24.1.tgz", - "integrity": "sha512-y4HqEnkelJIOQGd+3g1bTeKsA5c6qM7eOn7VggGVbBc0y8MLSKHacwcIE2PplNlQSj0PqS9rrXL/nkPVK+kUNg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.24.7.tgz", + "integrity": "sha512-unaQgZ/iRu/By6tsjMZzpeBZjChYfLYry6HrEXPoz3KmfF0sVBQ1l8zKMQ4xRGLWVsjuvB8nQfjNP/DcfEOCsg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -601,14 +618,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.24.1.tgz", - "integrity": "sha512-Hj791Ii4ci8HqnaKHAlLNs+zaLXb0EzSDhiAWp5VNlyvCNymYfacs64pxTxbH1znW/NcArSmwpmG9IKE/TUVVQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.24.7.tgz", + "integrity": "sha512-+izXIbke1T33mY4MSNnrqhPXDz01WYhEf3yF5NbnUtkiNnm+XBZJl3kNfoK6NKmYlz/D07+l2GWVK/QfDkNCuQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/plugin-transform-optional-chaining": "^7.24.1" + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -618,13 +635,13 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.24.1.tgz", - "integrity": "sha512-m9m/fXsXLiHfwdgydIFnpk+7jlVbnvlK5B2EKiPdLUb6WX654ZaaEWJUjk8TftRbZpK0XibovlLWX4KIZhV6jw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.24.7.tgz", + "integrity": "sha512-utA4HuR6F4Vvcr+o4DnjL8fCOlgRFGbeeBEGNg3ZTrLFw6VWG5XmUrvcQ0FjIYMU2ST4XcR2Wsp7t9qOAPnxMg==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-environment-visitor": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -651,14 +668,14 @@ } }, "node_modules/@babel/plugin-proposal-decorators": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.24.1.tgz", - "integrity": "sha512-zPEvzFijn+hRvJuX2Vu3KbEBN39LN3f7tW3MQO2LsIs57B26KU+kUc82BdAktS1VCM6libzh45eKGI65lg0cpA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.24.7.tgz", + "integrity": "sha512-RL9GR0pUG5Kc8BUWLNDm2T5OpYwSX15r98I0IkgmRQTXuELq/OynH8xtMTMvTJFjXbMWFVTKtYkTaYQsuAwQlQ==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.24.1", - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/plugin-syntax-decorators": "^7.24.1" + "@babel/helper-create-class-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/plugin-syntax-decorators": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -807,12 +824,12 @@ } }, "node_modules/@babel/plugin-syntax-decorators": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.24.1.tgz", - "integrity": "sha512-05RJdO/cCrtVWuAaSn1tS3bH8jbsJa/Y1uD186u6J4C/1mnHFxseeuWpsqr9anvo7TUulev7tm7GDwRV+VuhDw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.24.7.tgz", + "integrity": "sha512-Ui4uLJJrRV1lb38zg1yYTmRKmiZLiftDEvZN2iq3kd9kUFU+PttmzTbAFC2ucRk/XJmtek6G23gPsuZbhrT8fQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -846,12 +863,12 @@ } }, "node_modules/@babel/plugin-syntax-flow": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.24.1.tgz", - "integrity": "sha512-sxi2kLTI5DeW5vDtMUsk4mTPwvlUDbjOnoWayhynCwrw4QXRld4QEYwqzY8JmQXaJUtgUuCIurtSRH5sn4c7mA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.24.7.tgz", + "integrity": "sha512-9G8GYT/dxn/D1IIKOUBmGX0mnmj46mGH9NnZyJLwtCpgh5f7D2VbuKodb+2s9m1Yavh1s7ASQN8lf0eqrb1LTw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -861,12 +878,12 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.24.1.tgz", - "integrity": "sha512-IuwnI5XnuF189t91XbxmXeCDz3qs6iDRO7GJ++wcfgeXNs/8FmIlKcpDSXNVyuLQxlwvskmI3Ct73wUODkJBlQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.24.7.tgz", + "integrity": "sha512-Ec3NRUMoi8gskrkBe3fNmEQfxDvY8bgfQpz6jlk/41kX9eUjvpyqWU7PBP/pLAvMaSQjbMNKJmvX57jP+M6bPg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -876,12 +893,12 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.24.1.tgz", - "integrity": "sha512-zhQTMH0X2nVLnb04tz+s7AMuasX8U0FnpE+nHTOhSOINjWMnopoZTxtIKsd45n4GQ/HIZLyfIpoul8e2m0DnRA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.24.7.tgz", + "integrity": "sha512-hbX+lKKeUMGihnK8nvKqmXBInriT3GVjzXKFriV3YC6APGxMbP8RZNFwy91+hocLXq90Mta+HshoB31802bb8A==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -915,12 +932,12 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.1.tgz", - "integrity": "sha512-2eCtxZXf+kbkMIsXS4poTvT4Yu5rXiRa+9xGVT56raghjmBTKMpFNc9R4IDiB4emao9eO22Ox7CxuJG7BgExqA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.7.tgz", + "integrity": "sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1032,12 +1049,12 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.1.tgz", - "integrity": "sha512-Yhnmvy5HZEnHUty6i++gcfH1/l68AHnItFHnaCv6hn9dNh0hQvvQJsxpi4BMBFN5DLeHBuucT/0DgzXif/OyRw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.7.tgz", + "integrity": "sha512-c/+fVeJBB0FeKsFvwytYiUD+LBvhHjGSI0g446PRGdSVGZLRNArBUno2PETbAly3tpiNAQR5XaZ+JslxkotsbA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1063,12 +1080,12 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.24.1.tgz", - "integrity": "sha512-ngT/3NkRhsaep9ck9uj2Xhv9+xB1zShY3tM3g6om4xxCELwCDN4g4Aq5dRn48+0hasAql7s2hdBOysCfNpr4fw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.24.7.tgz", + "integrity": "sha512-Dt9LQs6iEY++gXUwY03DNFat5C2NbO48jj+j/bSAz6b3HgPs39qcPiYt77fDObIcFwj3/C2ICX9YMwGflUoSHQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1078,14 +1095,14 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.24.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.24.3.tgz", - "integrity": "sha512-Qe26CMYVjpQxJ8zxM1340JFNjZaF+ISWpr1Kt/jGo+ZTUzKkfw/pphEWbRCb+lmSM6k/TOgfYLvmbHkUQ0asIg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.24.7.tgz", + "integrity": "sha512-o+iF77e3u7ZS4AoAuJvapz9Fm001PuD2V3Lp6OSE4FYQke+cSewYtnek+THqGRWyQloRCyvWL1OkyfNEl9vr/g==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-remap-async-to-generator": "^7.22.20", + "@babel/helper-environment-visitor": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-remap-async-to-generator": "^7.24.7", "@babel/plugin-syntax-async-generators": "^7.8.4" }, "engines": { @@ -1096,14 +1113,14 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.24.1.tgz", - "integrity": "sha512-AawPptitRXp1y0n4ilKcGbRYWfbbzFWz2NqNu7dacYDtFtz0CMjG64b3LQsb3KIgnf4/obcUL78hfaOS7iCUfw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.24.7.tgz", + "integrity": "sha512-SQY01PcJfmQ+4Ash7NE+rpbLFbmqA2GPIgqzxfFTL4t1FKRq4zTms/7htKpoCUI9OcFYgzqfmCdH53s6/jn5fA==", "dev": true, "dependencies": { - "@babel/helper-module-imports": "^7.24.1", - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-remap-async-to-generator": "^7.22.20" + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-remap-async-to-generator": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1113,12 +1130,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.24.1.tgz", - "integrity": "sha512-TWWC18OShZutrv9C6mye1xwtam+uNi2bnTOCBUd5sZxyHOiWbU6ztSROofIMrK84uweEZC219POICK/sTYwfgg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.24.7.tgz", + "integrity": "sha512-yO7RAz6EsVQDaBH18IDJcMB1HnrUn2FJ/Jslc/WtPPWcjhpUJXU/rjbwmluzp7v/ZzWcEhTMXELnnsz8djWDwQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1128,12 +1145,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.24.4.tgz", - "integrity": "sha512-nIFUZIpGKDf9O9ttyRXpHFpKC+X3Y5mtshZONuEUYBomAKoM4y029Jr+uB1bHGPhNmK8YXHevDtKDOLmtRrp6g==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.24.7.tgz", + "integrity": "sha512-Nd5CvgMbWc+oWzBsuaMcbwjJWAcp5qzrbg69SZdHSP7AMY0AbWFqFO0WTFCA1jxhMCwodRwvRec8k0QUbZk7RQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1143,13 +1160,13 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.24.1.tgz", - "integrity": "sha512-OMLCXi0NqvJfORTaPQBwqLXHhb93wkBKZ4aNwMl6WtehO7ar+cmp+89iPEQPqxAnxsOKTaMcs3POz3rKayJ72g==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.24.7.tgz", + "integrity": "sha512-vKbfawVYayKcSeSR5YYzzyXvsDFWU2mD8U5TFeXtbCPLFUqe7GyCgvO6XDHzje862ODrOwy6WCPmKeWHbCFJ4w==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.24.1", - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-create-class-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1159,13 +1176,13 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.24.4.tgz", - "integrity": "sha512-B8q7Pz870Hz/q9UgP8InNpY01CSLDSCyqX7zcRuv3FcPl87A2G17lASroHWaCtbdIcbYzOZ7kWmXFKbijMSmFg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.24.7.tgz", + "integrity": "sha512-HMXK3WbBPpZQufbMG4B46A90PkuuhN9vBCb5T8+VAHqvAqvcLi+2cKoukcpmUYkszLhScU3l1iudhrks3DggRQ==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.24.4", - "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-create-class-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-class-static-block": "^7.14.5" }, "engines": { @@ -1176,18 +1193,18 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.24.1.tgz", - "integrity": "sha512-ZTIe3W7UejJd3/3R4p7ScyyOoafetUShSf4kCqV0O7F/RiHxVj/wRaRnQlrGwflvcehNA8M42HkAiEDYZu2F1Q==", - "dev": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-replace-supers": "^7.24.1", - "@babel/helper-split-export-declaration": "^7.22.6", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.24.7.tgz", + "integrity": "sha512-CFbbBigp8ln4FU6Bpy6g7sE8B/WmCmzvivzUC6xDAdWVsjYTXijpuuGJmYkAaoWAzcItGKT3IOAbxRItZ5HTjw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-compilation-targets": "^7.24.7", + "@babel/helper-environment-visitor": "^7.24.7", + "@babel/helper-function-name": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-replace-supers": "^7.24.7", + "@babel/helper-split-export-declaration": "^7.24.7", "globals": "^11.1.0" }, "engines": { @@ -1198,13 +1215,13 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.24.1.tgz", - "integrity": "sha512-5pJGVIUfJpOS+pAqBQd+QMaTD2vCL/HcePooON6pDpHgRp4gNRmzyHTPIkXntwKsq3ayUFVfJaIKPw2pOkOcTw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.24.7.tgz", + "integrity": "sha512-25cS7v+707Gu6Ds2oY6tCkUwsJ9YIDbggd9+cu9jzzDgiNq7hR/8dkzxWfKWnTic26vsI3EsCXNd4iEB6e8esQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/template": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/template": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1214,12 +1231,12 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.1.tgz", - "integrity": "sha512-ow8jciWqNxR3RYbSNVuF4U2Jx130nwnBnhRw6N6h1bOejNkABmcI5X5oz29K4alWX7vf1C+o6gtKXikzRKkVdw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.7.tgz", + "integrity": "sha512-19eJO/8kdCQ9zISOf+SEUJM/bAUIsvY3YDnXZTupUCQ8LgrWnsG/gFB9dvXqdXnRXMAM8fvt7b0CBKQHNGy1mw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1229,13 +1246,13 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.24.1.tgz", - "integrity": "sha512-p7uUxgSoZwZ2lPNMzUkqCts3xlp8n+o05ikjy7gbtFJSt9gdU88jAmtfmOxHM14noQXBxfgzf2yRWECiNVhTCw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.24.7.tgz", + "integrity": "sha512-ZOA3W+1RRTSWvyqcMJDLqbchh7U4NRGqwRfFSVbOLS/ePIP4vHB5e8T8eXcuqyN1QkgKyj5wuW0lcS85v4CrSw==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-create-regexp-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1245,12 +1262,12 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.24.1.tgz", - "integrity": "sha512-msyzuUnvsjsaSaocV6L7ErfNsa5nDWL1XKNnDePLgmz+WdU4w/J8+AxBMrWfi9m4IxfL5sZQKUPQKDQeeAT6lA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.24.7.tgz", + "integrity": "sha512-JdYfXyCRihAe46jUIliuL2/s0x0wObgwwiGxw/UbgJBr20gQBThrokO4nYKgWkD7uBaqM7+9x5TU7NkExZJyzw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1260,12 +1277,12 @@ } }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.24.1.tgz", - "integrity": "sha512-av2gdSTyXcJVdI+8aFZsCAtR29xJt0S5tas+Ef8NvBNmD1a+N/3ecMLeMBgfcK+xzsjdLDT6oHt+DFPyeqUbDA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.24.7.tgz", + "integrity": "sha512-sc3X26PhZQDb3JhORmakcbvkeInvxz+A8oda99lj7J60QRuPZvNAk9wQlTBS1ZynelDrDmTU4pw1tyc5d5ZMUg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3" }, "engines": { @@ -1276,13 +1293,13 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.24.1.tgz", - "integrity": "sha512-U1yX13dVBSwS23DEAqU+Z/PkwE9/m7QQy8Y9/+Tdb8UWYaGNDYwTLi19wqIAiROr8sXVum9A/rtiH5H0boUcTw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.24.7.tgz", + "integrity": "sha512-Rqe/vSc9OYgDajNIK35u7ot+KeCoetqQYFXM4Epf7M7ez3lWlOjrDjrwMei6caCVhfdw+mIKD4cgdGNy5JQotQ==", "dev": true, "dependencies": { - "@babel/helper-builder-binary-assignment-operator-visitor": "^7.22.15", - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1292,12 +1309,12 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.24.1.tgz", - "integrity": "sha512-Ft38m/KFOyzKw2UaJFkWG9QnHPG/Q/2SkOrRk4pNBPg5IPZ+dOxcmkK5IyuBcxiNPyyYowPGUReyBvrvZs7IlQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.24.7.tgz", + "integrity": "sha512-v0K9uNYsPL3oXZ/7F9NNIbAj2jv1whUEtyA6aujhekLs56R++JDQuzRcP2/z4WX5Vg/c5lE9uWZA0/iUoFhLTA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-export-namespace-from": "^7.8.3" }, "engines": { @@ -1308,13 +1325,13 @@ } }, "node_modules/@babel/plugin-transform-flow-strip-types": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.24.1.tgz", - "integrity": "sha512-iIYPIWt3dUmUKKE10s3W+jsQ3icFkw0JyRVyY1B7G4yK/nngAOHLVx8xlhA6b/Jzl/Y0nis8gjqhqKtRDQqHWQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.24.7.tgz", + "integrity": "sha512-cjRKJ7FobOH2eakx7Ja+KpJRj8+y+/SiB3ooYm/n2UJfxu0oEaOoxOinitkJcPqv9KxS0kxTGPUaR7L2XcXDXA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/plugin-syntax-flow": "^7.24.1" + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/plugin-syntax-flow": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1324,13 +1341,13 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.24.1.tgz", - "integrity": "sha512-OxBdcnF04bpdQdR3i4giHZNZQn7cm8RQKcSwA17wAAqEELo1ZOwp5FFgeptWUQXFyT9kwHo10aqqauYkRZPCAg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.24.7.tgz", + "integrity": "sha512-wo9ogrDG1ITTTBsy46oGiN1dS9A7MROBTcYsfS8DtsImMkHk9JXJ3EWQM6X2SUw4x80uGPlwj0o00Uoc6nEE3g==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1340,14 +1357,14 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.24.1.tgz", - "integrity": "sha512-BXmDZpPlh7jwicKArQASrj8n22/w6iymRnvHYYd2zO30DbE277JO20/7yXJT3QxDPtiQiOxQBbZH4TpivNXIxA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.24.7.tgz", + "integrity": "sha512-U9FcnA821YoILngSmYkW6FjyQe2TyZD5pHt4EVIhmcTkrJw/3KqcrRSxuOo5tFZJi7TE19iDyI1u+weTI7bn2w==", "dev": true, "dependencies": { - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-compilation-targets": "^7.24.7", + "@babel/helper-function-name": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1357,12 +1374,12 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.24.1.tgz", - "integrity": "sha512-U7RMFmRvoasscrIFy5xA4gIp8iWnWubnKkKuUGJjsuOH7GfbMkB+XZzeslx2kLdEGdOJDamEmCqOks6e8nv8DQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.24.7.tgz", + "integrity": "sha512-2yFnBGDvRuxAaE/f0vfBKvtnvvqU8tGpMHqMNpTN2oWMKIR3NqFkjaAgGwawhqK/pIN2T3XdjGPdaG0vDhOBGw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-json-strings": "^7.8.3" }, "engines": { @@ -1373,12 +1390,12 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.24.1.tgz", - "integrity": "sha512-zn9pwz8U7nCqOYIiBaOxoQOtYmMODXTJnkxG4AtX8fPmnCRYWBOHD0qcpwS9e2VDSp1zNJYpdnFMIKb8jmwu6g==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.24.7.tgz", + "integrity": "sha512-vcwCbb4HDH+hWi8Pqenwnjy+UiklO4Kt1vfspcQYFhJdpthSnW8XvWGyDZWKNVrVbVViI/S7K9PDJZiUmP2fYQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1388,12 +1405,12 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.24.1.tgz", - "integrity": "sha512-OhN6J4Bpz+hIBqItTeWJujDOfNP+unqv/NJgyhlpSqgBTPm37KkMmZV6SYcOj+pnDbdcl1qRGV/ZiIjX9Iy34w==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.24.7.tgz", + "integrity": "sha512-4D2tpwlQ1odXmTEIFWy9ELJcZHqrStlzK/dAOWYyxX3zT0iXQB6banjgeOJQXzEc4S0E0a5A+hahxPaEFYftsw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" }, "engines": { @@ -1404,12 +1421,12 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.24.1.tgz", - "integrity": "sha512-4ojai0KysTWXzHseJKa1XPNXKRbuUrhkOPY4rEGeR+7ChlJVKxFa3H3Bz+7tWaGKgJAXUWKOGmltN+u9B3+CVg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.24.7.tgz", + "integrity": "sha512-T/hRC1uqrzXMKLQ6UCwMT85S3EvqaBXDGf0FaMf4446Qx9vKwlghvee0+uuZcDUCZU5RuNi4781UQ7R308zzBw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1419,13 +1436,13 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.24.1.tgz", - "integrity": "sha512-lAxNHi4HVtjnHd5Rxg3D5t99Xm6H7b04hUS7EHIXcUl2EV4yl1gWdqZrNzXnSrHveL9qMdbODlLF55mvgjAfaQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.24.7.tgz", + "integrity": "sha512-9+pB1qxV3vs/8Hdmz/CulFB8w2tuu6EB94JZFsjdqxQokwGa9Unap7Bo2gGBGIvPmDIVvQrom7r5m/TCDMURhg==", "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-module-transforms": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1435,14 +1452,14 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.1.tgz", - "integrity": "sha512-szog8fFTUxBfw0b98gEWPaEqF42ZUD/T3bkynW/wtgx2p/XCP55WEsb+VosKceRSd6njipdZvNogqdtI4Q0chw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.7.tgz", + "integrity": "sha512-iFI8GDxtevHJ/Z22J5xQpVqFLlMNstcLXh994xifFwxxGslr2ZXXLWgtBeLctOD63UFDArdvN6Tg8RFw+aEmjQ==", "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-simple-access": "^7.22.5" + "@babel/helper-module-transforms": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-simple-access": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1452,15 +1469,15 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.24.1.tgz", - "integrity": "sha512-mqQ3Zh9vFO1Tpmlt8QPnbwGHzNz3lpNEMxQb1kAemn/erstyqw1r9KeOlOfo3y6xAnFEcOv2tSyrXfmMk+/YZA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.24.7.tgz", + "integrity": "sha512-GYQE0tW7YoaN13qFh3O1NCY4MPkUiAH3fiF7UcV/I3ajmDKEdG3l+UOcbAm4zUE3gnvUU+Eni7XrVKo9eO9auw==", "dev": true, "dependencies": { - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-validator-identifier": "^7.22.20" + "@babel/helper-hoist-variables": "^7.24.7", + "@babel/helper-module-transforms": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-validator-identifier": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1470,13 +1487,13 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.24.1.tgz", - "integrity": "sha512-tuA3lpPj+5ITfcCluy6nWonSL7RvaG0AOTeAuvXqEKS34lnLzXpDb0dcP6K8jD0zWZFNDVly90AGFJPnm4fOYg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.24.7.tgz", + "integrity": "sha512-3aytQvqJ/h9z4g8AsKPLvD4Zqi2qT+L3j7XoFFu1XBlZWEl2/1kWnhmAbxpLgPrHSY0M6UA02jyTiwUVtiKR6A==", "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-module-transforms": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1486,13 +1503,13 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.5.tgz", - "integrity": "sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.24.7.tgz", + "integrity": "sha512-/jr7h/EWeJtk1U/uz2jlsCioHkZk1JJZVcc8oQsJ1dUlaJD83f4/6Zeh2aHt9BIFokHIsSeDfhUmju0+1GPd6g==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-regexp-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1502,12 +1519,12 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.24.1.tgz", - "integrity": "sha512-/rurytBM34hYy0HKZQyA0nHbQgQNFm4Q/BOc9Hflxi2X3twRof7NaE5W46j4kQitm7SvACVRXsa6N/tSZxvPug==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.24.7.tgz", + "integrity": "sha512-RNKwfRIXg4Ls/8mMTza5oPF5RkOW8Wy/WgMAp1/F1yZ8mMbtwXW+HDoJiOsagWrAhI5f57Vncrmr9XeT4CVapA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1517,12 +1534,12 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.24.1.tgz", - "integrity": "sha512-iQ+caew8wRrhCikO5DrUYx0mrmdhkaELgFa+7baMcVuhxIkN7oxt06CZ51D65ugIb1UWRQ8oQe+HXAVM6qHFjw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.24.7.tgz", + "integrity": "sha512-Ts7xQVk1OEocqzm8rHMXHlxvsfZ0cEF2yomUqpKENHWMF4zKk175Y4q8H5knJes6PgYad50uuRmt3UJuhBw8pQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" }, "engines": { @@ -1533,12 +1550,12 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.24.1.tgz", - "integrity": "sha512-7GAsGlK4cNL2OExJH1DzmDeKnRv/LXq0eLUSvudrehVA5Rgg4bIrqEUW29FbKMBRT0ztSqisv7kjP+XIC4ZMNw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.24.7.tgz", + "integrity": "sha512-e6q1TiVUzvH9KRvicuxdBTUj4AdKSRwzIyFFnfnezpCfP2/7Qmbb8qbU2j7GODbl4JMkblitCQjKYUaX/qkkwA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-numeric-separator": "^7.10.4" }, "engines": { @@ -1549,15 +1566,15 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.1.tgz", - "integrity": "sha512-XjD5f0YqOtebto4HGISLNfiNMTTs6tbkFf2TOqJlYKYmbo+mN9Dnpl4SRoofiziuOWMIyq3sZEUqLo3hLITFEA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.7.tgz", + "integrity": "sha512-4QrHAr0aXQCEFni2q4DqKLD31n2DL+RxcwnNjDFkSG0eNQ/xCavnRkfCUjsyqGC2OviNJvZOF/mQqZBw7i2C5Q==", "dev": true, "dependencies": { - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-compilation-targets": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.24.1" + "@babel/plugin-transform-parameters": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1567,13 +1584,13 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.24.1.tgz", - "integrity": "sha512-oKJqR3TeI5hSLRxudMjFQ9re9fBVUU0GICqM3J1mi8MqlhVr6hC/ZN4ttAyMuQR6EZZIY6h/exe5swqGNNIkWQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.24.7.tgz", + "integrity": "sha512-A/vVLwN6lBrMFmMDmPPz0jnE6ZGx7Jq7d6sT/Ev4H65RER6pZ+kczlf1DthF5N0qaPHBsI7UXiE8Zy66nmAovg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-replace-supers": "^7.24.1" + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-replace-supers": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1583,12 +1600,12 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.24.1.tgz", - "integrity": "sha512-oBTH7oURV4Y+3EUrf6cWn1OHio3qG/PVwO5J03iSJmBg6m2EhKjkAu/xuaXaYwWW9miYtvbWv4LNf0AmR43LUA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.24.7.tgz", + "integrity": "sha512-uLEndKqP5BfBbC/5jTwPxLh9kqPWWgzN/f8w6UwAIirAEqiIVJWWY312X72Eub09g5KF9+Zn7+hT7sDxmhRuKA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" }, "engines": { @@ -1599,13 +1616,13 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.1.tgz", - "integrity": "sha512-n03wmDt+987qXwAgcBlnUUivrZBPZ8z1plL0YvgQalLm+ZE5BMhGm94jhxXtA1wzv1Cu2aaOv1BM9vbVttrzSg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.7.tgz", + "integrity": "sha512-tK+0N9yd4j+x/4hxF3F0e0fu/VdcxU18y5SevtyM/PCFlQvXbR0Zmlo2eBrKtVipGNFzpq56o8WsIIKcJFUCRQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", "@babel/plugin-syntax-optional-chaining": "^7.8.3" }, "engines": { @@ -1616,12 +1633,12 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.24.1.tgz", - "integrity": "sha512-8Jl6V24g+Uw5OGPeWNKrKqXPDw2YDjLc53ojwfMcKwlEoETKU9rU0mHUtcg9JntWI/QYzGAXNWEcVHZ+fR+XXg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.24.7.tgz", + "integrity": "sha512-yGWW5Rr+sQOhK0Ot8hjDJuxU3XLRQGflvT4lhlSY0DFvdb3TwKaY26CJzHtYllU0vT9j58hc37ndFPsqT1SrzA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1631,13 +1648,13 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.24.1.tgz", - "integrity": "sha512-tGvisebwBO5em4PaYNqt4fkw56K2VALsAbAakY0FjTYqJp7gfdrgr7YX76Or8/cpik0W6+tj3rZ0uHU9Oil4tw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.24.7.tgz", + "integrity": "sha512-COTCOkG2hn4JKGEKBADkA8WNb35TGkkRbI5iT845dB+NyqgO8Hn+ajPbSnIQznneJTa3d30scb6iz/DhH8GsJQ==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.24.1", - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-create-class-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1647,14 +1664,14 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.24.1.tgz", - "integrity": "sha512-pTHxDVa0BpUbvAgX3Gat+7cSciXqUcY9j2VZKTbSB6+VQGpNgNO9ailxTGHSXlqOnX1Hcx1Enme2+yv7VqP9bg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.24.7.tgz", + "integrity": "sha512-9z76mxwnwFxMyxZWEgdgECQglF2Q7cFLm0kMf8pGwt+GSJsY0cONKj/UuO4bOH0w/uAel3ekS4ra5CEAyJRmDA==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-create-class-features-plugin": "^7.24.1", - "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-create-class-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-private-property-in-object": "^7.14.5" }, "engines": { @@ -1665,12 +1682,12 @@ } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.24.1.tgz", - "integrity": "sha512-LetvD7CrHmEx0G442gOomRr66d7q8HzzGGr4PMHGr+5YIm6++Yke+jxj246rpvsbyhJwCLxcTn6zW1P1BSenqA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.24.7.tgz", + "integrity": "sha512-EMi4MLQSHfd2nrCqQEWxFdha2gBCqU4ZcCng4WBGZ5CJL4bBRW0ptdqqDdeirGZcpALazVVNJqRmsO8/+oNCBA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1680,12 +1697,12 @@ } }, "node_modules/@babel/plugin-transform-react-constant-elements": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.24.1.tgz", - "integrity": "sha512-QXp1U9x0R7tkiGB0FOk8o74jhnap0FlZ5gNkRIWdG3eP+SvMFg118e1zaWewDzgABb106QSKpVsD3Wgd8t6ifA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.24.7.tgz", + "integrity": "sha512-7LidzZfUXyfZ8/buRW6qIIHBY8wAZ1OrY9c/wTr8YhZ6vMPo+Uc/CVFLYY1spZrEQlD4w5u8wjqk5NQ3OVqQKA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1695,12 +1712,12 @@ } }, "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.24.1.tgz", - "integrity": "sha512-mvoQg2f9p2qlpDQRBC7M3c3XTr0k7cp/0+kFKKO/7Gtu0LSw16eKB+Fabe2bDT/UpsyasTBBkAnbdsLrkD5XMw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.24.7.tgz", + "integrity": "sha512-H/Snz9PFxKsS1JLI4dJLtnJgCJRoo0AUm3chP6NYr+9En1JMKloheEiLIhlp5MDVznWo+H3AAC1Mc8lmUEpsgg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1710,16 +1727,16 @@ } }, "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.23.4.tgz", - "integrity": "sha512-5xOpoPguCZCRbo/JeHlloSkTA8Bld1J/E1/kLfD1nsuiW1m8tduTA1ERCgIZokDflX/IBzKcqR3l7VlRgiIfHA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.24.7.tgz", + "integrity": "sha512-+Dj06GDZEFRYvclU6k4bme55GKBEWUmByM/eoKuqg4zTNQHiApWRhQph5fxQB2wAEFvRzL1tOEj1RJ19wJrhoA==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-jsx": "^7.23.3", - "@babel/types": "^7.23.4" + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/plugin-syntax-jsx": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1729,12 +1746,12 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-development": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.22.5.tgz", - "integrity": "sha512-bDhuzwWMuInwCYeDeMzyi7TaBgRQei6DqxhbyniL7/VG4RSS7HtSL2QbY4eESy1KJqlWt8g3xeEBGPuo+XqC8A==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.24.7.tgz", + "integrity": "sha512-QG9EnzoGn+Qar7rxuW+ZOsbWOt56FvvI93xInqsZDC5fsekx1AlIO4KIJ5M+D0p0SqSH156EpmZyXq630B8OlQ==", "dev": true, "dependencies": { - "@babel/plugin-transform-react-jsx": "^7.22.5" + "@babel/plugin-transform-react-jsx": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1744,13 +1761,13 @@ } }, "node_modules/@babel/plugin-transform-react-pure-annotations": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.24.1.tgz", - "integrity": "sha512-+pWEAaDJvSm9aFvJNpLiM2+ktl2Sn2U5DdyiWdZBxmLc6+xGt88dvFqsHiAiDS+8WqUwbDfkKz9jRxK3M0k+kA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.24.7.tgz", + "integrity": "sha512-PLgBVk3fzbmEjBJ/u8kFzOqS9tUeDjiaWud/rRym/yjCo/M9cASPlnrd2ZmmZpQT40fOOrvR8jh+n8jikrOhNA==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1760,12 +1777,12 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.24.1.tgz", - "integrity": "sha512-sJwZBCzIBE4t+5Q4IGLaaun5ExVMRY0lYwos/jNecjMrVCygCdph3IKv0tkP5Fc87e/1+bebAmEAGBfnRD+cnw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.24.7.tgz", + "integrity": "sha512-lq3fvXPdimDrlg6LWBoqj+r/DEWgONuwjuOuQCSYgRroXDH/IdM1C0IZf59fL5cHLpjEH/O6opIRBbqv7ELnuA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-plugin-utils": "^7.24.7", "regenerator-transform": "^0.15.2" }, "engines": { @@ -1776,12 +1793,12 @@ } }, "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.24.1.tgz", - "integrity": "sha512-JAclqStUfIwKN15HrsQADFgeZt+wexNQ0uLhuqvqAUFoqPMjEcFCYZBhq0LUdz6dZK/mD+rErhW71fbx8RYElg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.24.7.tgz", + "integrity": "sha512-0DUq0pHcPKbjFZCfTss/pGkYMfy3vFWydkUBd9r0GHpIyfs2eCDENvqadMycRS9wZCXR41wucAfJHJmwA0UmoQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1791,13 +1808,13 @@ } }, "node_modules/@babel/plugin-transform-runtime": { - "version": "7.24.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.24.3.tgz", - "integrity": "sha512-J0BuRPNlNqlMTRJ72eVptpt9VcInbxO6iP3jaxr+1NPhC0UkKL+6oeX6VXMEYdADnuqmMmsBspt4d5w8Y/TCbQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.24.7.tgz", + "integrity": "sha512-YqXjrk4C+a1kZjewqt+Mmu2UuV1s07y8kqcUf4qYLnoqemhR4gRQikhdAhSVJioMjVTu6Mo6pAbaypEA3jY6fw==", "dev": true, "dependencies": { - "@babel/helper-module-imports": "^7.24.3", - "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", "babel-plugin-polyfill-corejs2": "^0.4.10", "babel-plugin-polyfill-corejs3": "^0.10.1", "babel-plugin-polyfill-regenerator": "^0.6.1", @@ -1811,12 +1828,12 @@ } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.24.1.tgz", - "integrity": "sha512-LyjVB1nsJ6gTTUKRjRWx9C1s9hE7dLfP/knKdrfeH9UPtAGjYGgxIbFfx7xyLIEWs7Xe1Gnf8EWiUqfjLhInZA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.24.7.tgz", + "integrity": "sha512-KsDsevZMDsigzbA09+vacnLpmPH4aWjcZjXdyFKGzpplxhbeB4wYtury3vglQkg6KM/xEPKt73eCjPPf1PgXBA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1826,13 +1843,13 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.24.1.tgz", - "integrity": "sha512-KjmcIM+fxgY+KxPVbjelJC6hrH1CgtPmTvdXAfn3/a9CnWGSTY7nH4zm5+cjmWJybdcPSsD0++QssDsjcpe47g==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.24.7.tgz", + "integrity": "sha512-x96oO0I09dgMDxJaANcRyD4ellXFLLiWhuwDxKZX5g2rWP1bTPkBSwCYv96VDXVT1bD9aPj8tppr5ITIh8hBng==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1842,12 +1859,12 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.24.1.tgz", - "integrity": "sha512-9v0f1bRXgPVcPrngOQvLXeGNNVLc8UjMVfebo9ka0WF3/7+aVUHmaJVT3sa0XCzEFioPfPHZiOcYG9qOsH63cw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.24.7.tgz", + "integrity": "sha512-kHPSIJc9v24zEml5geKg9Mjx5ULpfncj0wRpYtxbvKyTtHCYDkVE3aHQ03FrpEo4gEe2vrJJS1Y9CJTaThA52g==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1857,12 +1874,12 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.24.1.tgz", - "integrity": "sha512-WRkhROsNzriarqECASCNu/nojeXCDTE/F2HmRgOzi7NGvyfYGq1NEjKBK3ckLfRgGc6/lPAqP0vDOSw3YtG34g==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.24.7.tgz", + "integrity": "sha512-AfDTQmClklHCOLxtGoP7HkeMw56k1/bTQjwsfhL6pppo/M4TOBSq+jjBUBLmV/4oeFg4GWMavIl44ZeCtmmZTw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1872,12 +1889,12 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.1.tgz", - "integrity": "sha512-CBfU4l/A+KruSUoW+vTQthwcAdwuqbpRNB8HQKlZABwHRhsdHZ9fezp4Sn18PeAlYxTNiLMlx4xUBV3AWfg1BA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.7.tgz", + "integrity": "sha512-VtR8hDy7YLB7+Pet9IarXjg/zgCMSF+1mNS/EQEiEaUPoFXCVsHG64SIxcaaI2zJgRiv+YmgaQESUfWAdbjzgg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1887,15 +1904,15 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.24.4.tgz", - "integrity": "sha512-79t3CQ8+oBGk/80SQ8MN3Bs3obf83zJ0YZjDmDaEZN8MqhMI760apl5z6a20kFeMXBwJX99VpKT8CKxEBp5H1g==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.24.7.tgz", + "integrity": "sha512-iLD3UNkgx2n/HrjBesVbYX6j0yqn/sJktvbtKKgcaLIQ4bTTQ8obAypc1VpyHPD2y4Phh9zHOaAt8e/L14wCpw==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-create-class-features-plugin": "^7.24.4", - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/plugin-syntax-typescript": "^7.24.1" + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-create-class-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/plugin-syntax-typescript": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1905,12 +1922,12 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.24.1.tgz", - "integrity": "sha512-RlkVIcWT4TLI96zM660S877E7beKlQw7Ig+wqkKBiWfj0zH5Q4h50q6er4wzZKRNSYpfo6ILJ+hrJAGSX2qcNw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.24.7.tgz", + "integrity": "sha512-U3ap1gm5+4edc2Q/P+9VrBNhGkfnf+8ZqppY71Bo/pzZmXhhLdqgaUl6cuB07O1+AQJtCLfaOmswiNbSQ9ivhw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1920,13 +1937,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.24.1.tgz", - "integrity": "sha512-Ss4VvlfYV5huWApFsF8/Sq0oXnGO+jB+rijFEFugTd3cwSObUSnUi88djgR5528Csl0uKlrI331kRqe56Ov2Ng==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.24.7.tgz", + "integrity": "sha512-uH2O4OV5M9FZYQrwc7NdVmMxQJOCCzFeYudlZSzUAHRFeOujQefa92E74TQDVskNHCzOXoigEuoyzHDhaEaK5w==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-create-regexp-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1936,13 +1953,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.24.1.tgz", - "integrity": "sha512-2A/94wgZgxfTsiLaQ2E36XAOdcZmGAaEEgVmxQWwZXWkGhvoHbaqXcKnU8zny4ycpu3vNqg0L/PcCiYtHtA13g==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.24.7.tgz", + "integrity": "sha512-hlQ96MBZSAXUq7ltkjtu3FJCCSMx/j629ns3hA3pXnBXjanNP0LHi+JpPeA81zaWgVK1VGH95Xuy7u0RyQ8kMg==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-create-regexp-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1952,13 +1969,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.24.1.tgz", - "integrity": "sha512-fqj4WuzzS+ukpgerpAoOnMfQXwUHFxXUZUE84oL2Kao2N8uSlvcpnAidKASgsNgzZHBsHWvcm8s9FPWUhAb8fA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.24.7.tgz", + "integrity": "sha512-2G8aAvF4wy1w/AGZkemprdGMRg5o6zPNhbHVImRz3lss55TYCBd6xStN19rt8XJHq20sqV0JbyWjOWwQRwV/wg==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-create-regexp-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1968,27 +1985,27 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.24.4.tgz", - "integrity": "sha512-7Kl6cSmYkak0FK/FXjSEnLJ1N9T/WA2RkMhu17gZ/dsxKJUuTYNIylahPTzqpLyJN4WhDif8X0XK1R8Wsguo/A==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.24.4", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-validator-option": "^7.23.5", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.24.4", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.24.1", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.24.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.24.1", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.24.7.tgz", + "integrity": "sha512-1YZNsc+y6cTvWlDHidMBsQZrZfEFjRIo/BZCT906PMdzOyXtSLTgqGdrpcuTDCXyd11Am5uQULtDIcCfnTc8fQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.24.7", + "@babel/helper-compilation-targets": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-validator-option": "^7.24.7", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.24.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.24.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.24.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.24.7", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-namespace-from": "^7.8.3", - "@babel/plugin-syntax-import-assertions": "^7.24.1", - "@babel/plugin-syntax-import-attributes": "^7.24.1", + "@babel/plugin-syntax-import-assertions": "^7.24.7", + "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", @@ -2000,54 +2017,54 @@ "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.24.1", - "@babel/plugin-transform-async-generator-functions": "^7.24.3", - "@babel/plugin-transform-async-to-generator": "^7.24.1", - "@babel/plugin-transform-block-scoped-functions": "^7.24.1", - "@babel/plugin-transform-block-scoping": "^7.24.4", - "@babel/plugin-transform-class-properties": "^7.24.1", - "@babel/plugin-transform-class-static-block": "^7.24.4", - "@babel/plugin-transform-classes": "^7.24.1", - "@babel/plugin-transform-computed-properties": "^7.24.1", - "@babel/plugin-transform-destructuring": "^7.24.1", - "@babel/plugin-transform-dotall-regex": "^7.24.1", - "@babel/plugin-transform-duplicate-keys": "^7.24.1", - "@babel/plugin-transform-dynamic-import": "^7.24.1", - "@babel/plugin-transform-exponentiation-operator": "^7.24.1", - "@babel/plugin-transform-export-namespace-from": "^7.24.1", - "@babel/plugin-transform-for-of": "^7.24.1", - "@babel/plugin-transform-function-name": "^7.24.1", - "@babel/plugin-transform-json-strings": "^7.24.1", - "@babel/plugin-transform-literals": "^7.24.1", - "@babel/plugin-transform-logical-assignment-operators": "^7.24.1", - "@babel/plugin-transform-member-expression-literals": "^7.24.1", - "@babel/plugin-transform-modules-amd": "^7.24.1", - "@babel/plugin-transform-modules-commonjs": "^7.24.1", - "@babel/plugin-transform-modules-systemjs": "^7.24.1", - "@babel/plugin-transform-modules-umd": "^7.24.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", - "@babel/plugin-transform-new-target": "^7.24.1", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.1", - "@babel/plugin-transform-numeric-separator": "^7.24.1", - "@babel/plugin-transform-object-rest-spread": "^7.24.1", - "@babel/plugin-transform-object-super": "^7.24.1", - "@babel/plugin-transform-optional-catch-binding": "^7.24.1", - "@babel/plugin-transform-optional-chaining": "^7.24.1", - "@babel/plugin-transform-parameters": "^7.24.1", - "@babel/plugin-transform-private-methods": "^7.24.1", - "@babel/plugin-transform-private-property-in-object": "^7.24.1", - "@babel/plugin-transform-property-literals": "^7.24.1", - "@babel/plugin-transform-regenerator": "^7.24.1", - "@babel/plugin-transform-reserved-words": "^7.24.1", - "@babel/plugin-transform-shorthand-properties": "^7.24.1", - "@babel/plugin-transform-spread": "^7.24.1", - "@babel/plugin-transform-sticky-regex": "^7.24.1", - "@babel/plugin-transform-template-literals": "^7.24.1", - "@babel/plugin-transform-typeof-symbol": "^7.24.1", - "@babel/plugin-transform-unicode-escapes": "^7.24.1", - "@babel/plugin-transform-unicode-property-regex": "^7.24.1", - "@babel/plugin-transform-unicode-regex": "^7.24.1", - "@babel/plugin-transform-unicode-sets-regex": "^7.24.1", + "@babel/plugin-transform-arrow-functions": "^7.24.7", + "@babel/plugin-transform-async-generator-functions": "^7.24.7", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoped-functions": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.24.7", + "@babel/plugin-transform-class-properties": "^7.24.7", + "@babel/plugin-transform-class-static-block": "^7.24.7", + "@babel/plugin-transform-classes": "^7.24.7", + "@babel/plugin-transform-computed-properties": "^7.24.7", + "@babel/plugin-transform-destructuring": "^7.24.7", + "@babel/plugin-transform-dotall-regex": "^7.24.7", + "@babel/plugin-transform-duplicate-keys": "^7.24.7", + "@babel/plugin-transform-dynamic-import": "^7.24.7", + "@babel/plugin-transform-exponentiation-operator": "^7.24.7", + "@babel/plugin-transform-export-namespace-from": "^7.24.7", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-function-name": "^7.24.7", + "@babel/plugin-transform-json-strings": "^7.24.7", + "@babel/plugin-transform-literals": "^7.24.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", + "@babel/plugin-transform-member-expression-literals": "^7.24.7", + "@babel/plugin-transform-modules-amd": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.7", + "@babel/plugin-transform-modules-systemjs": "^7.24.7", + "@babel/plugin-transform-modules-umd": "^7.24.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-new-target": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-numeric-separator": "^7.24.7", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-object-super": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.7", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-property-literals": "^7.24.7", + "@babel/plugin-transform-regenerator": "^7.24.7", + "@babel/plugin-transform-reserved-words": "^7.24.7", + "@babel/plugin-transform-shorthand-properties": "^7.24.7", + "@babel/plugin-transform-spread": "^7.24.7", + "@babel/plugin-transform-sticky-regex": "^7.24.7", + "@babel/plugin-transform-template-literals": "^7.24.7", + "@babel/plugin-transform-typeof-symbol": "^7.24.7", + "@babel/plugin-transform-unicode-escapes": "^7.24.7", + "@babel/plugin-transform-unicode-property-regex": "^7.24.7", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.24.7", "@babel/preset-modules": "0.1.6-no-external-plugins", "babel-plugin-polyfill-corejs2": "^0.4.10", "babel-plugin-polyfill-corejs3": "^0.10.4", @@ -2089,17 +2106,17 @@ } }, "node_modules/@babel/preset-react": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.24.1.tgz", - "integrity": "sha512-eFa8up2/8cZXLIpkafhaADTXSnl7IsUFCYenRWrARBz0/qZwcT0RBXpys0LJU4+WfPoF2ZG6ew6s2V6izMCwRA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.24.7.tgz", + "integrity": "sha512-AAH4lEkpmzFWrGVlHaxJB7RLH21uPQ9+He+eFLWHmF9IuFQVugz8eAsamaW0DXRrTfco5zj1wWtpdcXJUOfsag==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-validator-option": "^7.23.5", - "@babel/plugin-transform-react-display-name": "^7.24.1", - "@babel/plugin-transform-react-jsx": "^7.23.4", - "@babel/plugin-transform-react-jsx-development": "^7.22.5", - "@babel/plugin-transform-react-pure-annotations": "^7.24.1" + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-validator-option": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.24.7", + "@babel/plugin-transform-react-jsx-development": "^7.24.7", + "@babel/plugin-transform-react-pure-annotations": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -2109,16 +2126,16 @@ } }, "node_modules/@babel/preset-typescript": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.24.1.tgz", - "integrity": "sha512-1DBaMmRDpuYQBPWD8Pf/WEwCrtgRHxsZnP4mIy9G/X+hFfbI47Q2G4t1Paakld84+qsk2fSsUPMKg71jkoOOaQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.24.7.tgz", + "integrity": "sha512-SyXRe3OdWwIwalxDg5UtJnJQO+YPcTfwiIY2B0Xlddh9o7jpWLvv8X1RthIeDOxQ+O1ML5BLPCONToObyVQVuQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-validator-option": "^7.23.5", - "@babel/plugin-syntax-jsx": "^7.24.1", - "@babel/plugin-transform-modules-commonjs": "^7.24.1", - "@babel/plugin-transform-typescript": "^7.24.1" + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-validator-option": "^7.24.7", + "@babel/plugin-syntax-jsx": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -2134,9 +2151,9 @@ "dev": true }, "node_modules/@babel/runtime": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz", - "integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.7.tgz", + "integrity": "sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -2145,31 +2162,31 @@ } }, "node_modules/@babel/template": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", - "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz", + "integrity": "sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==", "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/parser": "^7.24.0", - "@babel/types": "^7.24.0" + "@babel/code-frame": "^7.24.7", + "@babel/parser": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.1.tgz", - "integrity": "sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==", - "dependencies": { - "@babel/code-frame": "^7.24.1", - "@babel/generator": "^7.24.1", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.24.1", - "@babel/types": "^7.24.0", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.7.tgz", + "integrity": "sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA==", + "dependencies": { + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.24.7", + "@babel/helper-environment-visitor": "^7.24.7", + "@babel/helper-function-name": "^7.24.7", + "@babel/helper-hoist-variables": "^7.24.7", + "@babel/helper-split-export-declaration": "^7.24.7", + "@babel/parser": "^7.24.7", + "@babel/types": "^7.24.7", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -2178,12 +2195,12 @@ } }, "node_modules/@babel/types": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", - "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.7.tgz", + "integrity": "sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==", "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-string-parser": "^7.24.7", + "@babel/helper-validator-identifier": "^7.24.7", "to-fast-properties": "^2.0.0" }, "engines": { @@ -2641,9 +2658,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" @@ -2672,6 +2689,22 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -2697,6 +2730,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2731,28 +2770,28 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", - "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.4.tgz", + "integrity": "sha512-a4IowK4QkXl4SCWTGUR0INAfEOX3wtsYw3rKK5InQEHMGObkR8Xk44qYQD9P4r6HHw0iIfK6GUKECmY8sTkqRA==", "dependencies": { - "@floating-ui/utils": "^0.2.1" + "@floating-ui/utils": "^0.2.4" } }, "node_modules/@floating-ui/dom": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz", - "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==", + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.7.tgz", + "integrity": "sha512-wmVfPG5o2xnKDU4jx/m4w5qva9FWHcnZ8BvzEe90D/RpwsJaTAVYPEPdQ8sbr/N8zZTAHlZUTQdqg8ZUbzHmng==", "dependencies": { - "@floating-ui/core": "^1.0.0", - "@floating-ui/utils": "^0.2.0" + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.4" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.8.tgz", - "integrity": "sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.1.tgz", + "integrity": "sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==", "dependencies": { - "@floating-ui/dom": "^1.6.1" + "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", @@ -2760,9 +2799,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", - "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.4.tgz", + "integrity": "sha512-dWO2pw8hhi+WrXq1YJy2yCuWoL20PddgGaqTgVe4cOS9Q6qklXCiA1tJEqX6BEwRNSCP84/afac9hd4MS+zEUA==" }, "node_modules/@gar/promisify": { "version": "1.1.3", @@ -2773,6 +2812,7 @@ "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "deprecated": "Use @eslint/config-array instead", "dev": true, "dependencies": { "@humanwhocodes/object-schema": "^2.0.2", @@ -2822,6 +2862,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", "dev": true }, "node_modules/@isaacs/cliui": { @@ -3799,18 +3840,18 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.15.15", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.15.tgz", - "integrity": "sha512-aXnw29OWQ6I5A47iuWEI6qSSUfH6G/aCsW9KmW3LiFqr7uXZBK4Ks+z8G+qeIub8k0T5CMqlT2q0L+ZJTMrqpg==", + "version": "5.15.21", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.21.tgz", + "integrity": "sha512-dp9lXBaJZzJYeJfQY3Ow4Rb49QaCEdkl2KKYscdQHQm6bMJ+l4XPY3Cd9PCeeJTsHPIDJ60lzXbeRgs6sx/rpw==", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" } }, "node_modules/@mui/icons-material": { - "version": "5.15.15", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.15.tgz", - "integrity": "sha512-kkeU/pe+hABcYDH6Uqy8RmIsr2S/y5bP2rp+Gat4CcRjCcVne6KudS1NrZQhUCRysrTDCAhcbcf9gt+/+pGO2g==", + "version": "5.15.21", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.21.tgz", + "integrity": "sha512-yqkq1MbdkmX5ZHyvZTBuAaA6RkvoqkoAgwBSx9Oh0L0jAfj9T/Ih/NhMNjkl8PWVSonjfDUkKroBnjRyo/1M9Q==", "dependencies": { "@babel/runtime": "^7.23.9" }, @@ -3873,16 +3914,16 @@ } }, "node_modules/@mui/material": { - "version": "5.15.15", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.15.tgz", - "integrity": "sha512-3zvWayJ+E1kzoIsvwyEvkTUKVKt1AjchFFns+JtluHCuvxgKcLSRJTADw37k0doaRtVAsyh8bz9Afqzv+KYrIA==", + "version": "5.15.21", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.21.tgz", + "integrity": "sha512-nTyCcgduKwHqiuQ/B03EQUa+utSMzn2sQp0QAibsnYe4tvc3zkMbO0amKpl48vhABIY3IvT6w9615BFIgMt0YA==", "dependencies": { "@babel/runtime": "^7.23.9", "@mui/base": "5.0.0-beta.40", - "@mui/core-downloads-tracker": "^5.15.15", - "@mui/system": "^5.15.15", + "@mui/core-downloads-tracker": "^5.15.21", + "@mui/system": "^5.15.20", "@mui/types": "^7.2.14", - "@mui/utils": "^5.15.14", + "@mui/utils": "^5.15.20", "@types/react-transition-group": "^4.4.10", "clsx": "^2.1.0", "csstype": "^3.1.3", @@ -3917,12 +3958,12 @@ } }, "node_modules/@mui/private-theming": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.14.tgz", - "integrity": "sha512-UH0EiZckOWcxiXLX3Jbb0K7rC8mxTr9L9l6QhOZxYc4r8FHUkefltV9VDGLrzCaWh30SQiJvAEd7djX3XXY6Xw==", + "version": "5.15.20", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.20.tgz", + "integrity": "sha512-BK8F94AIqSrnaPYXf2KAOjGZJgWfvqAVQ2gVR3EryvQFtuBnG6RwodxrCvd3B48VuMy6Wsk897+lQMUxJyk+6g==", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/utils": "^5.15.14", + "@mui/utils": "^5.15.20", "prop-types": "^15.8.1" }, "engines": { @@ -3974,15 +4015,15 @@ } }, "node_modules/@mui/system": { - "version": "5.15.15", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.15.tgz", - "integrity": "sha512-aulox6N1dnu5PABsfxVGOZffDVmlxPOVgj56HrUnJE8MCSh8lOvvkd47cebIVQQYAjpwieXQXiDPj5pwM40jTQ==", + "version": "5.15.20", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.20.tgz", + "integrity": "sha512-LoMq4IlAAhxzL2VNUDBTQxAb4chnBe8JvRINVNDiMtHE2PiPOoHlhOPutSxEbaL5mkECPVWSv6p8JEV+uykwIA==", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/private-theming": "^5.15.14", + "@mui/private-theming": "^5.15.20", "@mui/styled-engine": "^5.15.14", "@mui/types": "^7.2.14", - "@mui/utils": "^5.15.14", + "@mui/utils": "^5.15.20", "clsx": "^2.1.0", "csstype": "^3.1.3", "prop-types": "^15.8.1" @@ -4026,9 +4067,9 @@ } }, "node_modules/@mui/utils": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.14.tgz", - "integrity": "sha512-0lF/7Hh/ezDv5X7Pry6enMsbYyGKjADzvHyo3Qrc/SSlTsQ1VkbDMbH0m2t3OR5iIVLwMoxwM7yGd+6FCMtTFA==", + "version": "5.15.20", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.20.tgz", + "integrity": "sha512-mAbYx0sovrnpAu1zHc3MDIhPqL8RPVC5W5xcO1b7PiSCJPtckIZmBkp8hefamAvUiAV8gpfMOM6Zb+eSisbI2A==", "dependencies": { "@babel/runtime": "^7.23.9", "@types/prop-types": "^15.7.11", @@ -4053,9 +4094,9 @@ } }, "node_modules/@mui/x-data-grid": { - "version": "6.19.10", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.19.10.tgz", - "integrity": "sha512-p6cc6pJvPPXw/KqDbU8xqaxvv1qVNU2qawTCGfXwtCUwjWaa8VumLfXioX4Sn9yHxf1SuCxnW9ZasHlaS577eg==", + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.20.3.tgz", + "integrity": "sha512-VVggwKiEgMdkVqpORZEBgSqcpuBoVKMwYZnO+Q8vns2+otpiFE4yr52TZjKkF+ugDPgZ4rcq8mlj4VsK83XiMQ==", "dependencies": { "@babel/runtime": "^7.23.2", "@mui/utils": "^5.14.16", @@ -4078,13 +4119,13 @@ } }, "node_modules/@mui/x-data-grid-pro": { - "version": "6.19.10", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid-pro/-/x-data-grid-pro-6.19.10.tgz", - "integrity": "sha512-2AxtPmnhJfQTWSd0VtfOSxyqSQamqBnjmb9/vEH4KWq4xqyeak1sHoin95657Q/xnLLv4HDtMtW6+4YfHFdgwA==", + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid-pro/-/x-data-grid-pro-6.20.3.tgz", + "integrity": "sha512-UblinLg4BZkHkjSy5r4B5Ip+kBcZ/6PzNUJNB0I1nep/gi/vqJd5zCsPZ9IZqtTbh0wyDfupvqQxnneMsiLl2w==", "dependencies": { "@babel/runtime": "^7.23.2", "@mui/utils": "^5.14.16", - "@mui/x-data-grid": "6.19.10", + "@mui/x-data-grid": "6.20.3", "@mui/x-license-pro": "6.10.2", "@types/format-util": "^1.0.3", "clsx": "^2.0.0", @@ -4102,9 +4143,9 @@ } }, "node_modules/@mui/x-date-pickers": { - "version": "6.19.9", - "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-6.19.9.tgz", - "integrity": "sha512-B2m4Fv/fOme5qmV6zuE3QnWQSvj3zKtI2OvikPz5prpiCcIxqpeytkQ7VfrWH3/Aqd5yhG1Yr4IgbqG0ymIXGg==", + "version": "6.20.2", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-6.20.2.tgz", + "integrity": "sha512-x1jLg8R+WhvkmUETRfX2wC+xJreMii78EXKLl6r3G+ggcAZlPyt0myID1Amf6hvJb9CtR7CgUo8BwR+1Vx9Ggw==", "dependencies": { "@babel/runtime": "^7.23.2", "@mui/base": "^5.0.0-beta.22", @@ -4259,24 +4300,10 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/@npmcli/fs/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@npmcli/fs/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "bin": { "semver": "bin/semver.js" }, @@ -4284,11 +4311,6 @@ "node": ">=10" } }, - "node_modules/@npmcli/fs/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, "node_modules/@npmcli/move-file": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", @@ -4313,19 +4335,17 @@ } }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { - "version": "0.5.11", - "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.11.tgz", - "integrity": "sha512-7j/6vdTym0+qZ6u4XbSAxrWBGYSdCfTzySkj7WAFgDLmSyWlOrWvpyzxlFh5jtw9dn0oL/jtW+06XfFiisN3JQ==", + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.15.tgz", + "integrity": "sha512-LFWllMA55pzB9D34w/wXUCf8+c+IYKuJDgxiZ3qMhl64KRMBHYM1I3VdGaD2BV5FNPV2/S2596bppxHbv2ZydQ==", "dev": true, "dependencies": { - "ansi-html-community": "^0.0.8", - "common-path-prefix": "^3.0.0", + "ansi-html": "^0.0.9", "core-js-pure": "^3.23.3", "error-stack-parser": "^2.0.6", - "find-up": "^5.0.0", "html-entities": "^2.1.0", "loader-utils": "^2.0.4", - "schema-utils": "^3.0.0", + "schema-utils": "^4.2.0", "source-map": "^0.7.3" }, "engines": { @@ -4337,7 +4357,7 @@ "sockjs-client": "^1.4.0", "type-fest": ">=0.17.0 <5.0.0", "webpack": ">=4.43.0 <6.0.0", - "webpack-dev-server": "3.x || 4.x", + "webpack-dev-server": "3.x || 4.x || 5.x", "webpack-hot-middleware": "2.x", "webpack-plugin-serve": "0.x || 1.x" }, @@ -4479,9 +4499,9 @@ "dev": true }, "node_modules/@rushstack/eslint-patch": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.2.tgz", - "integrity": "sha512-hw437iINopmQuxWPSUEvqE56NCPsiU8N4AYtfHmJFckclktzK9YQJieD3XkDCDH4OjL+C7zgPUh73R/nrcHrqw==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.3.tgz", + "integrity": "sha512-qC/xYId4NMebE6w/V33Fh9gWxLgURiNYgVNObbJl2LZv0GUUItCcCqC5axQSwRaAgaxl2mELq1rMzlswaQ0Zxg==", "dev": true }, "node_modules/@sinclair/typebox": { @@ -4763,13 +4783,13 @@ } }, "node_modules/@swc/core": { - "version": "1.4.12", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.4.12.tgz", - "integrity": "sha512-QljRxTaUajSLB9ui93cZ38/lmThwIw/BPxjn+TphrYN6LPU3vu9/ykjgHtlpmaXDDcngL4K5i396E7iwwEUxYg==", + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.6.7.tgz", + "integrity": "sha512-BBzORL9qWz5hZqAZ83yn+WNaD54RH5eludjqIOboolFOK/Pw+2l00/H77H4CEBJnzCIBQszsyqtITmrn4evp0g==", "hasInstallScript": true, "dependencies": { - "@swc/counter": "^0.1.2", - "@swc/types": "^0.1.5" + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.9" }, "engines": { "node": ">=10" @@ -4779,19 +4799,19 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.4.12", - "@swc/core-darwin-x64": "1.4.12", - "@swc/core-linux-arm-gnueabihf": "1.4.12", - "@swc/core-linux-arm64-gnu": "1.4.12", - "@swc/core-linux-arm64-musl": "1.4.12", - "@swc/core-linux-x64-gnu": "1.4.12", - "@swc/core-linux-x64-musl": "1.4.12", - "@swc/core-win32-arm64-msvc": "1.4.12", - "@swc/core-win32-ia32-msvc": "1.4.12", - "@swc/core-win32-x64-msvc": "1.4.12" + "@swc/core-darwin-arm64": "1.6.7", + "@swc/core-darwin-x64": "1.6.7", + "@swc/core-linux-arm-gnueabihf": "1.6.7", + "@swc/core-linux-arm64-gnu": "1.6.7", + "@swc/core-linux-arm64-musl": "1.6.7", + "@swc/core-linux-x64-gnu": "1.6.7", + "@swc/core-linux-x64-musl": "1.6.7", + "@swc/core-win32-arm64-msvc": "1.6.7", + "@swc/core-win32-ia32-msvc": "1.6.7", + "@swc/core-win32-x64-msvc": "1.6.7" }, "peerDependencies": { - "@swc/helpers": "^0.5.0" + "@swc/helpers": "*" }, "peerDependenciesMeta": { "@swc/helpers": { @@ -4799,16 +4819,16 @@ } } }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.4.12", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.4.12.tgz", - "integrity": "sha512-3A4qMtddBDbtprV5edTB/SgJn9L+X5TL7RGgS3eWtEgn/NG8gA80X/scjf1v2MMeOsrcxiYhnemI2gXCKuQN2g==", + "node_modules/@swc/core-darwin-arm64": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.6.7.tgz", + "integrity": "sha512-sNb+ghP2OhZyUjS7E5Mf3PqSvoXJ5gY6GBaH2qp8WQxx9VL7ozC4HVo6vkeFJBN5cmYqUCLnhrM3HU4W+7yMSA==", "cpu": [ - "x64" + "arm64" ], "optional": true, "os": [ - "win32" + "darwin" ], "engines": { "node": ">=10" @@ -4820,31 +4840,30 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" }, "node_modules/@swc/types": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.6.tgz", - "integrity": "sha512-/JLo/l2JsT/LRd80C3HfbmVpxOAJ11FO2RCEslFrgzLltoP9j8XIbsyDcfCt2WWyX+CM96rBoNM+IToAkFOugg==", + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.9.tgz", + "integrity": "sha512-qKnCno++jzcJ4lM4NTfYifm1EFSCeIfKiAHAfkENZAV5Kl9PjJIyd2yeeVv6c/2CckuLyv2NmRC5pv6pm2WQBg==", "dependencies": { "@swc/counter": "^0.1.3" } }, "node_modules/@testing-library/dom": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.1.0.tgz", - "integrity": "sha512-wdsYKy5zupPyLCW2Je5DLHSxSfbIp6h80WoHOQc+RPtmPGA52O9x5MJEkv92Sjonpq+poOAtUKhh1kBGAXBrNA==", + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", + "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", + "aria-query": "5.1.3", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" }, "engines": { - "node": ">=18" + "node": ">=14" } }, "node_modules/@testing-library/dom/node_modules/ansi-styles": { @@ -4852,7 +4871,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -4863,12 +4881,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "dependencies": { + "deep-equal": "^2.0.5" + } + }, "node_modules/@testing-library/dom/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -4885,7 +4911,6 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -4897,15 +4922,51 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@testing-library/dom/node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", "dev": true, - "peer": true + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true }, "node_modules/@testing-library/dom/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, - "peer": true, "engines": { "node": ">=8" } @@ -4915,7 +4976,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -4924,18 +4984,18 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.2.tgz", - "integrity": "sha512-CzqH0AFymEMG48CpzXFriYYkOjk6ZGPCLMhW9e9jg3KMCn5OfJecF8GtGW7yGfR/IgCe3SX8BSwjdzI6BBbZLw==", + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.6.tgz", + "integrity": "sha512-8qpnGVincVDLEcQXWaHOf6zmlbwTKc6Us6PPu4CRnPXCzo2OGBS5cwgMMOWdxDpEz1mkbvXHpEy99M5Yvt682w==", "dev": true, "dependencies": { - "@adobe/css-tools": "^4.3.2", + "@adobe/css-tools": "^4.4.0", "@babel/runtime": "^7.9.2", "aria-query": "^5.0.0", "chalk": "^3.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", - "lodash": "^4.17.15", + "lodash": "^4.17.21", "redent": "^3.0.0" }, "engines": { @@ -5014,12 +5074,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true - }, "node_modules/@testing-library/jest-dom/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5059,234 +5113,104 @@ "react-dom": "^18.0.0" } }, - "node_modules/@testing-library/react/node_modules/@testing-library/dom": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", - "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "node_modules/@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", "dev": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" + "engines": { + "node": ">=12", + "npm": ">=6" }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tmcw/togeojson": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@tmcw/togeojson/-/togeojson-4.7.0.tgz", + "integrity": "sha512-edAPymgIEIY/jrEmATYe56a46XHvPVm7SXhf29h7jSAUrRhLOIFIlbHPCsic/gGDSvWODTSioRFpXgou47ZLYg==" + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", "engines": { - "node": ">=14" + "node": ">= 10" } }, - "node_modules/@testing-library/react/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=10.13.0" + } + }, + "node_modules/@turf/bbox": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", + "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/turf" } }, - "node_modules/@testing-library/react/node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", - "dev": true, + "node_modules/@turf/boolean-equal": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-equal/-/boolean-equal-6.5.0.tgz", + "integrity": "sha512-cY0M3yoLC26mhAnjv1gyYNQjn7wxIXmL2hBmI/qs8g5uKuC2hRWi13ydufE3k4x0aNRjFGlg41fjoYLwaVF+9Q==", "dependencies": { - "deep-equal": "^2.0.5" + "@turf/clean-coords": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "geojson-equality": "0.1.6" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/@testing-library/react/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, + "node_modules/@turf/center-of-mass": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/center-of-mass/-/center-of-mass-6.5.0.tgz", + "integrity": "sha512-EWrriU6LraOfPN7m1jZi+1NLTKNkuIsGLZc2+Y8zbGruvUW+QV7K0nhf7iZWutlxHXTBqEXHbKue/o79IumAsQ==", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@turf/centroid": "^6.5.0", + "@turf/convex": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0" }, - "engines": { - "node": ">=10" + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/centroid": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-6.5.0.tgz", + "integrity": "sha512-MwE1oq5E3isewPprEClbfU5pXljIK/GUOMbn22UM3IFPDJX0KeoyLNwghszkdmFp/qMGL/M13MMWvU+GNLXP/A==", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://opencollective.com/turf" } }, - "node_modules/@testing-library/react/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "node_modules/@turf/clean-coords": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/clean-coords/-/clean-coords-6.5.0.tgz", + "integrity": "sha512-EMX7gyZz0WTH/ET7xV8MyrExywfm9qUi0/MY89yNffzGIEHuFfqwhcCqZ8O00rZIPZHUTxpmsxQSTfzJJA1CPw==", "dependencies": { - "color-name": "~1.1.4" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@testing-library/react/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/@testing-library/react/node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "dev": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@testing-library/react/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/react/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/user-event": { - "version": "14.5.2", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", - "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", - "dev": true, - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, - "node_modules/@tmcw/togeojson": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@tmcw/togeojson/-/togeojson-4.7.0.tgz", - "integrity": "sha512-edAPymgIEIY/jrEmATYe56a46XHvPVm7SXhf29h7jSAUrRhLOIFIlbHPCsic/gGDSvWODTSioRFpXgou47ZLYg==" - }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "engines": { - "node": ">= 10" - } - }, - "node_modules/@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", - "dev": true, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/@turf/bbox": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", - "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/boolean-equal": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/boolean-equal/-/boolean-equal-6.5.0.tgz", - "integrity": "sha512-cY0M3yoLC26mhAnjv1gyYNQjn7wxIXmL2hBmI/qs8g5uKuC2hRWi13ydufE3k4x0aNRjFGlg41fjoYLwaVF+9Q==", - "dependencies": { - "@turf/clean-coords": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "geojson-equality": "0.1.6" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/center-of-mass": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/center-of-mass/-/center-of-mass-6.5.0.tgz", - "integrity": "sha512-EWrriU6LraOfPN7m1jZi+1NLTKNkuIsGLZc2+Y8zbGruvUW+QV7K0nhf7iZWutlxHXTBqEXHbKue/o79IumAsQ==", - "dependencies": { - "@turf/centroid": "^6.5.0", - "@turf/convex": "^6.5.0", - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/centroid": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-6.5.0.tgz", - "integrity": "sha512-MwE1oq5E3isewPprEClbfU5pXljIK/GUOMbn22UM3IFPDJX0KeoyLNwghszkdmFp/qMGL/M13MMWvU+GNLXP/A==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/clean-coords": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/clean-coords/-/clean-coords-6.5.0.tgz", - "integrity": "sha512-EMX7gyZz0WTH/ET7xV8MyrExywfm9qUi0/MY89yNffzGIEHuFfqwhcCqZ8O00rZIPZHUTxpmsxQSTfzJJA1CPw==", - "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" - }, - "funding": { - "url": "https://opencollective.com/turf" + "funding": { + "url": "https://opencollective.com/turf" } }, "node_modules/@turf/convex": { @@ -5383,9 +5307,9 @@ } }, "node_modules/@types/babel__traverse": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.5.tgz", - "integrity": "sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==", + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", "dev": true, "dependencies": { "@babel/types": "^7.20.7" @@ -5430,9 +5354,9 @@ } }, "node_modules/@types/eslint": { - "version": "8.56.9", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.9.tgz", - "integrity": "sha512-W4W3KcqzjJ0sHg2vAq9vfml6OhsJ53TcUjUqfzzZf/EChUtwspszj/S0pzMxnfRcO55/iGq47dscXw71Fxc4Zg==", + "version": "8.56.10", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz", + "integrity": "sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==", "dev": true, "dependencies": { "@types/estree": "*", @@ -5468,9 +5392,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "4.19.0", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz", - "integrity": "sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==", + "version": "4.19.5", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.5.tgz", + "integrity": "sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg==", "dev": true, "dependencies": { "@types/node": "*", @@ -5608,9 +5532,9 @@ "dev": true }, "node_modules/@types/leaflet": { - "version": "1.9.9", - "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.9.tgz", - "integrity": "sha512-o0qD9ReJzWpGNIAY0O32NkpfM6rhV4sxnwVkz7x7Ah4Zy9sP+2T9Q3byccL5la1ZX416k+qiyvt8ksBavPPY7A==", + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.12.tgz", + "integrity": "sha512-BK7XS+NyRI291HIo0HCfE18Lp8oA30H1gpi1tf0mF3TgiCEzanQjOqNZ4x126SXzzi2oNSZhZ5axJp1k0iM6jg==", "dev": true, "dependencies": { "@types/geojson": "*" @@ -5635,9 +5559,9 @@ } }, "node_modules/@types/lodash": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", - "integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==" + "version": "4.17.6", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.6.tgz", + "integrity": "sha512-OpXEVoCKSS3lQqjx9GGGOapBeuW5eUboYHRlHP9urXPX25IKZ6AnP5ZRxtVf63iieUbsHxLn8NQ5Nlftc6yzAA==" }, "node_modules/@types/lodash-es": { "version": "4.17.12", @@ -5660,9 +5584,9 @@ "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==" }, "node_modules/@types/node": { - "version": "18.19.31", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.31.tgz", - "integrity": "sha512-ArgCD39YpyyrtFKIqMDvjz79jto5fcI/SVUs2HwB+f0dAzq68yqOdyaSivLiLugSziTpNXLQrVb7RZFmdZzbhA==", + "version": "18.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.39.tgz", + "integrity": "sha512-nPwTRDKUctxw3di5b4TfT3I0sWDiWoPQCZjXhvdkINntwr8lcoVCKsTgnXeRubKIlfnV+eN/HYk6Jb40tbcEAQ==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -5720,9 +5644,9 @@ "dev": true }, "node_modules/@types/qs": { - "version": "6.9.14", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.14.tgz", - "integrity": "sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==", + "version": "6.9.15", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", + "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==", "dev": true }, "node_modules/@types/range-parser": { @@ -5732,18 +5656,18 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.2.77", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.77.tgz", - "integrity": "sha512-CUT9KUUF+HytDM7WiXKLF9qUSg4tGImwy4FXTlfEDPEkkNUzJ7rVFolYweJ9fS1ljoIaP7M7Rdjc5eUm/Yu5AA==", + "version": "18.3.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", + "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "18.2.25", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.25.tgz", - "integrity": "sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==", + "version": "18.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", + "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", "dev": true, "dependencies": { "@types/react": "*" @@ -5839,9 +5763,9 @@ } }, "node_modules/@types/shpjs": { - "version": "3.4.6", - "resolved": "https://registry.npmjs.org/@types/shpjs/-/shpjs-3.4.6.tgz", - "integrity": "sha512-hekn3Rl4/C1FzB+HfBLZVMwVSVm65Tl8oNdQDw7wn327DpJfb4sDeN5hMXQkHTmuLzTi+vCVO2RHK43SsQOKpg==", + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/@types/shpjs/-/shpjs-3.4.7.tgz", + "integrity": "sha512-/6PjggpFsq9NFxar6ZpXsnYZ+nQJR8Cv03Gne1enIJuMZ/eFVOpu0orHxL9D7RT3ciJElzF2H6l+49US23ydUw==", "dev": true, "dependencies": { "@types/geojson": "*", @@ -5934,26 +5858,11 @@ } } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -5961,12 +5870,6 @@ "node": ">=10" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/@typescript-eslint/experimental-utils": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.62.0.tgz", @@ -6108,26 +6011,11 @@ "node": ">=4.0" } }, - "node_modules/@typescript-eslint/experimental-utils/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/experimental-utils/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -6135,12 +6023,6 @@ "node": ">=10" } }, - "node_modules/@typescript-eslint/experimental-utils/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/@typescript-eslint/parser": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.6.0.tgz", @@ -6254,26 +6136,11 @@ } } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -6281,12 +6148,6 @@ "node": ">=10" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/@typescript-eslint/utils": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.6.0.tgz", @@ -6312,26 +6173,11 @@ "eslint": "^8.56.0" } }, - "node_modules/@typescript-eslint/utils/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -6339,12 +6185,6 @@ "node": ">=10" } }, - "node_modules/@typescript-eslint/utils/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/@typescript-eslint/visitor-keys": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.6.0.tgz", @@ -6551,9 +6391,9 @@ } }, "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -6584,10 +6424,10 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "dev": true, "peerDependencies": { "acorn": "^8" @@ -6668,14 +6508,14 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.16.0.tgz", + "integrity": "sha512-F0twR8U1ZU67JIEtekUcLkXkoO5mMMmgGD8sK/xUFzJ805jxHQl92hImFAqqXMyMYjSPOyUPAwHYhB72g5sTXw==", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.4.1" }, "funding": { "type": "github", @@ -6699,35 +6539,16 @@ } } }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "peerDependencies": { - "ajv": "^6.9.1" + "peerDependencies": { + "ajv": "^8.8.2" } }, "node_modules/ansi-escapes": { @@ -6745,6 +6566,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ansi-html": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/ansi-html/-/ansi-html-0.0.9.tgz", + "integrity": "sha512-ozbS3LuenHVxNRh/wdnN16QapUHzauqSomAl1jwwJRRsGwFwtj644lIhxfWu0Fy0acCij2+AEgHvjscq3dlVXg==", + "dev": true, + "engines": [ + "node >= 0.8.0" + ], + "bin": { + "ansi-html": "bin/ansi-html" + } + }, "node_modules/ansi-html-community": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", @@ -6804,6 +6637,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" @@ -6992,16 +6826,19 @@ } }, "node_modules/array.prototype.tosorted": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.3.tgz", - "integrity": "sha512-/DdH4TiTmOKzyQbp/eadcCVexiCb36xJg7HshYOYJnNZFDj33GEv0P7GxsynpShhq4OLYJzbGcBDkLsDt7MnNg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, "dependencies": { - "call-bind": "^1.0.5", + "call-bind": "^1.0.7", "define-properties": "^1.2.1", - "es-abstract": "^1.22.3", - "es-errors": "^1.1.0", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/arraybuffer.prototype.slice": { @@ -7170,23 +7007,23 @@ } }, "node_modules/aws4": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", - "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==" + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.0.tgz", + "integrity": "sha512-3AungXC4I8kKsS9PuS4JH2nc+0bVY/mjgrephHTIi8fpEeGsTHBUJeosp0Wc1myYMElmD0B3Oc4XL/HVJ4PV2g==" }, "node_modules/axe-core": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz", - "integrity": "sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.9.1.tgz", + "integrity": "sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw==", "dev": true, "engines": { "node": ">=4" } }, "node_modules/axios": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", - "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", + "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -7207,12 +7044,44 @@ } }, "node_modules/axobject-query": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", - "integrity": "sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", + "integrity": "sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==", "dev": true, "dependencies": { - "dequal": "^2.0.3" + "deep-equal": "^2.0.5" + } + }, + "node_modules/axobject-query/node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/babel-jest": { @@ -7325,6 +7194,37 @@ "webpack": ">=2" } }, + "node_modules/babel-loader/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/babel-loader/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/babel-loader/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "node_modules/babel-loader/node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -7429,13 +7329,13 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.10", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.10.tgz", - "integrity": "sha512-rpIuu//y5OX6jVU+a5BCn1R5RSZYWAl2Nar76iwaOdycqb6JPxediskWFMMl7stfwNJR4b7eiQvh5fB5TEQJTQ==", + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.11.tgz", + "integrity": "sha512-sMEJ27L0gRHShOh5G54uAAPaiCOygY/5ratXuiyb2G46FmlSpc9eFCzYVyDiPxfNbwzA7mYahmjQc5q+CZQ09Q==", "dev": true, "dependencies": { "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.6.1", + "@babel/helper-define-polyfill-provider": "^0.6.2", "semver": "^6.3.1" }, "peerDependencies": { @@ -7456,12 +7356,12 @@ } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.1.tgz", - "integrity": "sha512-JfTApdE++cgcTWjsiCQlLyFBMbTUft9ja17saCc93lgV33h4tuCVj7tlvu//qpLwaG+3yEz7/KhahGrUMkVq9g==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.2.tgz", + "integrity": "sha512-2R25rQZWP63nGwaAswvDazbPXfrM3HwVoBXK6HcqeKrSrL/JqcC/rDcf95l4r7LXLyxDXc8uQDa064GubtCABg==", "dev": true, "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.1" + "@babel/helper-define-polyfill-provider": "^0.6.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -7693,12 +7593,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -7711,9 +7611,9 @@ "dev": true }, "node_modules/browserslist": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", - "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", + "integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==", "funding": [ { "type": "opencollective", @@ -7729,10 +7629,10 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001587", - "electron-to-chromium": "^1.4.668", + "caniuse-lite": "^1.0.30001629", + "electron-to-chromium": "^1.4.796", "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "update-browserslist-db": "^1.0.16" }, "bin": { "browserslist": "cli.js" @@ -7832,6 +7732,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -7947,9 +7848,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001609", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001609.tgz", - "integrity": "sha512-JFPQs34lHKx1B5t1EpQpWH4c+29zIyn/haGsbpfq3suuV9v56enjFt23zqijxGTMwy1p/4H2tjnQMY+p1WoAyA==", + "version": "1.0.30001640", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001640.tgz", + "integrity": "sha512-lA4VMpW0PSUrFnkmVuEKBUovSWKhj7puyCg8StBChgu298N1AtuF1sKWEvfDuimSEDbhlb/KqPKC3fs1HbuQUA==", "funding": [ { "type": "opencollective", @@ -8060,9 +7961,9 @@ } }, "node_modules/chrome-trace-event": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", - "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "dev": true, "engines": { "node": ">=6.0" @@ -8084,9 +7985,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", - "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz", + "integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==", "dev": true }, "node_modules/clean-css": { @@ -8132,9 +8033,9 @@ } }, "node_modules/clsx": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", - "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "engines": { "node": ">=6" } @@ -8222,12 +8123,6 @@ "node": ">= 12" } }, - "node_modules/common-path-prefix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", - "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", - "dev": true - }, "node_modules/common-tags": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", @@ -8377,9 +8272,9 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, "node_modules/core-js": { - "version": "3.36.1", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.36.1.tgz", - "integrity": "sha512-BTvUrwxVBezj5SZ3f10ImnX2oRByMxql3EimVqMysepbC9EeMUOpLwdy6Eoili2x6E4kf+ZUB5k/+Jv55alPfA==", + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.37.1.tgz", + "integrity": "sha512-Xn6qmxrQZyB0FFY8E3bgRXei3lWDJHhvI+u0q9TKIYM49G8pAr0FgnnrFRAmsbptZL1yxRADVXn+x5AGsbBfyw==", "dev": true, "hasInstallScript": true, "funding": { @@ -8388,9 +8283,9 @@ } }, "node_modules/core-js-compat": { - "version": "3.36.1", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.36.1.tgz", - "integrity": "sha512-Dk997v9ZCt3X/npqzyGdTlq6t7lDBhZwGvV94PKzDArjp7BTRm7WlDAXYd/OWdeFHO8OChQYRJNJvUCqCbrtKA==", + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz", + "integrity": "sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==", "dev": true, "dependencies": { "browserslist": "^4.23.0" @@ -8401,9 +8296,9 @@ } }, "node_modules/core-js-pure": { - "version": "3.36.1", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.36.1.tgz", - "integrity": "sha512-NXCvHvSVYSrewP0L5OhltzXeWFJLo2AL2TYnj6iLV3Bw8mM62wAQMNgUCRI6EBu6hVVpbCxmOPlxh1Ikw2PfUA==", + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.37.1.tgz", + "integrity": "sha512-J/r5JTHSmzTxbiYYrzXg9w1VpqrYt+gexenBE9pugeyhwPZTAEJddyiReJWsLO6uNQ8xJZFbod6XC7KKwatCiA==", "dev": true, "hasInstallScript": true, "funding": { @@ -8632,26 +8527,11 @@ } } }, - "node_modules/css-loader/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/css-loader/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -8659,12 +8539,6 @@ "node": ">=10" } }, - "node_modules/css-loader/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/css-minimizer-webpack-plugin": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.4.1.tgz", @@ -8703,34 +8577,6 @@ } } }, - "node_modules/css-minimizer-webpack-plugin/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/css-minimizer-webpack-plugin/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, "node_modules/css-minimizer-webpack-plugin/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -8754,31 +8600,6 @@ "node": ">= 10.13.0" } }, - "node_modules/css-minimizer-webpack-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, - "node_modules/css-minimizer-webpack-plugin/node_modules/schema-utils": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", - "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/css-minimizer-webpack-plugin/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -9133,14 +8954,14 @@ } }, "node_modules/dayjs": { - "version": "1.11.10", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", - "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" + "version": "1.11.11", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz", + "integrity": "sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==" }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", "dependencies": { "ms": "2.1.2" }, @@ -9434,9 +9255,9 @@ } }, "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true }, "node_modules/dom-converter": { @@ -9606,9 +9427,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.735", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.735.tgz", - "integrity": "sha512-pkYpvwg8VyOTQAeBqZ7jsmpCjko1Qc6We1ZtZCjRyYbT5v4AIUKDy5cQTRotQlSSZmMr8jqpEt6JtOj5k7lR7A==" + "version": "1.4.816", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.816.tgz", + "integrity": "sha512-EKH5X5oqC6hLmiS7/vYtZHZFTNdhsYG5NVPRN6Yn0kQHNBlT59+xSM8HBy66P5fxWpKgZbPqb+diC64ng295Jw==" }, "node_modules/emittery": { "version": "0.13.1", @@ -9667,9 +9488,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.16.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.16.0.tgz", - "integrity": "sha512-O+QWCviPNSSLAD9Ucn8Awv+poAkqn3T1XY5/N7kR7rQO9yfSGWkYZDwpJ+iKF7B8rxaQKWngSqACpgzeapSyoA==", + "version": "5.17.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.0.tgz", + "integrity": "sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==", "dev": true, "dependencies": { "graceful-fs": "^4.2.4", @@ -9823,14 +9644,14 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.0.18", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.18.tgz", - "integrity": "sha512-scxAJaewsahbqTYrGKJihhViaM6DDZDDoucfvzNbK0pOren1g/daDQ3IAhzn+1G14rBG7w+i5N+qul60++zlKA==", + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.19.tgz", + "integrity": "sha512-zoMwbCcH5hwUkKJkT8kDIBZSz9I6mVG//+lDCinLCGov4+r7NIy0ld8o03M0cJxl2spVf6ESYVS6/gpIfq1FFw==", "dev": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", - "es-abstract": "^1.23.0", + "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.0.3", "function-bind": "^1.1.2", @@ -9848,9 +9669,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.0.tgz", - "integrity": "sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", + "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", "dev": true }, "node_modules/es-object-atoms": { @@ -10314,30 +10135,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/eslint-config-react-app/node_modules/eslint-plugin-jest": { - "version": "25.7.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-25.7.0.tgz", - "integrity": "sha512-PWLUEXeeF7C9QGKqvdSbzLOiLTx+bno7/HC9eefePfEb257QFHg7ye3dh80AZVkaa/RQsBB1Q/ORQvg2X7F0NQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/experimental-utils": "^5.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - }, - "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^4.0.0 || ^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/eslint-plugin": { - "optional": true - }, - "jest": { - "optional": true - } - } - }, "node_modules/eslint-config-react-app/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -10360,26 +10157,11 @@ "node": ">=4.0" } }, - "node_modules/eslint-config-react-app/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-config-react-app/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -10387,12 +10169,6 @@ "node": ">=10" } }, - "node_modules/eslint-config-react-app/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", @@ -10531,28 +10307,52 @@ "node": "*" } }, + "node_modules/eslint-plugin-jest": { + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-25.7.0.tgz", + "integrity": "sha512-PWLUEXeeF7C9QGKqvdSbzLOiLTx+bno7/HC9eefePfEb257QFHg7ye3dh80AZVkaa/RQsBB1Q/ORQvg2X7F0NQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/experimental-utils": "^5.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^4.0.0 || ^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + } + } + }, "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.8.0.tgz", - "integrity": "sha512-Hdh937BS3KdwwbBaKd5+PLCOmYY6U4f2h9Z2ktwtNKvIdIEu137rjYbcb9ApSbVJfWxANNuiKTD/9tOKjK9qOA==", + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.9.0.tgz", + "integrity": "sha512-nOFOCaJG2pYqORjK19lqPqxMO/JpvdCZdPtNdxY3kvom3jTvkAbOvQvD8wuD0G8BYR0IGAGYDlzqWJOh/ybn2g==", "dev": true, "dependencies": { - "@babel/runtime": "^7.23.2", - "aria-query": "^5.3.0", - "array-includes": "^3.1.7", + "aria-query": "~5.1.3", + "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", - "axe-core": "=4.7.0", - "axobject-query": "^3.2.1", + "axe-core": "^4.9.1", + "axobject-query": "~3.1.1", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", - "es-iterator-helpers": "^1.0.15", - "hasown": "^2.0.0", + "es-iterator-helpers": "^1.0.19", + "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", - "object.entries": "^1.1.7", - "object.fromentries": "^2.0.7" + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.0" }, "engines": { "node": ">=4.0" @@ -10561,16 +10361,57 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" } }, - "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -10605,29 +10446,29 @@ } }, "node_modules/eslint-plugin-react": { - "version": "7.34.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.34.1.tgz", - "integrity": "sha512-N97CxlouPT1AHt8Jn0mhhN2RrADlUAsk1/atcT2KyA/l9Q/E6ll7OIGwNumFmWfZ9skV3XXccYS19h80rHtgkw==", + "version": "7.34.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.34.3.tgz", + "integrity": "sha512-aoW4MV891jkUulwDApQbPYTVZmeuSyFrudpbTAQuj5Fv8VL+o6df2xIGpw8B0hPjAaih1/Fb0om9grCdyFYemA==", "dev": true, "dependencies": { - "array-includes": "^3.1.7", - "array.prototype.findlast": "^1.2.4", + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.2", "array.prototype.toreversed": "^1.1.2", - "array.prototype.tosorted": "^1.1.3", + "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.0.17", + "es-iterator-helpers": "^1.0.19", "estraverse": "^5.3.0", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", - "object.entries": "^1.1.7", - "object.fromentries": "^2.0.7", - "object.hasown": "^1.1.3", - "object.values": "^1.1.7", + "object.entries": "^1.1.8", + "object.fromentries": "^2.0.8", + "object.hasown": "^1.1.4", + "object.values": "^1.2.0", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.10" + "string.prototype.matchall": "^4.0.11" }, "engines": { "node": ">=4" @@ -10637,9 +10478,9 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", - "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", "dev": true, "engines": { "node": ">=10" @@ -10837,26 +10678,11 @@ "node": ">=4.0" } }, - "node_modules/eslint-plugin-testing-library/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-plugin-testing-library/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -10864,12 +10690,6 @@ "node": ">=10" } }, - "node_modules/eslint-plugin-testing-library/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -10922,34 +10742,6 @@ "webpack": "^5.0.0" } }, - "node_modules/eslint-webpack-plugin/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/eslint-webpack-plugin/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, "node_modules/eslint-webpack-plugin/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -10973,31 +10765,6 @@ "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, - "node_modules/eslint-webpack-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, - "node_modules/eslint-webpack-plugin/node_modules/schema-utils": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", - "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/eslint-webpack-plugin/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -11013,6 +10780,22 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -11096,6 +10879,12 @@ "node": ">=8" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -11474,6 +11263,55 @@ "webpack": "^4.0.0 || ^5.0.0" } }, + "node_modules/file-loader/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/file-loader/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/file-loader/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/file-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/file-selector": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.4.0.tgz", @@ -11516,9 +11354,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -11658,9 +11496,9 @@ } }, "node_modules/foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", + "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==", "dev": true, "dependencies": { "cross-spawn": "^7.0.0", @@ -11732,6 +11570,31 @@ } } }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -11840,17 +11703,11 @@ "node": ">=8" } }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } + "node_modules/fork-ts-checker-webpack-plugin/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/minimatch": { "version": "3.1.2", @@ -11883,13 +11740,10 @@ } }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -11918,12 +11772,6 @@ "node": ">=6" } }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -11938,9 +11786,9 @@ } }, "node_modules/formik": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.5.tgz", - "integrity": "sha512-Gxlht0TD3vVdzMDHwkiNZqJ7Mvg77xQNfmBRrNtvzcHZs72TJppSTDKHpImCMJZwcWPBJ8jSQQ95GJzXFf1nAQ==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.6.tgz", + "integrity": "sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==", "funding": [ { "type": "individual", @@ -12022,9 +11870,9 @@ } }, "node_modules/fs-monkey": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.5.tgz", - "integrity": "sha512-8uMbBjrhzW76TYgEV27Y5E//W2f/lTFmx78P2w19FZSxarhI/798APGQyuGCwmkNxgwGRhrLfvWyLBvNtuOmew==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz", + "integrity": "sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==", "dev": true }, "node_modules/fs.realpath": { @@ -12069,6 +11917,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.3", @@ -12199,6 +12048,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -12299,11 +12149,12 @@ } }, "node_modules/globalthis": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dependencies": { - "define-properties": "^1.1.3" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -12358,6 +12209,7 @@ "version": "7.1.7", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -12448,6 +12300,26 @@ "node": ">=6" } }, + "node_modules/har-validator/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/har-validator/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, "node_modules/hard-rejection": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", @@ -13048,6 +12920,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -13220,11 +13093,14 @@ } }, "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.14.0.tgz", + "integrity": "sha512-a5dFJih5ZLYlRtDc0dZWP7RiKr6xIKzmn/oAYCDvdLThadVgyJwlaoQPmRtMSpz+rk0OGAgIu+TcM9HUF0fk1A==", "dependencies": { - "hasown": "^2.0.0" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -13637,9 +13513,9 @@ } }, "node_modules/istanbul-lib-instrument": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.2.tgz", - "integrity": "sha512-1WUsZ9R1lA0HtBSohTkm39WTPlNKSJ5iFk7UwqXkBLoHQT+hfqPsfsTDVuZdKGaBwn7din9bS7SsnoAr943hvw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, "dependencies": { "@babel/core": "^7.23.9", @@ -13652,26 +13528,11 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-instrument/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -13679,12 +13540,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-instrument/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/istanbul-lib-report": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", @@ -13770,9 +13625,9 @@ } }, "node_modules/jackspeak": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", - "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.0.tgz", + "integrity": "sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw==", "dev": true, "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -13788,9 +13643,9 @@ } }, "node_modules/jake": { - "version": "10.8.7", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", - "integrity": "sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==", + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.1.tgz", + "integrity": "sha512-61btcOHNnLnsOdtLgA5efqQWjnSi/vow5HbI7HMdKKWqvrKR1bLK3BPlJn9gcSaP2ewuamUSMB5XEy76KUIS2w==", "dev": true, "dependencies": { "async": "^3.2.3", @@ -15378,18 +15233,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/jest-jasmine2/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jest-jasmine2/node_modules/resolve.exports": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.1.tgz", @@ -15400,13 +15243,10 @@ } }, "node_modules/jest-jasmine2/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -15447,12 +15287,6 @@ "typedarray-to-buffer": "^3.1.5" } }, - "node_modules/jest-jasmine2/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/jest-leak-detector": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", @@ -16169,18 +16003,6 @@ "node": ">=8" } }, - "node_modules/jest-snapshot/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jest-snapshot/node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -16208,13 +16030,10 @@ } }, "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -16234,12 +16053,6 @@ "node": ">=8" } }, - "node_modules/jest-snapshot/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/jest-sonar-reporter": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/jest-sonar-reporter/-/jest-sonar-reporter-2.0.0.tgz", @@ -16464,26 +16277,104 @@ "node": ">=8" } }, - "node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "node_modules/jest-watch-typeahead": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jest-watch-typeahead/-/jest-watch-typeahead-1.1.0.tgz", + "integrity": "sha512-Va5nLSJTN7YFtC2jd+7wsoe1pNe5K4ShLux/E5iHEwlB9AxaxmggY7to9KUqKojhaJw3aXqt5WAb4jGPOolpEw==", "dev": true, "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", + "ansi-escapes": "^4.3.1", + "chalk": "^4.0.0", + "jest-regex-util": "^28.0.0", + "jest-watcher": "^28.0.0", + "slash": "^4.0.0", + "string-length": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "jest": "^27.0.0 || ^28.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/console": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-28.1.3.tgz", + "integrity": "sha512-QPAkP5EwKdK/bxIr6C1I4Vs0rm2nHiANzj/Z5X2JQkrZo6IqvC4ldZ9K95tF0HdidhA8Bo6egxSzUFPYKcEXLw==", + "dev": true, + "dependencies": { + "@jest/types": "^28.1.3", "@types/node": "*", - "ansi-escapes": "^4.2.1", "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" + "jest-message-util": "^28.1.3", + "jest-util": "^28.1.3", + "slash": "^3.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, - "node_modules/jest-watcher/node_modules/ansi-styles": { + "node_modules/jest-watch-typeahead/node_modules/@jest/console/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/schemas": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz", + "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.24.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/test-result": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-28.1.3.tgz", + "integrity": "sha512-kZAkxnSE+FqE8YjW8gNuoVkkC9I7S1qmenl8sGcDOLropASP+BkcGKwhXoyqQuGOGeYY0y/ixjrd/iERpEXHNg==", + "dev": true, + "dependencies": { + "@jest/console": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/types": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-28.1.3.tgz", + "integrity": "sha512-RyjiyMUZrKz/c+zlMFO1pm70DcIlST8AeWTkoUdZevew44wcNZQHsEVOiCVtgVnlFFD82FPaXycys58cf2muVQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^28.1.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@sinclair/typebox": { + "version": "0.24.51", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", + "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==", + "dev": true + }, + "node_modules/jest-watch-typeahead/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", @@ -16498,7 +16389,7 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-watcher/node_modules/chalk": { + "node_modules/jest-watch-typeahead/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", @@ -16514,7 +16405,7 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-watcher/node_modules/color-convert": { + "node_modules/jest-watch-typeahead/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", @@ -16526,13 +16417,25 @@ "node": ">=7.0.0" } }, - "node_modules/jest-watcher/node_modules/color-name": { + "node_modules/jest-watch-typeahead/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/jest-watcher/node_modules/has-flag": { + "node_modules/jest-watch-typeahead/node_modules/emittery": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.10.2.tgz", + "integrity": "sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", @@ -16541,67 +16444,346 @@ "node": ">=8" } }, - "node_modules/jest-watcher/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/jest-watch-typeahead/node_modules/jest-message-util": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-28.1.3.tgz", + "integrity": "sha512-PFdn9Iewbt575zKPf1286Ht9EPoJmYT7P0kY+RibeYZ2XtOr53pDLEFoTWXbd1h4JiGiWpTBC84fc8xMXQMb7g==", "dev": true, "dependencies": { - "has-flag": "^4.0.0" + "@babel/code-frame": "^7.12.13", + "@jest/types": "^28.1.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^28.1.3", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-message-util/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, "engines": { "node": ">=8" } }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "node_modules/jest-watch-typeahead/node_modules/jest-regex-util": { + "version": "28.0.2", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-28.0.2.tgz", + "integrity": "sha512-4s0IgyNIy0y9FK+cjoVYoxamT7Zeo7MhzqRGx7YDYmaQn1wucY9rotiGkBzzcMXTtjrCAP/f7f+E0F7+fxPNdw==", + "dev": true, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-util": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-28.1.3.tgz", + "integrity": "sha512-XdqfpHwpcSRko/C35uLYFM2emRAltIIKZiJ9eAmhjsj0CqZMa0p1ib0R5fWIqGhn1a103DebTbpqIaP1qCQ6tQ==", "dev": true, "dependencies": { + "@jest/types": "^28.1.3", "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, - "node_modules/jest-worker/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/jest-watch-typeahead/node_modules/jest-watcher": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-28.1.3.tgz", + "integrity": "sha512-t4qcqj9hze+jviFPUN3YAtAEeFnr/azITXQEMARf5cMwKY2SMBRnCQTXLixTl20OR6mLh9KLMrgVJgJISym+1g==", "dev": true, + "dependencies": { + "@jest/test-result": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.10.2", + "jest-util": "^28.1.3", + "string-length": "^4.0.1" + }, "engines": { - "node": ">=8" + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/jest-watch-typeahead/node_modules/jest-watcher/node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", "dev": true, "dependencies": { - "has-flag": "^4.0.0" + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/jiti": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", - "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "node_modules/jest-watch-typeahead/node_modules/jest-watcher/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-base64": { + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead/node_modules/pretty-format": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", + "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==", + "dev": true, + "dependencies": { + "@jest/schemas": "^28.1.3", + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watch-typeahead/node_modules/string-length": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz", + "integrity": "sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==", + "dev": true, + "dependencies": { + "char-regex": "^2.0.0", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watch-typeahead/node_modules/string-length/node_modules/char-regex": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-2.0.1.tgz", + "integrity": "sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw==", + "dev": true, + "engines": { + "node": ">=12.20" + } + }, + "node_modules/jest-watch-typeahead/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-watcher/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-watcher/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-watcher/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-watcher/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jest-watcher/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watcher/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "1.21.6", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", + "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-base64": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz", "integrity": "sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==" @@ -16756,9 +16938,9 @@ } }, "node_modules/jsdom/node_modules/tough-cookie": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", - "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", "dev": true, "dependencies": { "psl": "^1.1.33", @@ -16780,9 +16962,9 @@ } }, "node_modules/jsdom/node_modules/ws": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", - "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "dev": true, "engines": { "node": ">=8.3.0" @@ -16833,9 +17015,9 @@ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -16921,24 +17103,59 @@ } }, "node_modules/jszip": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-2.7.0.tgz", - "integrity": "sha512-JIsRKRVC3gTRo2vM4Wy9WBC3TRcfnIZU8k65Phi3izkvPH975FowRYtKGT6PxevA0XnJ/yO8b0QwV0ydVyQwfw==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", "dependencies": { - "pako": "~1.0.2" + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" } }, - "node_modules/jwt-decode": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", - "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "dependencies": { + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { "json-buffer": "3.0.1" } }, @@ -16969,9 +17186,9 @@ } }, "node_modules/language-subtag-registry": { - "version": "0.3.22", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", - "integrity": "sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==", + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", "dev": true }, "node_modules/language-tags": { @@ -16987,9 +17204,9 @@ } }, "node_modules/launch-editor": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.6.1.tgz", - "integrity": "sha512-eB/uXmFVpY4zezmGp5XtU21kwo7GBbKB+EQ+UZeWtGb9yAM5xt/Evk+lYH3eRNAtId+ej4u7TYPFZ07w4s7rRw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.8.0.tgz", + "integrity": "sha512-vJranOAJrI/llyWGRQqiDM+adrw+k83fvmmx3+nV47g3+36xM15jE+zyZ6Ffel02+xSvuM0b2GDRosXZkbb6wA==", "dev": true, "dependencies": { "picocolors": "^1.0.0", @@ -17241,26 +17458,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-dir/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/make-dir/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -17268,12 +17470,6 @@ "node": ">=10" } }, - "node_modules/make-dir/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/make-fetch-happen": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", @@ -17437,12 +17633,12 @@ "integrity": "sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==" }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", + "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", "dev": true, "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -17497,9 +17693,9 @@ } }, "node_modules/mini-css-extract-plugin": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.8.1.tgz", - "integrity": "sha512-/1HDlyFRxWIZPI1ZpgqlZ8jMw/1Dp/dl3P0L1jtZ+zVcHqwPhGwaJwKL00WVgfnBy6PWCde9W65or7IIETImuA==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.0.tgz", + "integrity": "sha512-Zs1YsZVfemekSZG+44vBsYTLQORkPMwnlv+aehcxK/NLKC+EGhDB39/YePYYqx/sTk6NnYpuqikhSn7+JIevTA==", "dev": true, "dependencies": { "schema-utils": "^4.0.0", @@ -17516,59 +17712,6 @@ "webpack": "^5.0.0" } }, - "node_modules/mini-css-extract-plugin/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/mini-css-extract-plugin/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/mini-css-extract-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, - "node_modules/mini-css-extract-plugin/node_modules/schema-utils": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", - "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -17576,9 +17719,9 @@ "dev": true }, "node_modules/minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, "dependencies": { "brace-expansion": "^2.0.1" @@ -17746,9 +17889,9 @@ } }, "node_modules/nan": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.19.0.tgz", - "integrity": "sha512-nO1xXxfh/RWNxfd/XPfbIfFk5vgLsAxUR9y5O0cHMJu/AW9U95JLXqthYHjEp+8gQ5p96K9jUp8nbVOxCdRbtw==" + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.20.0.tgz", + "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==" }, "node_modules/nanoclone": { "version": "0.2.1", @@ -17971,12 +18114,9 @@ } }, "node_modules/node-gyp/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "bin": { "semver": "bin/semver.js" }, @@ -18160,24 +18300,10 @@ "node": ">=10" } }, - "node_modules/normalize-package-data/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/normalize-package-data/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "bin": { "semver": "bin/semver.js" }, @@ -18185,11 +18311,6 @@ "node": ">=10" } }, - "node_modules/normalize-package-data/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -18341,6 +18462,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", "dependencies": { "are-we-there-yet": "^3.0.0", "console-control-strings": "^1.1.0", @@ -18364,9 +18486,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", - "integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==", + "version": "2.2.10", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.10.tgz", + "integrity": "sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ==", "dev": true }, "node_modules/oauth-sign": { @@ -18395,9 +18517,12 @@ } }, "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -18622,17 +18747,17 @@ } }, "node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, "engines": { "node": ">= 0.8.0" @@ -18712,6 +18837,12 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "dev": true + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -18824,34 +18955,34 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-scurry": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.2.tgz", - "integrity": "sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.3.0.tgz", + "integrity": "sha512-CQl19J/g+Hbjbv4Y3mFNNXFEL/5t/KCg8POCuUqd4rMKjGG+j1ybER83hxV58zL+dFI1PTkt3GNFSHRt+d8qEQ==", "dev": true, "engines": { "node": "14 || >=16.14" } }, "node_modules/path-scurry/node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "dev": true, "engines": { "node": ">=16 || 14 >=14.17" @@ -18876,9 +19007,9 @@ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -19071,9 +19202,9 @@ } }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", + "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", "dev": true, "funding": [ { @@ -19091,7 +19222,7 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", + "picocolors": "^1.0.1", "source-map-js": "^1.2.0" }, "engines": { @@ -19592,9 +19723,9 @@ } }, "node_modules/postcss-load-config/node_modules/lilconfig": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", - "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", + "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", "dev": true, "engines": { "node": ">=14" @@ -19604,9 +19735,9 @@ } }, "node_modules/postcss-load-config/node_modules/yaml": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.1.tgz", - "integrity": "sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==", + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.5.tgz", + "integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==", "dev": true, "bin": { "yaml": "bin.mjs" @@ -19637,26 +19768,11 @@ "webpack": "^5.0.0" } }, - "node_modules/postcss-loader/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/postcss-loader/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -19664,12 +19780,6 @@ "node": ">=10" } }, - "node_modules/postcss-loader/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/postcss-logical": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/postcss-logical/-/postcss-logical-5.0.4.tgz", @@ -20273,9 +20383,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.0.16", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz", - "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.0.tgz", + "integrity": "sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==", "dev": true, "dependencies": { "cssesc": "^3.0.0", @@ -20613,6 +20723,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", "dev": true, "engines": { "node": ">=0.6.0", @@ -20620,9 +20731,9 @@ } }, "node_modules/qs": { - "version": "6.12.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.1.tgz", - "integrity": "sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==", + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.2.tgz", + "integrity": "sha512-x+NLUpx9SYrcwXtX7ob1gnkSems4i/mGZX5SlYxwIau6RrUSODO89TR/XDGGpn5RPWSYIB+aSfuSlV5+CmbTBg==", "dependencies": { "side-channel": "^1.0.6" }, @@ -20721,9 +20832,9 @@ } }, "node_modules/react": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", - "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dependencies": { "loose-envify": "^1.1.0" }, @@ -20872,9 +20983,9 @@ } }, "node_modules/react-dev-utils/node_modules/loader-utils": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz", - "integrity": "sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", + "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", "dev": true, "engines": { "node": ">= 12.13.0" @@ -20893,15 +21004,15 @@ } }, "node_modules/react-dom": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", - "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dependencies": { "loose-envify": "^1.1.0", - "scheduler": "^0.23.0" + "scheduler": "^0.23.2" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.3.1" } }, "node_modules/react-dropzone": { @@ -20932,9 +21043,9 @@ "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" }, "node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" }, "node_modules/react-leaflet": { "version": "4.2.1", @@ -21277,18 +21388,6 @@ } } }, - "node_modules/react-scripts/node_modules/@jest/schemas": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz", - "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==", - "dev": true, - "dependencies": { - "@sinclair/typebox": "^0.24.1" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, "node_modules/react-scripts/node_modules/@jest/source-map": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", @@ -21375,12 +21474,6 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/react-scripts/node_modules/@sinclair/typebox": { - "version": "0.24.51", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", - "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==", - "dev": true - }, "node_modules/react-scripts/node_modules/@sinonjs/commons": { "version": "1.8.6", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", @@ -21423,21 +21516,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/react-scripts/node_modules/are-we-there-yet": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", - "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/react-scripts/node_modules/babel-jest": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz", @@ -21619,28 +21697,6 @@ "node": ">=12" } }, - "node_modules/react-scripts/node_modules/gauge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", - "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.2", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.1", - "object-assign": "^4.1.1", - "signal-exit": "^3.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.2" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/react-scripts/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -22046,401 +22102,111 @@ "jest-runtime": "^27.5.1", "jest-util": "^27.5.1", "jest-worker": "^27.5.1", - "source-map-support": "^0.5.6", - "throat": "^6.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/react-scripts/node_modules/jest-runtime": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", - "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", - "dev": true, - "dependencies": { - "@jest/environment": "^27.5.1", - "@jest/fake-timers": "^27.5.1", - "@jest/globals": "^27.5.1", - "@jest/source-map": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "execa": "^5.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-mock": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-resolve": "^27.5.1", - "jest-snapshot": "^27.5.1", - "jest-util": "^27.5.1", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/react-scripts/node_modules/jest-snapshot": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", - "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", - "dev": true, - "dependencies": { - "@babel/core": "^7.7.2", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/traverse": "^7.7.2", - "@babel/types": "^7.0.0", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/babel__traverse": "^7.0.4", - "@types/prettier": "^2.1.5", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^27.5.1", - "graceful-fs": "^4.2.9", - "jest-diff": "^27.5.1", - "jest-get-type": "^27.5.1", - "jest-haste-map": "^27.5.1", - "jest-matcher-utils": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-util": "^27.5.1", - "natural-compare": "^1.4.0", - "pretty-format": "^27.5.1", - "semver": "^7.3.2" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/react-scripts/node_modules/jest-util": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", - "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", - "dev": true, - "dependencies": { - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/react-scripts/node_modules/jest-validate": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz", - "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==", - "dev": true, - "dependencies": { - "@jest/types": "^27.5.1", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^27.5.1", - "leven": "^3.1.0", - "pretty-format": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jest-watch-typeahead/-/jest-watch-typeahead-1.1.0.tgz", - "integrity": "sha512-Va5nLSJTN7YFtC2jd+7wsoe1pNe5K4ShLux/E5iHEwlB9AxaxmggY7to9KUqKojhaJw3aXqt5WAb4jGPOolpEw==", - "dev": true, - "dependencies": { - "ansi-escapes": "^4.3.1", - "chalk": "^4.0.0", - "jest-regex-util": "^28.0.0", - "jest-watcher": "^28.0.0", - "slash": "^4.0.0", - "string-length": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "jest": "^27.0.0 || ^28.0.0" - } - }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/@jest/console": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-28.1.3.tgz", - "integrity": "sha512-QPAkP5EwKdK/bxIr6C1I4Vs0rm2nHiANzj/Z5X2JQkrZo6IqvC4ldZ9K95tF0HdidhA8Bo6egxSzUFPYKcEXLw==", - "dev": true, - "dependencies": { - "@jest/types": "^28.1.3", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^28.1.3", - "jest-util": "^28.1.3", - "slash": "^3.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/@jest/console/node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/@jest/test-result": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-28.1.3.tgz", - "integrity": "sha512-kZAkxnSE+FqE8YjW8gNuoVkkC9I7S1qmenl8sGcDOLropASP+BkcGKwhXoyqQuGOGeYY0y/ixjrd/iERpEXHNg==", - "dev": true, - "dependencies": { - "@jest/console": "^28.1.3", - "@jest/types": "^28.1.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/@jest/types": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-28.1.3.tgz", - "integrity": "sha512-RyjiyMUZrKz/c+zlMFO1pm70DcIlST8AeWTkoUdZevew44wcNZQHsEVOiCVtgVnlFFD82FPaXycys58cf2muVQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^28.1.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", - "dev": true, - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/emittery": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.10.2.tgz", - "integrity": "sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/jest-message-util": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-28.1.3.tgz", - "integrity": "sha512-PFdn9Iewbt575zKPf1286Ht9EPoJmYT7P0kY+RibeYZ2XtOr53pDLEFoTWXbd1h4JiGiWpTBC84fc8xMXQMb7g==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^28.1.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^28.1.3", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/jest-message-util/node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/jest-regex-util": { - "version": "28.0.2", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-28.0.2.tgz", - "integrity": "sha512-4s0IgyNIy0y9FK+cjoVYoxamT7Zeo7MhzqRGx7YDYmaQn1wucY9rotiGkBzzcMXTtjrCAP/f7f+E0F7+fxPNdw==", - "dev": true, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/jest-util": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-28.1.3.tgz", - "integrity": "sha512-XdqfpHwpcSRko/C35uLYFM2emRAltIIKZiJ9eAmhjsj0CqZMa0p1ib0R5fWIqGhn1a103DebTbpqIaP1qCQ6tQ==", - "dev": true, - "dependencies": { - "@jest/types": "^28.1.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/jest-watcher": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-28.1.3.tgz", - "integrity": "sha512-t4qcqj9hze+jviFPUN3YAtAEeFnr/azITXQEMARf5cMwKY2SMBRnCQTXLixTl20OR6mLh9KLMrgVJgJISym+1g==", - "dev": true, - "dependencies": { - "@jest/test-result": "^28.1.3", - "@jest/types": "^28.1.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.10.2", - "jest-util": "^28.1.3", - "string-length": "^4.0.1" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/jest-watcher/node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "dev": true, - "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/jest-watcher/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" + "source-map-support": "^0.5.6", + "throat": "^6.0.1" }, "engines": { - "node": ">=8" + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/pretty-format": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", - "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==", + "node_modules/react-scripts/node_modules/jest-runtime": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", + "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", "dev": true, "dependencies": { - "@jest/schemas": "^28.1.3", - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/globals": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "execa": "^5.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/slash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", - "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/string-length": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz", - "integrity": "sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==", + "node_modules/react-scripts/node_modules/jest-snapshot": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", + "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", "dev": true, "dependencies": { - "char-regex": "^2.0.0", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12.20" + "@babel/core": "^7.7.2", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/traverse": "^7.7.2", + "@babel/types": "^7.0.0", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__traverse": "^7.0.4", + "@types/prettier": "^2.1.5", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "natural-compare": "^1.4.0", + "pretty-format": "^27.5.1", + "semver": "^7.3.2" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/string-length/node_modules/char-regex": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-2.0.1.tgz", - "integrity": "sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw==", - "dev": true, "engines": { - "node": ">=12.20" + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "node_modules/react-scripts/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", "dev": true, "dependencies": { - "ansi-regex": "^6.0.1" + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/strip-ansi/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "node_modules/react-scripts/node_modules/jest-validate": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz", + "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==", "dev": true, - "engines": { - "node": ">=12" + "dependencies": { + "@jest/types": "^27.5.1", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "leven": "^3.1.0", + "pretty-format": "^27.5.1" }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, "node_modules/react-scripts/node_modules/jest-watcher": { @@ -22490,64 +22256,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/react-scripts/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/react-scripts/node_modules/node-sass": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-7.0.3.tgz", - "integrity": "sha512-8MIlsY/4dXUkJDYht9pIWBhMil3uHmE8b/AdJPjmFn1nBx9X9BASzfzmsCy0uCCb8eqI3SYYzVPDswWqSx7gjw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "peer": true, - "dependencies": { - "async-foreach": "^0.1.3", - "chalk": "^4.1.2", - "cross-spawn": "^7.0.3", - "gaze": "^1.0.0", - "get-stdin": "^4.0.1", - "glob": "^7.0.3", - "lodash": "^4.17.15", - "meow": "^9.0.0", - "nan": "^2.13.2", - "node-gyp": "^8.4.1", - "npmlog": "^5.0.0", - "request": "^2.88.0", - "sass-graph": "^4.0.1", - "stdout-stream": "^1.4.0", - "true-case-path": "^1.0.2" - }, - "bin": { - "node-sass": "bin/node-sass" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/react-scripts/node_modules/npmlog": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", - "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "are-we-there-yet": "^2.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^3.0.0", - "set-blocking": "^2.0.0" - } - }, "node_modules/react-scripts/node_modules/resolve.exports": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.1.tgz", @@ -22557,52 +22265,11 @@ "node": ">=10" } }, - "node_modules/react-scripts/node_modules/sass-loader": { - "version": "12.6.0", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz", - "integrity": "sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA==", - "dev": true, - "dependencies": { - "klona": "^2.0.4", - "neo-async": "^2.6.2" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "fibers": ">= 3.1.0", - "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", - "sass": "^1.3.0", - "sass-embedded": "*", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "fibers": { - "optional": true - }, - "node-sass": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - } - } - }, "node_modules/react-scripts/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -22631,17 +22298,6 @@ "node": ">=8" } }, - "node_modules/react-scripts/node_modules/true-case-path": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/true-case-path/-/true-case-path-1.0.3.tgz", - "integrity": "sha512-m6s2OdQe5wgpFMC+pAJ+q9djG82O2jcHPOI6RNg1yy9rCYR+WD6Nbpl32fDpfC56nirdRy+opFa/Vk7HYhqaew==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "glob": "^7.1.2" - } - }, "node_modules/react-scripts/node_modules/v8-to-istanbul": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", @@ -22677,12 +22333,6 @@ "typedarray-to-buffer": "^3.1.5" } }, - "node_modules/react-scripts/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/react-scripts/node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", @@ -23196,7 +22846,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -23359,6 +23008,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dependencies": { "glob": "^7.1.3" }, @@ -23552,6 +23202,44 @@ "node": ">=12" } }, + "node_modules/sass-loader": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz", + "integrity": "sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA==", + "dev": true, + "dependencies": { + "klona": "^2.0.4", + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "fibers": ">= 3.1.0", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "fibers": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + } + } + }, "node_modules/sax": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", @@ -23571,25 +23259,26 @@ } }, "node_modules/scheduler": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", - "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "dependencies": { "loose-envify": "^1.1.0" } }, "node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", "dev": true, "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" }, "engines": { - "node": ">= 10.13.0" + "node": ">= 12.13.0" }, "funding": { "type": "opencollective", @@ -23817,6 +23506,11 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -23850,22 +23544,15 @@ } }, "node_modules/shpjs": { - "version": "3.6.3", - "resolved": "https://registry.npmjs.org/shpjs/-/shpjs-3.6.3.tgz", - "integrity": "sha512-wcR2S3WL/7RnEIm+YO+H/mZR9z9FCV46op+SZt+W5PtPs26Omb9U93f+EPI1DOpNKBuAIrWjHWh0SxlnBahJkg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/shpjs/-/shpjs-5.0.2.tgz", + "integrity": "sha512-vop/HhKgU3R7jY9hg6XP/TJaZLvg3Z/ZTt55LI/B2q931Rc7cNaRfmLjQwaGiJ+QOv9rsKrnecuAbaSdtbaFwQ==", "dependencies": { - "jszip": "^2.4.0", - "lie": "^3.0.1", - "lru-cache": "^2.7.0", + "jszip": "^3.10.1", "parsedbf": "^1.1.0", "proj4": "^2.1.4" } }, - "node_modules/shpjs/node_modules/lru-cache": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz", - "integrity": "sha512-WpibWJ60c3AgAz8a2iYErDrcT2C7OmKnsWhIcHOjkUHFjkXncJhtLxNSqUmxRxRunpb5I8Vprd7aNSd2NtksJQ==" - }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -24055,9 +23742,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.17", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz", - "integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==" + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz", + "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==" }, "node_modules/spdy": { "version": "4.0.2", @@ -24313,6 +24000,16 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "node_modules/string.prototype.includes": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.0.tgz", + "integrity": "sha512-E34CkBgyeqNDcrbU76cDjL5JLcVrtSdYq0MEh/B10r17pRP4ciHLwTgnuLV8Ay6cgEMLkcBkFCKyFZ43YldYzg==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.11", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz", @@ -24559,31 +24256,32 @@ } }, "node_modules/sucrase/node_modules/glob": { - "version": "10.3.12", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz", - "integrity": "sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==", + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.2.tgz", + "integrity": "sha512-GwMlUF6PkPo3Gk21UxkCohOv0PLcIXVtKyLlpEI28R/cO/4eNOdmLk3CMW1wROV/WR/EsZOWAfBbBOqYvs88/w==", "dev": true, "dependencies": { "foreground-child": "^3.1.0", - "jackspeak": "^2.3.6", - "minimatch": "^9.0.1", - "minipass": "^7.0.4", - "path-scurry": "^1.10.2" + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/sucrase/node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "dev": true, "engines": { "node": ">=16 || 14 >=14.17" @@ -24798,9 +24496,9 @@ "dev": true }, "node_modules/tailwindcss": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.3.tgz", - "integrity": "sha512-U7sxQk/n397Bmx4JHbJx/iSOOv5G+II3f1kpLpY2QeUv5DcPdcTsYLlusZfq1NthHS1c1cZoyFmmkex1rzke0A==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.4.tgz", + "integrity": "sha512-ZoyXOdJjISB7/BcLTR6SEsLgKtDStYyYZVLsUtWChO4Ps20CBad7lfJKVDiejocV4ME1hLmyY0WJE3hSDcmQ2A==", "dev": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -24928,9 +24626,9 @@ } }, "node_modules/terser": { - "version": "5.30.3", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.30.3.tgz", - "integrity": "sha512-STdUgOUx8rLbMGO9IOwHLpCqolkDITFFQSMYYwKE1N2lY6MVSaeoi10z/EhWxRc6ybqoVmKSkhKYH/XUpl7vSA==", + "version": "5.31.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.1.tgz", + "integrity": "sha512-37upzU1+viGvuFtBo9NPufCb9dwM0+l9hMxYyWfBA+fbwrPqNJAhbZ6W47bBFnZHKHTUBnMvi87434qq+qnxOg==", "dev": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -24979,6 +24677,31 @@ } } }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, "node_modules/terser-webpack-plugin/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -25002,6 +24725,30 @@ "node": ">= 10.13.0" } }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/terser-webpack-plugin/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -25271,9 +25018,9 @@ } }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -25581,9 +25328,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", "funding": [ { "type": "opencollective", @@ -25599,8 +25346,8 @@ } ], "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.1.2", + "picocolors": "^1.0.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -25683,9 +25430,9 @@ } }, "node_modules/v8-to-istanbul": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", - "integrity": "sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", "dev": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", @@ -25794,9 +25541,9 @@ } }, "node_modules/webpack": { - "version": "5.91.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.91.0.tgz", - "integrity": "sha512-rzVwlLeBWHJbmgTC/8TvAcu5vpJNII+MelQpylD4jNERPwpBJOE2lEcko1zJX3QJeLjTTAnQxn/OJ8bjDzVQaw==", + "version": "5.92.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.1.tgz", + "integrity": "sha512-JECQ7IwJb+7fgUFBlrJzbyu3GEuNBcdqr1LD7IbSzwkSmIevTm8PF+wej3Oxuz/JFBUZ6O1o43zsPkwm1C4TmA==", "dev": true, "dependencies": { "@types/eslint-scope": "^3.7.3", @@ -25805,10 +25552,10 @@ "@webassemblyjs/wasm-edit": "^1.12.1", "@webassemblyjs/wasm-parser": "^1.12.1", "acorn": "^8.7.1", - "acorn-import-assertions": "^1.9.0", + "acorn-import-attributes": "^1.9.5", "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.16.0", + "enhanced-resolve": "^5.17.0", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -25863,59 +25610,6 @@ "webpack": "^4.0.0 || ^5.0.0" } }, - "node_modules/webpack-dev-middleware/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack-dev-middleware/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/webpack-dev-middleware/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, - "node_modules/webpack-dev-middleware/node_modules/schema-utils": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", - "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/webpack-dev-server": { "version": "4.15.2", "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz", @@ -25975,68 +25669,15 @@ } } }, - "node_modules/webpack-dev-server/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack-dev-server/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, "node_modules/webpack-dev-server/node_modules/ipaddr.js": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", - "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", "dev": true, "engines": { "node": ">= 10" } }, - "node_modules/webpack-dev-server/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, - "node_modules/webpack-dev-server/node_modules/schema-utils": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", - "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/webpack-manifest-plugin": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-4.1.1.tgz", @@ -26084,6 +25725,31 @@ "node": ">=10.13.0" } }, + "node_modules/webpack/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -26106,6 +25772,30 @@ "node": ">=4.0" } }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", @@ -26344,39 +26034,6 @@ "node": ">=10.0.0" } }, - "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz", - "integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==", - "dev": true, - "dependencies": { - "json-schema": "^0.4.0", - "jsonpointer": "^5.0.0", - "leven": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "ajv": ">=8" - } - }, - "node_modules/workbox-build/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/workbox-build/node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -26392,12 +26049,6 @@ "node": ">=10" } }, - "node_modules/workbox-build/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "node_modules/workbox-build/node_modules/source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", @@ -26716,9 +26367,9 @@ } }, "node_modules/ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "dev": true, "engines": { "node": ">=10.0.0" diff --git a/app/package.json b/app/package.json index 95b9e6668a..05208cad7d 100644 --- a/app/package.json +++ b/app/package.json @@ -46,6 +46,7 @@ "@turf/center-of-mass": "^6.5.0", "@turf/centroid": "^6.5.0", "@turf/truncate": "^6.5.0", + "ajv": "^8.16.0", "axios": "^1.6.8", "dayjs": "^1.11.10", "express": "^4.19.2", @@ -71,7 +72,7 @@ "react-router-dom": "^5.3.3", "react-window": "^1.8.6", "request": "^2.88.2", - "shpjs": "^3.6.3", + "shpjs": "^5.0.2", "typescript": "^4.7.4", "uuid": "^8.3.2", "yup": "^0.32.9" @@ -97,7 +98,7 @@ "@types/react-router": "^5.1.20", "@types/react-router-dom": "^5.3.3", "@types/react-window": "^1.8.2", - "@types/shpjs": "^3.4.0", + "@types/shpjs": "^3.4.7", "@types/uuid": "^8.3.0", "@typescript-eslint/eslint-plugin": "~7.6.0", "@typescript-eslint/parser": "~7.6.0", diff --git a/app/server/index.js b/app/server/index.js index 020eeb4532..bb903d2809 100644 --- a/app/server/index.js +++ b/app/server/index.js @@ -28,7 +28,7 @@ const request = require('request'); * This includes a health check endpoint that OpenShift uses to determine if the app is healthy. * * This file is only used when serving the app in OpenShift. - * When running the app locally, the app is served by docker-compose, and doesn't use this file at all. + * When running the app locally, the app is served by compose.yml, and doesn't use this file at all. * * Note: All changes to env vars here must also be reflected in the `app/src/contexts/configContext.tsx` file, so that * the app has access to the same env vars when running in both OpenShift and local development. diff --git a/app/src/App.tsx b/app/src/App.tsx index a22693d7ea..3097514ba3 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -28,6 +28,8 @@ const App = () => { authority: `${config.KEYCLOAK_CONFIG.authority}/realms/${config.KEYCLOAK_CONFIG.realm}/`, client_id: config.KEYCLOAK_CONFIG.clientId, resource: config.KEYCLOAK_CONFIG.clientId, + // Automatically renew the access token before it expires + automaticSilentRenew: true, // Default sign in redirect redirect_uri: buildUrl(window.location.origin), // Default sign out redirect diff --git a/app/src/AppRouter.tsx b/app/src/AppRouter.tsx index 4728f20175..5e09060113 100644 --- a/app/src/AppRouter.tsx +++ b/app/src/AppRouter.tsx @@ -6,7 +6,7 @@ import AdminUsersRouter from 'features/admin/AdminUsersRouter'; import FundingSourcesRouter from 'features/funding-sources/FundingSourcesRouter'; import ProjectsRouter from 'features/projects/ProjectsRouter'; import ResourcesPage from 'features/resources/ResourcesPage'; -import SpeciesStandardsPage from 'features/standards/SpeciesStandardsPage'; +import StandardsPage from 'features/standards/StandardsPage'; import SummaryRouter from 'features/summary/SummaryRouter'; import BaseLayout from 'layouts/BaseLayout'; import AccessDenied from 'pages/403/AccessDenied'; @@ -83,7 +83,9 @@ const AppRouter: React.FC = () => { - + + + @@ -112,7 +114,7 @@ const AppRouter: React.FC = () => { - + diff --git a/app/src/components/accordion/AccordionCard.tsx b/app/src/components/accordion/AccordionCard.tsx deleted file mode 100644 index 2a604276b7..0000000000 --- a/app/src/components/accordion/AccordionCard.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { mdiChevronDown, mdiDotsVertical } from '@mdi/js'; -import { Icon } from '@mdi/react'; -import Accordion from '@mui/material/Accordion'; -import AccordionDetails from '@mui/material/AccordionDetails'; -import AccordionSummary from '@mui/material/AccordionSummary'; -import Box from '@mui/material/Box'; -import IconButton from '@mui/material/IconButton'; -import Paper from '@mui/material/Paper'; - -interface IAccordionCardProps { - /** - * The content to display in the non-collapsible portion of the card. - */ - summaryContent: JSX.Element; - /** - * The content to display in the collapsible portion of the card, when expanded. - */ - detailsContent: JSX.Element; - /** - * Callback for when the menu button is clicked. - * If not provided, the menu button will not be rendered. - */ - onMenuClick?: (event: React.MouseEvent) => void; - /** - * Icon to display in the menu button. - * Defaults to three vertical dots. - */ - menuIcon?: JSX.Element; - /** - * If true, the accordion will be expanded by default. - */ - expanded?: boolean; -} - -/** - * General purpose accordion card component. - * - * @param {IAccordionCardProps} props - * @return {*} - */ -export const AccordionCard = (props: IAccordionCardProps) => { - const { summaryContent, detailsContent, onMenuClick, menuIcon, expanded } = props; - - return ( - - - } - aria-controls="panel1bh-content" - sx={{ - flex: '1 1 auto', - mr: 1, - pr: 8.5, - minHeight: 55, - overflow: 'hidden', - border: 0, - '& .MuiAccordionSummary-content': { - flex: '1 1 auto', - py: 0, - pl: 0, - overflow: 'hidden', - whiteSpace: 'nowrap' - } - }}> - {summaryContent} - - {onMenuClick && ( - - {menuIcon || } - - )} - - {detailsContent} - - ); -}; diff --git a/app/src/components/alert/FormikErrorSnackbar.tsx b/app/src/components/alert/FormikErrorSnackbar.tsx index b62d93cb0f..97d749ff18 100644 --- a/app/src/components/alert/FormikErrorSnackbar.tsx +++ b/app/src/components/alert/FormikErrorSnackbar.tsx @@ -4,6 +4,23 @@ import { useFormikContext } from 'formik'; import { useEffect, useState } from 'react'; import { logIfDevelopment } from 'utils/developer-utils'; +/** + * Plug-and-play Formik error snackbar component. + * + * Adds a snackbar that displays when there are form validation errors. + * + * Additionally, when in NODE_ENV=development, logs the formik values and errors to the console. + * + * @example + * ```tsx + * + * + *
+ * + * ``` + * + * @return {*} + */ const FormikErrorSnackbar = () => { const formikProps = useFormikContext(); const { values, errors, submitCount, isSubmitting } = formikProps; diff --git a/app/src/components/attachments/FileUploadWithMeta.tsx b/app/src/components/attachments/FileUploadWithMeta.tsx index c16676baa6..7d7574a94c 100644 --- a/app/src/components/attachments/FileUploadWithMeta.tsx +++ b/app/src/components/attachments/FileUploadWithMeta.tsx @@ -8,12 +8,12 @@ import { IUploadHandler, UploadFileStatus } from 'components/file-upload/FileUploadItem'; -import { AttachmentType, ProjectSurveyAttachmentValidExtensions } from 'constants/attachments'; +import { AttachmentType, AttachmentTypeFileExtensions } from 'constants/attachments'; import { useFormikContext } from 'formik'; import React from 'react'; export interface IFileUploadWithMetaProps { - attachmentType: AttachmentType.REPORT | AttachmentType.KEYX | AttachmentType.OTHER; + attachmentType: AttachmentType.REPORT | AttachmentType.KEYX | AttachmentType.CFG | AttachmentType.OTHER; uploadHandler: IUploadHandler; fileHandler?: IFileHandler; onSuccess?: IOnUploadSuccess; @@ -52,7 +52,7 @@ export const FileUploadWithMeta: React.FC = (props) => dropZoneProps={{ maxNumFiles: 1, multiple: false, - acceptedFileExtensions: ProjectSurveyAttachmentValidExtensions.REPORT + acceptedFileExtensions: AttachmentTypeFileExtensions.REPORT }} status={UploadFileStatus.STAGED} replace={true} @@ -71,7 +71,7 @@ export const FileUploadWithMeta: React.FC = (props) => fileHandler={fileHandler} onSuccess={props.onSuccess} dropZoneProps={{ - acceptedFileExtensions: ProjectSurveyAttachmentValidExtensions.KEYX + acceptedFileExtensions: AttachmentTypeFileExtensions.KEYX }} enableErrorDetails={true} /> diff --git a/app/src/components/chips/AccessStatusChip.tsx b/app/src/components/chips/AccessStatusChip.tsx index 111240bf07..c9ca012592 100644 --- a/app/src/components/chips/AccessStatusChip.tsx +++ b/app/src/components/chips/AccessStatusChip.tsx @@ -9,15 +9,18 @@ const useStyles = () => { return { chipPending: { color: '#fff', - backgroundColor: theme.palette.error.main + backgroundColor: theme.palette.error.main, + userSelect: 'none' }, chipActioned: { color: '#fff', - backgroundColor: theme.palette.success.main + backgroundColor: theme.palette.success.main, + userSelect: 'none' }, chipRejected: { color: '#fff', - backgroundColor: theme.palette.error.main + backgroundColor: theme.palette.error.main, + userSelect: 'none' } }; }; diff --git a/app/src/components/chips/ColouredRectangleChip.tsx b/app/src/components/chips/ColouredRectangleChip.tsx index 8cc7716d45..9baf0282f0 100644 --- a/app/src/components/chips/ColouredRectangleChip.tsx +++ b/app/src/components/chips/ColouredRectangleChip.tsx @@ -27,7 +27,8 @@ const ColouredRectangleChip = (props: IColouredRectangleChipProps) => { fontSize: '0.75rem', p: 1, textTransform: 'uppercase' - } + }, + ...props.sx }} /> ); diff --git a/app/src/components/data-grid/GenericGridColumnDefinitions.tsx b/app/src/components/data-grid/GenericGridColumnDefinitions.tsx index e6711ec818..f432551087 100644 --- a/app/src/components/data-grid/GenericGridColumnDefinitions.tsx +++ b/app/src/components/data-grid/GenericGridColumnDefinitions.tsx @@ -1,8 +1,5 @@ -import { mdiTrashCanOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import IconButton from '@mui/material/IconButton'; import Typography from '@mui/material/Typography'; -import { GridCellParams, GridColDef, GridRowParams, GridValidRowModel } from '@mui/x-data-grid'; +import { GridCellParams, GridColDef, GridValidRowModel } from '@mui/x-data-grid'; import TextFieldDataGrid from 'components/data-grid/TextFieldDataGrid'; import TimePickerDataGrid from 'components/data-grid/TimePickerDataGrid'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; @@ -13,13 +10,15 @@ import { getFormattedDate } from 'utils/Utils'; export const GenericDateColDef = (props: { field: string; headerName: string; + description?: string; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { field, headerName, hasError } = props; + const { field, headerName, hasError, description } = props; return { field, headerName, + description: description ?? undefined, editable: true, hideable: true, type: 'date', @@ -62,15 +61,17 @@ export const GenericDateColDef = (props: { export const GenericTimeColDef = (props: { field: string; headerName: string; + description?: string; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { hasError, field, headerName } = props; + const { hasError, field, headerName, description } = props; return { field, headerName, editable: true, hideable: true, + description: description ?? undefined, type: 'string', width: 150, disableColumnMenu: true, @@ -124,13 +125,15 @@ export const GenericTimeColDef = (props: { export const GenericLatitudeColDef = (props: { field: string; headerName: string; + description?: string; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { hasError, field, headerName } = props; + const { hasError, field, headerName, description } = props; return { field, headerName, + description: description ?? undefined, editable: true, hideable: true, width: 120, @@ -183,13 +186,15 @@ export const GenericLatitudeColDef = (props: { export const GenericLongitudeColDef = (props: { field: string; headerName: string; + description?: string; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { hasError, field, headerName } = props; + const { hasError, field, headerName, description } = props; return { field, headerName, + description: description ?? undefined, editable: true, hideable: true, width: 120, @@ -238,29 +243,3 @@ export const GenericLongitudeColDef = (props: { } }; }; - -export const GenericActionsColDef = (props: { - disabled: boolean | ((params: GridRowParams) => boolean); - onDelete: (records: T[]) => void; -}): GridColDef => { - return { - field: 'actions', - headerName: '', - type: 'actions', - width: 70, - disableColumnMenu: true, - align: 'right', - resizable: false, - cellClassName: 'pinnedColumn', - getActions: (params) => [ - { - props.onDelete([params.row]); - }} - disabled={typeof props.disabled === 'function' ? props.disabled(params) : props.disabled} - key={`actions[${params.id}].handleDeleteRow`}> - - - ] - }; -}; diff --git a/app/src/components/data-grid/StyledDataGrid.tsx b/app/src/components/data-grid/StyledDataGrid.tsx index ba283d3a8c..566f929745 100644 --- a/app/src/components/data-grid/StyledDataGrid.tsx +++ b/app/src/components/data-grid/StyledDataGrid.tsx @@ -49,9 +49,21 @@ export const StyledDataGrid = (props: StyledD '& .MuiDataGrid-columnHeader:last-of-type, .MuiDataGrid-cell:last-of-type': { pr: 2 }, - '&.MuiDataGrid-root--densityCompact .MuiDataGrid-cell': { py: '8px' }, - '&.MuiDataGrid-root--densityStandard .MuiDataGrid-cell': { py: '15px' }, - '&.MuiDataGrid-root--densityComfortable .MuiDataGrid-cell': { py: '22px' }, + '&.MuiDataGrid-root--densityCompact .MuiDataGrid-cell': { + py: '8px', + wordWrap: 'anywhere' + }, + '&.MuiDataGrid-root--densityStandard .MuiDataGrid-cell': { + py: '15px', + wordWrap: 'anywhere' + }, + '&.MuiDataGrid-root--densityComfortable .MuiDataGrid-cell': { + py: '22px', + wordWrap: 'anywhere' + }, + '& .MuiDataGrid-columnHeaderDraggableContainer': { + minWidth: '50px' + }, ...props.sx }} /> diff --git a/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx b/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx index a46027a4c2..91a244b3b9 100644 --- a/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx +++ b/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx @@ -1,46 +1,40 @@ import { Paper } from '@mui/material'; -import Autocomplete from '@mui/material/Autocomplete'; +import Autocomplete, { AutocompleteProps } from '@mui/material/Autocomplete'; import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; -import { grey } from '@mui/material/colors'; +import ListItemText from '@mui/material/ListItemText'; import TextField from '@mui/material/TextField'; import useEnhancedEffect from '@mui/material/utils/useEnhancedEffect'; -import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; -import { - IAutocompleteDataGridOption, - IAutocompleteDataGridTaxonomyOption -} from 'components/data-grid/autocomplete/AutocompleteDataGrid.interface'; -import SpeciesCard from 'components/species/components/SpeciesCard'; +import { GridRenderEditCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { IAutocompleteDataGridOption } from 'components/data-grid/autocomplete/AutocompleteDataGrid.interface'; import { DebouncedFunc } from 'lodash-es'; import { useEffect, useRef, useState } from 'react'; export interface IAsyncAutocompleteDataGridEditCell< DataGridType extends GridValidRowModel, + AutocompleteOptionType extends IAutocompleteDataGridOption, ValueType extends string | number > { /** * Data grid props for the cell. * - * @type {GridRenderCellParams} + * @type {GridRenderEditCellParams} * @memberof IAsyncAutocompleteDataGridEditCell */ - dataGridProps: GridRenderCellParams; + dataGridProps: GridRenderEditCellParams; /** * Function that returns a single option. Used to translate an existing value to its matching option. * * @memberof IAsyncAutocompleteDataGridEditCell */ - getCurrentOption: (value: ValueType) => Promise | null>; + getCurrentOption: (value: ValueType) => Promise; /** * Search function that returns an array of options to choose from. * * @memberof IAsyncAutocompleteDataGridEditCell */ getOptions: DebouncedFunc< - ( - searchTerm: string, - onSearchResults: (searchResults: IAutocompleteDataGridTaxonomyOption[]) => void - ) => Promise + (searchTerm: string, onSearchResults: (searchResults: AutocompleteOptionType[]) => void) => Promise >; /** * Indicates if there is an error with the control @@ -51,21 +45,26 @@ export interface IAsyncAutocompleteDataGridEditCell< /** * Optional function to render the autocomplete option. */ - renderOption?: (option: IAutocompleteDataGridOption) => JSX.Element; + renderOption?: AutocompleteProps['renderOption']; } /** * Data grid single value asynchronous autocomplete component for edit. * * @template DataGridType + * @template AutocompleteOptionType * @template ValueType - * @param {IAsyncAutocompleteDataGridEditCell} props + * @param {IAsyncAutocompleteDataGridEditCell} props * @return {*} */ -const AsyncAutocompleteDataGridEditCell = ( - props: IAsyncAutocompleteDataGridEditCell +const AsyncAutocompleteDataGridEditCell = < + DataGridType extends GridValidRowModel, + AutocompleteOptionType extends IAutocompleteDataGridOption, + ValueType extends string | number +>( + props: IAsyncAutocompleteDataGridEditCell ) => { - const { dataGridProps, getCurrentOption, getOptions } = props; + const { dataGridProps, getCurrentOption, getOptions, error, renderOption } = props; const ref = useRef(); @@ -78,11 +77,11 @@ const AsyncAutocompleteDataGridEditCell = ['label']>(''); + const [inputValue, setInputValue] = useState(''); // The currently selected option - const [currentOption, setCurrentOption] = useState | null>(null); + const [currentOption, setCurrentOption] = useState(null); // The array of options to choose from - const [options, setOptions] = useState[]>([]); + const [options, setOptions] = useState([]); // Is control loading (search in progress) const [isLoading, setIsLoading] = useState(false); @@ -187,9 +186,9 @@ const AsyncAutocompleteDataGridEditCell = @@ -200,23 +199,16 @@ const AsyncAutocompleteDataGridEditCell = )} - renderOption={(renderProps, renderOption) => { - return ( - - - + renderOption={ + renderOption ?? + ((renderProps, renderOption) => { + return ( + + - - ); - }} + ); + }) + } data-testid={dataGridProps.id} /> ); diff --git a/app/src/components/data-grid/autocomplete/AutocompleteDataGrid.interface.ts b/app/src/components/data-grid/autocomplete/AutocompleteDataGrid.interface.ts index fd0f1183d1..48d7dea057 100644 --- a/app/src/components/data-grid/autocomplete/AutocompleteDataGrid.interface.ts +++ b/app/src/components/data-grid/autocomplete/AutocompleteDataGrid.interface.ts @@ -1,5 +1,3 @@ -import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; - /** * Defines a single option for a data grid autocomplete control. * @@ -12,16 +10,3 @@ export interface IAutocompleteDataGridOption label: string; subtext?: string; } - -/** - * Defines a single option for a data grid taxonomy autocomplete control. - * - * @export - * @interface IAutocompleteDataGridTaxonomyOption - * @extends {ITaxonomy} - * @template ValueType - */ -export interface IAutocompleteDataGridTaxonomyOption extends IPartialTaxonomy { - value: ValueType; - label: string; -} diff --git a/app/src/components/data-grid/taxonomy/TaxonomyDataGrid.interface.ts b/app/src/components/data-grid/taxonomy/TaxonomyDataGrid.interface.ts new file mode 100644 index 0000000000..4e3542ccbe --- /dev/null +++ b/app/src/components/data-grid/taxonomy/TaxonomyDataGrid.interface.ts @@ -0,0 +1,13 @@ +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; + +/** + * Defines a single option for a data grid taxonomy autocomplete control. + * + * @export + * @interface IAutocompleteDataGridTaxonomyOption + * @extends {IPartialTaxonomy} + */ +export interface IAutocompleteDataGridTaxonomyOption extends IPartialTaxonomy { + value: number; + label: string; +} diff --git a/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx b/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx index d08377ea24..ba47cfd44f 100644 --- a/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx +++ b/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx @@ -1,10 +1,13 @@ +import Box from '@mui/material/Box'; +import { grey } from '@mui/material/colors'; import { GridRenderEditCellParams, GridValidRowModel } from '@mui/x-data-grid'; import AsyncAutocompleteDataGridEditCell from 'components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell'; +import { IAutocompleteDataGridTaxonomyOption } from 'components/data-grid/taxonomy/TaxonomyDataGrid.interface'; +import SpeciesCard from 'components/species/components/SpeciesCard'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useTaxonomyContext } from 'hooks/useContext'; import debounce from 'lodash-es/debounce'; import { useMemo } from 'react'; -import { IAutocompleteDataGridTaxonomyOption } from '../autocomplete/AutocompleteDataGrid.interface'; export interface ITaxonomyDataGridCellProps { dataGridProps: GridRenderEditCellParams; @@ -19,7 +22,7 @@ export interface ITaxonomyDataGridCellProps} props * @return {*} */ -const TaxonomyDataGridEditCell = ( +const TaxonomyDataGridEditCell = ( props: ITaxonomyDataGridCellProps ) => { const { dataGridProps } = props; @@ -27,9 +30,7 @@ const TaxonomyDataGridEditCell = | null> => { + const getCurrentOption = async (speciesId: string | number): Promise => { if (!speciesId) { return null; } @@ -47,7 +48,7 @@ const TaxonomyDataGridEditCell = []) => void + onSearchResults: (searchedValues: IAutocompleteDataGridTaxonomyOption[]) => void ) => { if (!searchTerm) { onSearchResults([]); @@ -72,7 +73,7 @@ const TaxonomyDataGridEditCell = ({ - value: item.tsn as ValueType, + value: item.tsn, label: item.scientificName, tsn: item.tsn, commonNames: item.commonNames, @@ -93,6 +94,21 @@ const TaxonomyDataGridEditCell = ( + + + + + + )} /> ); }; diff --git a/app/src/components/dialog/YesNoDialog.tsx b/app/src/components/dialog/YesNoDialog.tsx index 757ca9130a..4703a2973d 100644 --- a/app/src/components/dialog/YesNoDialog.tsx +++ b/app/src/components/dialog/YesNoDialog.tsx @@ -52,7 +52,7 @@ export interface IYesNoDialogProps { * * @memberof IYesNoDialogProps */ - onYes: () => void; + onYes: () => Promise | void; /** * The yes button label. diff --git a/app/src/components/dialog/attachments/FileUploadWithMetaDialog.tsx b/app/src/components/dialog/attachments/FileUploadWithMetaDialog.tsx index b8264c57f4..c95f8c8a21 100644 --- a/app/src/components/dialog/attachments/FileUploadWithMetaDialog.tsx +++ b/app/src/components/dialog/attachments/FileUploadWithMetaDialog.tsx @@ -34,10 +34,10 @@ export interface IFileUploadWithMetaDialogProps { /** * The type of attachment. * - * @type {('Report' | 'KeyX' | 'Other')} + * @type {('Report' | 'KeyX' | 'Cfg' | 'Other')} * @memberof IFileUploadWithMetaDialogProps */ - attachmentType: AttachmentType.REPORT | AttachmentType.KEYX | AttachmentType.OTHER; + attachmentType: AttachmentType.REPORT | AttachmentType.KEYX | AttachmentType.CFG | AttachmentType.OTHER; /** * Set to `true` to open the dialog, `false` to close the dialog. * diff --git a/app/src/components/fields/AnimalAutocompleteField.tsx b/app/src/components/fields/AnimalAutocompleteField.tsx new file mode 100644 index 0000000000..755212e5c9 --- /dev/null +++ b/app/src/components/fields/AnimalAutocompleteField.tsx @@ -0,0 +1,177 @@ +import Autocomplete from '@mui/material/Autocomplete'; +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import grey from '@mui/material/colors/grey'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import { IAutocompleteFieldOption } from 'components/fields/AutocompleteField'; +import { ScientificNameTypography } from 'features/surveys/animals/components/ScientificNameTypography'; +import { useFormikContext } from 'formik'; +import { useSurveyContext } from 'hooks/useContext'; +import { ICritterSimpleResponse } from 'interfaces/useCritterApi.interface'; +import { get } from 'lodash-es'; +import { useState } from 'react'; + +export interface IAnimalAutocompleteFieldProps { + /** + * Formik field name. + * + * @type {string} + * @memberof IAnimalAutocompleteFieldProps + */ + formikFieldName: string; + /** + * The field label. + * + * @type {string} + * @memberof IAnimalAutocompleteFieldProps + */ + label: string; + /** + * Callback fired on option selection. + * + * @memberof IAnimalAutocompleteFieldProps + */ + onSelect: (animal: ICritterSimpleResponse) => void; + /** + * Optional callback fired on option de-selected/cleared. + * + * @memberof IAnimalAutocompleteFieldProps + */ + onClear?: () => void; + /** + * Default animal to render for input and options. + * + * @type {ICritterSimpleResponse} + * @memberof IAnimalAutocompleteFieldProps + */ + defaultAnimal?: ICritterSimpleResponse; + /** + * If field is required. + * + * @type {boolean} + * @memberof IAnimalAutocompleteFieldProps + */ + required?: boolean; + /** + * If field is disabled. + * + * @type {boolean} + * @memberof IAnimalAutocompleteFieldProps + */ + disabled?: boolean; + /** + * If `true`, clears the input field after a selection is made. + * + * @type {ICritterSimpleResponse} + * @memberof IAnimalAutocompleteFieldProps + */ + clearOnSelect?: boolean; + /** + * Placeholder text for the TextField + * + * @type {string} + * @memberof IAnimalAutocompleteFieldProps + */ + placeholder?: string; +} + +/** + * An autocomplete field for selecting an existing animal from the Survey. + * + * @template T + * @param {IAnimalAutocompleteFieldProps} props + * @return {*} + */ +export const AnimalAutocompleteField = (props: IAnimalAutocompleteFieldProps) => { + const { formikFieldName, label, onSelect, defaultAnimal, required, disabled, clearOnSelect, placeholder } = props; + + const { touched, errors, setFieldValue } = useFormikContext>(); + + const surveyContext = useSurveyContext(); + + // The input field value + const [inputValue, setInputValue] = useState(defaultAnimal?.animal_id ?? ''); + + // Survey animals to choose from + const options = surveyContext.critterDataLoader.data; + + return ( + option.animal_id ?? String(option.critter_id)} + isOptionEqualToValue={(option, value) => { + return option.critter_id === value.critter_id; + }} + filterOptions={(item) => item} + inputValue={inputValue} + onInputChange={(_, _value, reason) => { + if (clearOnSelect && reason === 'clear') { + setFieldValue(formikFieldName, ''); + setInputValue(''); + } + }} + onChange={(_, option) => { + if (option) { + onSelect(option); + setInputValue(option.animal_id ?? String(option.critter_id)); //startCase(option?.commonNames?.length ? option.commonNames[0] : option.scientificName)); + } + }} + renderOption={(renderProps, renderOption) => { + return ( + + + + + {renderOption.animal_id}  + + {renderOption.wlh_id} + + + {renderOption.critter_id} + + + + + ); + }} + renderInput={(params) => ( + setInputValue(event.currentTarget.value)} + required={required} + sx={{ opacity: props?.disabled ? 0.25 : 1 }} + error={get(touched, formikFieldName) && Boolean(get(errors, formikFieldName))} + helperText={get(touched, formikFieldName) && get(errors, formikFieldName)} + fullWidth + placeholder={placeholder || 'Search for an animal in the Survey'} + InputProps={{ + ...params.InputProps, + endAdornment: ( + <> + {surveyContext.critterDataLoader.isLoading ? : null} + {params.InputProps.endAdornment} + + ) + }} + /> + )} + /> + ); +}; diff --git a/app/src/components/fields/AutocompleteField.tsx b/app/src/components/fields/AutocompleteField.tsx index 974d0ccddd..421c969c23 100644 --- a/app/src/components/fields/AutocompleteField.tsx +++ b/app/src/components/fields/AutocompleteField.tsx @@ -71,7 +71,7 @@ const AutocompleteField = (props: IAutocompleteField< getOptionDisabled={props.getOptionDisabled} filterOptions={createFilterOptions({ limit: props.filterLimit })} disabled={props?.disabled || false} - sx={props.sx} + sx={{ flex: '1 1 auto', ...props.sx }} loading={props.loading} onInputChange={(_event, _value, reason) => { if (reason === 'reset') { diff --git a/app/src/components/fields/CbSelectField.tsx b/app/src/components/fields/CbSelectField.tsx deleted file mode 100644 index 1619510292..0000000000 --- a/app/src/components/fields/CbSelectField.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { FormControlProps } from '@mui/material/FormControl'; -import MenuItem from '@mui/material/MenuItem'; -import { SelectChangeEvent } from '@mui/material/Select'; -import { useFormikContext } from 'formik'; -import { ICbSelectRows, SelectOptionsProps } from 'hooks/cb_api/useLookupApi'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import useDataLoader from 'hooks/useDataLoader'; -import { startCase } from 'lodash-es'; -import get from 'lodash-es/get'; -import { useEffect, useMemo } from 'react'; -import { CbSelectWrapper } from './CbSelectFieldWrapper'; - -export interface ICbSelectSharedProps { - name: string; - label: string; - controlProps?: FormControlProps; -} - -export type ICbSelectField = ICbSelectSharedProps & - SelectOptionsProps & { - id: string; - disabledValues?: Record; - handleChangeSideEffect?: (value: string, label: string) => void; - }; - -interface ICbSelectOption { - value: string | number; - label: string; -} -/** - * Critterbase Select Field. Handles data retrieval, formatting and error handling. - * - * @param {ICbSelectField} props - * @return {*} - * - */ -const CbSelectField = (props: ICbSelectField) => { - const { name, orderBy, label, route, query, handleChangeSideEffect, controlProps, disabledValues } = props; - - const critterbaseApi = useCritterbaseApi(); - - const { data, refresh } = useDataLoader(critterbaseApi.lookup.getSelectOptions); - const { values, handleChange } = useFormikContext(); - - const val = get(values, name) ?? ''; - - const selectParams = { route, query, orderBy }; - - useEffect(() => { - // Skip fetching if route ends with an undefined id - // example: /xref/collection-units/{undefined} - if (route.endsWith('/')) { - return; - } - refresh(selectParams); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(selectParams)]); - - const isValueInRange = useMemo(() => { - if (val === '') { - return true; - } - if (!data) { - return false; - } - const inRange = data.some((d) => (typeof d === 'string' ? d === val : d.id === val)); - - return inRange; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data, val, name]); - - const innerChangeHandler = (e: SelectChangeEvent) => { - handleChange(e); - // useful for when the select item label is needed in parent component - if (handleChangeSideEffect) { - const item = data?.find((a) => typeof a !== 'string' && a.id === e.target.value); - handleChangeSideEffect(e.target.value, (item as ICbSelectRows).value); - } - }; - - return ( - - {data?.map((a) => { - const item = typeof a === 'string' ? { label: a, value: a } : { label: a.value, value: a.id }; - return ( - - {startCase(item.label)} - - ); - })} - - ); -}; - -export default CbSelectField; diff --git a/app/src/components/fields/CbSelectFieldWrapper.tsx b/app/src/components/fields/CbSelectFieldWrapper.tsx deleted file mode 100644 index 19ed62ff19..0000000000 --- a/app/src/components/fields/CbSelectFieldWrapper.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import FormControl from '@mui/material/FormControl'; -import FormHelperText from '@mui/material/FormHelperText'; -import InputLabel from '@mui/material/InputLabel'; -import Select, { SelectProps } from '@mui/material/Select'; -import { useFormikContext } from 'formik'; -import { get } from 'lodash-es'; -import { ReactNode } from 'react'; -import { ICbSelectSharedProps } from './CbSelectField'; - -interface CbSelectWrapperProps extends ICbSelectSharedProps { - children?: ReactNode; - onChange?: SelectProps['onChange']; - value?: SelectProps['value']; -} - -/** - * - * Wrapper for cb selects to handle all errors / onChange / onBlur - * - * @return {*} - * - **/ - -export const CbSelectWrapper = ({ children, name, label, controlProps, onChange, value }: CbSelectWrapperProps) => { - const { values, touched, errors, handleBlur, handleChange } = useFormikContext(); - const val = get(values, name) ?? ''; - const err = get(touched, name) && get(errors, name); - return ( - - {label} - - {err} - - ); -}; diff --git a/app/src/components/fields/CustomTextField.tsx b/app/src/components/fields/CustomTextField.tsx index 38744291fd..4f50ed4ae3 100644 --- a/app/src/components/fields/CustomTextField.tsx +++ b/app/src/components/fields/CustomTextField.tsx @@ -9,6 +9,13 @@ export interface ICustomTextField { * @memberof ICustomTextField */ label: string; + /** + * Placeholder for the text field + * + * @type {string} + * @memberof ICustomTextField + */ + placeholder?: string; /** * Name of the text field, typically this is used to identify the field in the formik context. * @@ -34,13 +41,14 @@ export interface ICustomTextField { const CustomTextField = (props: React.PropsWithChildren) => { const { touched, errors, values, handleChange, handleBlur } = useFormikContext(); - const { name, label, other } = props; + const { name, label, other, placeholder } = props; return ( { + label: string; + name: string; + id: string; + required: boolean; + formikProps: FormikContextType; +} + +export const DateField = (props: IDateFieldProps) => { + const { + formikProps: { values, errors, touched, setFieldValue }, + label, + name, + id, + required + } = props; + + const rawDateValue = get(values, name); + const formattedDateValue = + (rawDateValue && + dayjs(rawDateValue, DATE_FORMAT.ShortDateFormat).isValid() && + dayjs(rawDateValue, DATE_FORMAT.ShortDateFormat)) || + null; + + return ( + + + }} + slotProps={{ + textField: { + id: id, + name: name, + required: required, + variant: 'outlined', + error: get(touched, name) && Boolean(get(errors, name)), + helperText: get(touched, name) && get(errors, name), + inputProps: { + 'data-testid': name + }, + InputLabelProps: { + shrink: true + }, + fullWidth: true + } + }} + label={label} + format={DATE_FORMAT.ShortDateFormat} + minDate={dayjs(DATE_LIMIT.min)} + maxDate={dayjs(DATE_LIMIT.max)} + value={formattedDateValue} + onChange={(value) => { + if (!value || value === 'Invalid Date') { + // The creation input value will be 'Invalid Date' when the date field is cleared (empty), and will + // contain an actual date string value if the field is not empty but is invalid. + setFieldValue(name, null); + return; + } + + setFieldValue(name, dayjs(value).format(DATE_FORMAT.ShortDateFormat)); + }} + /> + + ); +}; diff --git a/app/src/components/fields/StartEndDateFields.tsx b/app/src/components/fields/StartEndDateFields.tsx index ccd12b51a7..6efcaad1af 100644 --- a/app/src/components/fields/StartEndDateFields.tsx +++ b/app/src/components/fields/StartEndDateFields.tsx @@ -6,11 +6,11 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { DATE_FORMAT, DATE_LIMIT } from 'constants/dateTimeFormats'; import { default as dayjs } from 'dayjs'; +import { useFormikContext } from 'formik'; import get from 'lodash-es/get'; import React from 'react'; interface IStartEndDateFieldsProps { - formikProps: any; startName: string; endName: string; startRequired: boolean; @@ -30,13 +30,9 @@ const CalendarEndIcon = () => { * */ const StartEndDateFields: React.FC = (props) => { - const { - formikProps: { values, errors, touched, setFieldValue }, - startName, - endName, - startRequired, - endRequired - } = props; + const { startName, endName, startRequired, endRequired } = props; + + const { values, errors, touched, setFieldValue } = useFormikContext(); const rawStartDateValue = get(values, startName); const rawEndDateValue = get(values, endName); diff --git a/app/src/components/fields/SystemUserAutocompleteField.tsx b/app/src/components/fields/SystemUserAutocompleteField.tsx index 1af9a1cc89..be2e133998 100644 --- a/app/src/components/fields/SystemUserAutocompleteField.tsx +++ b/app/src/components/fields/SystemUserAutocompleteField.tsx @@ -182,8 +182,8 @@ export const SystemUserAutocompleteField = (props: ISystemUserAutocompleteFieldP borderTop: '1px solid' + grey[300] } }} - key={renderOption.system_user_id} - {...renderProps}> + {...renderProps} + key={renderOption.system_user_id}> = (props) => { +const TelemetrySelectField: React.FC = (props) => { const bctwLookupLoader = useDataLoader(() => props.fetchData()); const { values, touched, errors, handleChange, handleBlur } = useFormikContext(); diff --git a/app/src/components/fields/TimeField.tsx b/app/src/components/fields/TimeField.tsx new file mode 100644 index 0000000000..23772bbfab --- /dev/null +++ b/app/src/components/fields/TimeField.tsx @@ -0,0 +1,83 @@ +import { mdiClockOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import { TimePicker } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { TIME_FORMAT } from 'constants/dateTimeFormats'; +import { default as dayjs } from 'dayjs'; +import { FormikContextType } from 'formik'; +import get from 'lodash-es/get'; + +interface ITimeFieldProps { + label: string; + name: string; + id: string; + required: boolean; + formikProps: FormikContextType; +} + +export const TimeField = (props: ITimeFieldProps) => { + const { + formikProps: { values, errors, touched, setFieldValue }, + label, + name, + id, + required + } = props; + + const rawTimeValue = get(values, name); + const formattedTimeValue = + (rawTimeValue && + dayjs(rawTimeValue, TIME_FORMAT.LongTimeFormat24Hour).isValid() && + dayjs(rawTimeValue, TIME_FORMAT.LongTimeFormat24Hour)) || + null; + + return ( + + + }} + slotProps={{ + textField: { + id: id, + name: name, + required: required, + variant: 'outlined', + error: get(touched, name) && Boolean(get(errors, name)), + helperText: get(touched, name) && get(errors, name), + inputProps: { + 'data-testid': id, + 'aria-label': 'Time (optional)' + }, + InputLabelProps: { + shrink: true + }, + fullWidth: true + } + }} + label={label} + format={TIME_FORMAT.LongTimeFormat24Hour} + value={formattedTimeValue} + onChange={(value: dayjs.Dayjs | null) => { + if (!value || !dayjs(value).isValid()) { + // Check if the value is null or invalid, and if so, clear the field. + setFieldValue(name, null); + return; + } + + setFieldValue(name, dayjs(value).format(TIME_FORMAT.LongTimeFormat24Hour)); + }} + views={['hours', 'minutes', 'seconds']} + timeSteps={{ hours: 1, minutes: 1, seconds: 1 }} + ampm={false} + /> + + ); +}; diff --git a/app/src/components/file-upload/DropZone.tsx b/app/src/components/file-upload/DropZone.tsx index 953b49c418..2fa5c2bdbf 100644 --- a/app/src/components/file-upload/DropZone.tsx +++ b/app/src/components/file-upload/DropZone.tsx @@ -48,12 +48,13 @@ export interface IDropZoneConfigProps { /** * Comma separated list of allowed file extensions. * - * Example: `'.pdf, .txt'` + * @example '.pdf, .txt' + * @example ['.pdf', '.txt'] * - * @type {string} + * @type {(string | string[])} * @memberof IDropZoneConfigProps */ - acceptedFileExtensions?: string; + acceptedFileExtensions?: string | string[]; } export const DropZone: React.FC = (props) => { diff --git a/app/src/components/layout/Header.tsx b/app/src/components/layout/Header.tsx index 3366c386fa..0e52a07142 100644 --- a/app/src/components/layout/Header.tsx +++ b/app/src/components/layout/Header.tsx @@ -277,11 +277,9 @@ const Header: React.FC = () => { Funding Sources - - - Standards - - + + Standards + Support @@ -354,11 +352,9 @@ const Header: React.FC = () => { Funding Sources - - - Standards - - + + Standards + - - - )} - /> - - ); -}; - -export default AddSystemUsersForm; diff --git a/app/src/features/admin/users/ManageUsersPage.test.tsx b/app/src/features/admin/users/ManageUsersPage.test.tsx index 3cd33589cc..ea17d4e119 100644 --- a/app/src/features/admin/users/ManageUsersPage.test.tsx +++ b/app/src/features/admin/users/ManageUsersPage.test.tsx @@ -1,8 +1,11 @@ import { AuthStateContext } from 'contexts/authStateContext'; +import { CodesContext, ICodesContext } from 'contexts/codesContext'; import { createMemoryHistory } from 'history'; import { useBiohubApi } from 'hooks/useBioHubApi'; +import { DataLoader } from 'hooks/useDataLoader'; import { Router } from 'react-router'; import { getMockAuthState, SystemAdminAuthState } from 'test-helpers/auth-helpers'; +import { codes } from 'test-helpers/code-helpers'; import { cleanup, render, waitFor } from 'test-helpers/test-utils'; import ManageUsersPage from './ManageUsersPage'; @@ -11,11 +14,20 @@ const history = createMemoryHistory(); const renderContainer = () => { const authState = getMockAuthState({ base: SystemAdminAuthState }); + const mockCodesContext: ICodesContext = { + codesDataLoader: { + data: codes, + load: () => {} + } as DataLoader + }; + return render( - - - + + + + + ); }; @@ -74,7 +86,7 @@ describe('ManageUsersPage', () => { const { getByText } = renderContainer(); await waitFor(() => { - expect(getByText('No Access Requests')).toBeVisible(); + expect(getByText('No Pending Access Requests')).toBeVisible(); expect(getByText('No Active Users')).toBeVisible(); }); }); diff --git a/app/src/features/admin/users/ManageUsersPage.tsx b/app/src/features/admin/users/ManageUsersPage.tsx index 69afec4863..9c11f7e044 100644 --- a/app/src/features/admin/users/ManageUsersPage.tsx +++ b/app/src/features/admin/users/ManageUsersPage.tsx @@ -3,13 +3,13 @@ import CircularProgress from '@mui/material/CircularProgress'; import Container from '@mui/material/Container'; import PageHeader from 'components/layout/PageHeader'; import { AdministrativeActivityStatusType, AdministrativeActivityType } from 'constants/misc'; -import AccessRequestList from 'features/admin/users/AccessRequestList'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; import { ISystemUser } from 'interfaces/useUserApi.interface'; import React, { useEffect, useState } from 'react'; -import ActiveUsersList from './ActiveUsersList'; +import AccessRequestContainer from './access-requests/AccessRequestContainer'; +import ActiveUsersList from './active/ActiveUsersList'; /** * Page to display user management data/functionality. @@ -33,7 +33,11 @@ const ManageUsersPage: React.FC = () => { const refreshAccessRequests = async () => { const accessResponse = await biohubApi.admin.getAdministrativeActivities( [AdministrativeActivityType.SYSTEM_ACCESS], - [AdministrativeActivityStatusType.PENDING, AdministrativeActivityStatusType.REJECTED] + [ + AdministrativeActivityStatusType.PENDING, + AdministrativeActivityStatusType.REJECTED, + AdministrativeActivityStatusType.ACTIONED + ] ); setAccessRequests(accessResponse); @@ -43,7 +47,11 @@ const ManageUsersPage: React.FC = () => { const getAccessRequests = async () => { const accessResponse = await biohubApi.admin.getAdministrativeActivities( [AdministrativeActivityType.SYSTEM_ACCESS], - [AdministrativeActivityStatusType.PENDING, AdministrativeActivityStatusType.REJECTED] + [ + AdministrativeActivityStatusType.PENDING, + AdministrativeActivityStatusType.REJECTED, + AdministrativeActivityStatusType.ACTIONED + ] ); setAccessRequests(() => { @@ -120,7 +128,7 @@ const ManageUsersPage: React.FC = () => { <> - { diff --git a/app/src/features/admin/users/access-requests/AccessRequestContainer.tsx b/app/src/features/admin/users/access-requests/AccessRequestContainer.tsx new file mode 100644 index 0000000000..051a9616ce --- /dev/null +++ b/app/src/features/admin/users/access-requests/AccessRequestContainer.tsx @@ -0,0 +1,123 @@ +import { mdiCancel, mdiCheck, mdiExclamationThick } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; +import Paper from '@mui/material/Paper'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup/ToggleButtonGroup'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; +import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; +import { useState } from 'react'; +import AccessRequestActionedList from './list/actioned/AccessRequestActionedList'; +import AccessRequestPendingList from './list/pending/AccessRequestPendingList'; +import AccessRequestRejectedList from './list/rejected/AccessRequestRejectedList'; + +export interface IAccessRequestContainerProps { + accessRequests: IGetAccessRequestsListResponse[]; + codes: IGetAllCodeSetsResponse; + refresh: () => void; +} + +enum AccessRequestViewEnum { + ACTIONED = 'ACTIONED', + PENDING = 'PENDING', + REJECTED = 'REJECTED' +} + +/** + * Container for displaying a list of user access requests. + * + */ +const AccessRequestContainer = (props: IAccessRequestContainerProps) => { + const { accessRequests, codes, refresh } = props; + const [activeView, setActiveView] = useState(AccessRequestViewEnum.PENDING); + + const views = [ + { value: AccessRequestViewEnum.PENDING, label: 'Pending', icon: mdiExclamationThick }, + { value: AccessRequestViewEnum.ACTIONED, label: 'Approved', icon: mdiCheck }, + { value: AccessRequestViewEnum.REJECTED, label: 'Rejected', icon: mdiCancel } + ]; + + const pendingRequests = accessRequests.filter((request) => request.status_name === 'Pending'); + const actionedRequests = accessRequests.filter((request) => request.status_name === 'Actioned'); + const rejectedRequests = accessRequests.filter((request) => request.status_name === 'Rejected'); + + return ( + + + + Access Requests + + + + + { + if (!view) { + // An active view must be selected at all times + return; + } + + setActiveView(view); + }} + exclusive + sx={{ + width: '100%', + gap: 1, + '& Button': { + py: 0.5, + px: 1.5, + border: 'none !important', + fontWeight: 700, + borderRadius: '4px !important', + fontSize: '0.875rem', + letterSpacing: '0.02rem' + } + }}> + {views.map((view) => { + const getCount = () => { + switch (view.value) { + case AccessRequestViewEnum.PENDING: + return pendingRequests.length; + case AccessRequestViewEnum.ACTIONED: + return actionedRequests.length; + case AccessRequestViewEnum.REJECTED: + return rejectedRequests.length; + default: + return 0; + } + }; + return ( + }> + {view.label} ({getCount()}) + + ); + })} + + + + + {activeView === AccessRequestViewEnum.PENDING && ( + + )} + {activeView === AccessRequestViewEnum.ACTIONED && ( + + )} + {activeView === AccessRequestViewEnum.REJECTED && ( + + )} + + + ); +}; + +export default AccessRequestContainer; diff --git a/app/src/features/admin/users/ReviewAccessRequestForm.test.tsx b/app/src/features/admin/users/access-requests/components/ReviewAccessRequestForm.test.tsx similarity index 95% rename from app/src/features/admin/users/ReviewAccessRequestForm.test.tsx rename to app/src/features/admin/users/access-requests/components/ReviewAccessRequestForm.test.tsx index 1c4c5a2c36..4041c0f84a 100644 --- a/app/src/features/admin/users/ReviewAccessRequestForm.test.tsx +++ b/app/src/features/admin/users/access-requests/components/ReviewAccessRequestForm.test.tsx @@ -1,12 +1,12 @@ import { SYSTEM_IDENTITY_SOURCE } from 'constants/auth'; -import ReviewAccessRequestForm, { - ReviewAccessRequestFormInitialValues, - ReviewAccessRequestFormYupSchema -} from 'features/admin/users/ReviewAccessRequestForm'; import { Formik } from 'formik'; import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; import { codes } from 'test-helpers/code-helpers'; import { render, waitFor } from 'test-helpers/test-utils'; +import ReviewAccessRequestForm, { + ReviewAccessRequestFormInitialValues, + ReviewAccessRequestFormYupSchema +} from './ReviewAccessRequestForm'; describe('ReviewAccessRequestForm', () => { describe('IDIR Request', () => { @@ -20,6 +20,8 @@ describe('ReviewAccessRequestForm', () => { description: 'test description', notes: 'test node', create_date: '2021-04-18', + updated_by: 'Doe, John WLRS:EX', + update_date: '2021-04-20', data: { name: 'test data name', username: 'test data username', @@ -54,7 +56,6 @@ describe('ReviewAccessRequestForm', () => { expect(getByText('test data name')).toBeVisible(); expect(getByText('IDIR/test data username')).toBeVisible(); expect(getByText('test data email')).toBeVisible(); - expect(getByText('04/18/2021')).toBeVisible(); }); }); }); @@ -70,6 +71,8 @@ describe('ReviewAccessRequestForm', () => { description: 'test description', notes: 'test node', create_date: '2021-04-18', + updated_by: 'Doe, John WLRS:EX', + update_date: '2021-04-20', data: { name: 'test data name', username: 'test data username', @@ -105,7 +108,6 @@ describe('ReviewAccessRequestForm', () => { expect(getByText('test data name')).toBeVisible(); expect(getByText('BCeID Basic/test data username')).toBeVisible(); expect(getByText('test data email')).toBeVisible(); - expect(getByText('04/18/2021')).toBeVisible(); expect(getByText('test company')).toBeVisible(); }); }); diff --git a/app/src/features/admin/users/ReviewAccessRequestForm.tsx b/app/src/features/admin/users/access-requests/components/ReviewAccessRequestForm.tsx similarity index 95% rename from app/src/features/admin/users/ReviewAccessRequestForm.tsx rename to app/src/features/admin/users/access-requests/components/ReviewAccessRequestForm.tsx index 51f4f2c7b9..5aee63c3f7 100644 --- a/app/src/features/admin/users/ReviewAccessRequestForm.tsx +++ b/app/src/features/admin/users/access-requests/components/ReviewAccessRequestForm.tsx @@ -4,10 +4,11 @@ import Typography from '@mui/material/Typography'; import AutocompleteField, { IAutocompleteFieldOption } from 'components/fields/AutocompleteField'; import { SYSTEM_IDENTITY_SOURCE } from 'constants/auth'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import dayjs from 'dayjs'; import { useFormikContext } from 'formik'; import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; import React from 'react'; -import { getFormattedDate, getFormattedIdentitySource } from 'utils/Utils'; +import { getFormattedIdentitySource } from 'utils/Utils'; import yup from 'utils/YupSchema'; export interface IReviewAccessRequestForm { @@ -79,7 +80,7 @@ const ReviewAccessRequestForm: React.FC = (props) Date of Request - {getFormattedDate(DATE_FORMAT.ShortDateFormatMonthFirst, props.request.create_date)} + {dayjs(props.request.create_date).format(DATE_FORMAT.ShortMediumDateTimeFormat)} @@ -109,7 +110,7 @@ const ReviewAccessRequestForm: React.FC = (props) sx={{ marginBottom: '18px' }}> - Requested System Role + System Role
diff --git a/app/src/features/admin/users/access-requests/components/ViewAccessRequestForm.tsx b/app/src/features/admin/users/access-requests/components/ViewAccessRequestForm.tsx new file mode 100644 index 0000000000..49ee184853 --- /dev/null +++ b/app/src/features/admin/users/access-requests/components/ViewAccessRequestForm.tsx @@ -0,0 +1,93 @@ +import { mdiInformationOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import { blue } from '@mui/material/colors'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; +import { SYSTEM_IDENTITY_SOURCE } from 'constants/auth'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import dayjs from 'dayjs'; +import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; +import React from 'react'; +import { getFormattedIdentitySource } from 'utils/Utils'; + +export interface IViewAccessReuqestFormProps { + request: IGetAccessRequestsListResponse; + bannerText: string; +} + +/** + * Component to view system access requests without the ability to edit the user's system role + * + * @return {*} + */ +export const ViewAccessRequestForm: React.FC = (props: IViewAccessReuqestFormProps) => { + const formattedUsername = [ + getFormattedIdentitySource(props.request.data.identitySource as SYSTEM_IDENTITY_SOURCE), + props.request.data.username + ] + .filter(Boolean) + .join('/'); + + return ( + <> + + + + + {props.bannerText} + + + + + + User Details + +
+ + + + Name + + {props.request.data.name} + + + + Username + + {formattedUsername} + + + + Email Address + + {props.request.data.email} + + + + Date of Request + + + {dayjs(props.request.create_date).format(DATE_FORMAT.ShortMediumDateTimeFormat)} + + + + + Company + + + {('company' in props.request.data && props.request.data.company) || 'Not Applicable'} + + + + + Reason for Request + + {props.request.data.reason} + + +
+
+ + ); +}; diff --git a/app/src/features/admin/users/access-requests/list/actioned/AccessRequestActionedList.tsx b/app/src/features/admin/users/access-requests/list/actioned/AccessRequestActionedList.tsx new file mode 100644 index 0000000000..c16d0bc61a --- /dev/null +++ b/app/src/features/admin/users/access-requests/list/actioned/AccessRequestActionedList.tsx @@ -0,0 +1,128 @@ +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import { GridColDef } from '@mui/x-data-grid'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { getAccessRequestStatusColour } from 'constants/colours'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import dayjs from 'dayjs'; +import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; +import { useState } from 'react'; +import { ViewAccessRequestForm } from '../../components/ViewAccessRequestForm'; + +interface IAccessRequestActionedListProps { + accessRequests: IGetAccessRequestsListResponse[]; +} + +/** + * Returns a data grid component displaying approved access requests + * + * @param props {IAccessRequestActionedListProps} + * @returns + */ +const AccessRequestActionedList = (props: IAccessRequestActionedListProps) => { + const { accessRequests } = props; + + const [showViewDialog, setShowViewDialog] = useState(false); + const [activeReview, setActiveReview] = useState(null); + + const accessRequestsColumnDefs: GridColDef[] = [ + { + field: 'display_name', + headerName: 'Display Name', + flex: 1, + disableColumnMenu: true, + valueGetter: (params) => { + return params.row.data?.displayName; + } + }, + { + field: 'username', + headerName: 'Username', + flex: 1, + disableColumnMenu: true, + valueGetter: (params) => { + return params.row.data?.username; + } + }, + { + field: 'create_date', + flex: 1, + headerName: 'Date of Request', + disableColumnMenu: true, + valueFormatter: (params) => { + return dayjs(params.value).format(DATE_FORMAT.ShortMediumDateTimeFormat); + } + }, + { + field: 'status_name', + width: 170, + headerName: 'Status', + disableColumnMenu: true, + renderCell: (params) => { + return ( + + ); + } + }, + { + field: 'actions', + headerName: '', + type: 'actions', + flex: 1, + sortable: false, + disableColumnMenu: true, + resizable: false, + align: 'right', + renderCell: (params) => ( + + ) + } + ]; + + return ( + <> + {activeReview && ( + setShowViewDialog(false)}> + Access Request + + + + + )} + + + ); +}; + +export default AccessRequestActionedList; diff --git a/app/src/features/admin/users/AccessRequestList.tsx b/app/src/features/admin/users/access-requests/list/pending/AccessRequestPendingList.tsx similarity index 59% rename from app/src/features/admin/users/AccessRequestList.tsx rename to app/src/features/admin/users/access-requests/list/pending/AccessRequestPendingList.tsx index fbd0ac607a..8144e3da8c 100644 --- a/app/src/features/admin/users/AccessRequestList.tsx +++ b/app/src/features/admin/users/access-requests/list/pending/AccessRequestPendingList.tsx @@ -1,88 +1,60 @@ -import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; -import Divider from '@mui/material/Divider'; -import Paper from '@mui/material/Paper'; -import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import { GridColDef } from '@mui/x-data-grid'; -import { AccessStatusChip } from 'components/chips/AccessStatusChip'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; import RequestDialog from 'components/dialog/RequestDialog'; +import { getAccessRequestStatusColour } from 'constants/colours'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; -import { AccessApprovalDispatchI18N, AccessDenialDispatchI18N, ReviewAccessRequestI18N } from 'constants/i18n'; -import { AdministrativeActivityStatusType } from 'constants/misc'; -import { DialogContext } from 'contexts/dialogContext'; +import { ReviewAccessRequestI18N } from 'constants/i18n'; +import { DialogContext, ISnackbarProps } from 'contexts/dialogContext'; +import dayjs from 'dayjs'; import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; import { useContext, useState } from 'react'; -import { getFormattedDate } from 'utils/Utils'; import ReviewAccessRequestForm, { IReviewAccessRequestForm, ReviewAccessRequestFormInitialValues, ReviewAccessRequestFormYupSchema -} from './ReviewAccessRequestForm'; +} from '../../components/ReviewAccessRequestForm'; -export interface IAccessRequestListProps { +interface IAccessRequestPendingListProps { accessRequests: IGetAccessRequestsListResponse[]; codes: IGetAllCodeSetsResponse; refresh: () => void; } -const pageSizeOptions = [10, 25, 50]; - /** - * Page to display a list of user access. + * Returns a data grid component displaying pending access requests * + * @param props {IAccessRequestPendingListProps} + * @returns */ -const AccessRequestList = (props: IAccessRequestListProps) => { +const AccessRequestPendingList = (props: IAccessRequestPendingListProps) => { const { accessRequests, codes, refresh } = props; const biohubApi = useBiohubApi(); + const dialogContext = useContext(DialogContext); const [showReviewDialog, setShowReviewDialog] = useState(false); const [activeReview, setActiveReview] = useState(null); - const dialogContext = useContext(DialogContext); - - const defaultErrorDialogProps = { - dialogTitle: ReviewAccessRequestI18N.reviewErrorTitle, - dialogText: ReviewAccessRequestI18N.reviewErrorText, - open: false, - onClose: () => { - dialogContext.setErrorDialog({ open: false }); - }, - onOk: () => { - dialogContext.setErrorDialog({ open: false }); - } - }; - - const dispatchApprovalErrorDialogProps = { - dialogTitle: AccessApprovalDispatchI18N.reviewErrorTitle, - dialogText: AccessApprovalDispatchI18N.reviewErrorText, - open: false, - onClose: () => { - dialogContext.setErrorDialog({ open: false }); - }, - onOk: () => { - dialogContext.setErrorDialog({ open: false }); - } - }; - - const dispatchDenialErrorDialogProps = { - dialogTitle: AccessDenialDispatchI18N.reviewErrorTitle, - dialogText: AccessDenialDispatchI18N.reviewErrorText, - open: false, - onClose: () => { - dialogContext.setErrorDialog({ open: false }); - }, - onOk: () => { - dialogContext.setErrorDialog({ open: false }); - } + const showSnackBar = (textDialogProps?: Partial) => { + dialogContext.setSnackbar({ ...textDialogProps, open: true }); }; const accessRequestsColumnDefs: GridColDef[] = [ + { + field: 'display_name', + headerName: 'Display Name', + flex: 1, + disableColumnMenu: true, + valueGetter: (params) => { + return params.row.data?.displayName; + } + }, { field: 'username', headerName: 'Username', @@ -98,7 +70,7 @@ const AccessRequestList = (props: IAccessRequestListProps) => { headerName: 'Date of Request', disableColumnMenu: true, valueFormatter: (params) => { - return getFormattedDate(DATE_FORMAT.ShortMediumDateFormat, params.value); + return dayjs(params.value).format(DATE_FORMAT.ShortMediumDateTimeFormat); } }, { @@ -107,7 +79,12 @@ const AccessRequestList = (props: IAccessRequestListProps) => { headerName: 'Status', disableColumnMenu: true, renderCell: (params) => { - return ; + return ( + + ); } }, { @@ -119,26 +96,32 @@ const AccessRequestList = (props: IAccessRequestListProps) => { disableColumnMenu: true, resizable: false, align: 'right', - renderCell: (params) => { - if (params.row.status_name !== AdministrativeActivityStatusType.PENDING) { - return <>; - } - - return ( - - ); - } + renderCell: (params) => ( + + ) } ]; + const defaultErrorDialogProps = { + dialogTitle: ReviewAccessRequestI18N.reviewErrorTitle, + dialogText: ReviewAccessRequestI18N.reviewErrorText, + open: false, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + } + }; + const handleReviewDialogApprove = async (values: IReviewAccessRequestForm) => { if (!activeReview) { return; @@ -156,6 +139,14 @@ const AccessRequestList = (props: IAccessRequestListProps) => { roleIds: (values.system_role && [values.system_role]) || [] }); + showSnackBar({ + snackbarMessage: ( + + Approved access request + + ) + }); + try { await biohubApi.admin.sendGCNotification( { @@ -166,16 +157,18 @@ const AccessRequestList = (props: IAccessRequestListProps) => { { subject: 'SIMS: Your request for access has been approved.', header: 'Your request for access to the Species Inventory Management System has been approved.', - main_body1: 'This is an automated message from the BioHub Species Inventory Management System', + main_body1: 'This is an automated message from the Species Inventory Management System', main_body2: '', footer: '' } ); } catch (error) { - dialogContext.setErrorDialog({ - ...dispatchApprovalErrorDialogProps, - open: true, - dialogErrorDetails: (error as APIError).errors + showSnackBar({ + snackbarMessage: ( + + Approved access request, but failed to send notification + + ) }); } finally { refresh(); @@ -199,6 +192,14 @@ const AccessRequestList = (props: IAccessRequestListProps) => { try { await biohubApi.admin.denyAccessRequest(activeReview.id); + showSnackBar({ + snackbarMessage: ( + + Approved access request + + ) + }); + try { await biohubApi.admin.sendGCNotification( { @@ -209,16 +210,18 @@ const AccessRequestList = (props: IAccessRequestListProps) => { { subject: 'SIMS: Your request for access has been denied.', header: 'Your request for access to the Species Inventory Management System has been denied.', - main_body1: 'This is an automated message from the BioHub Species Inventory Management System', + main_body1: 'This is an automated message from the Species Inventory Management System', main_body2: '', footer: '' } ); } catch (error) { - dialogContext.setErrorDialog({ - ...dispatchDenialErrorDialogProps, - open: true, - dialogErrorDetails: (error as APIError).errors + showSnackBar({ + snackbarMessage: ( + + Denied access request, but failed to send notification + + ) }); } finally { refresh(); @@ -246,51 +249,28 @@ const AccessRequestList = (props: IAccessRequestListProps) => { element: activeReview ? ( { - return { value: item.id, label: item.name }; - }) || [] - } + system_roles={codes?.system_roles?.map((item: any) => ({ value: item.id, label: item.name })) || []} /> ) : ( <> ) }} /> - - - - Access Requests{' '} - - ({Number(accessRequests?.length ?? 0).toLocaleString()}) - - - - - - - - + ); }; -export default AccessRequestList; +export default AccessRequestPendingList; diff --git a/app/src/features/admin/users/access-requests/list/rejected/AccessRequestRejectedList.tsx b/app/src/features/admin/users/access-requests/list/rejected/AccessRequestRejectedList.tsx new file mode 100644 index 0000000000..b22d0ddab8 --- /dev/null +++ b/app/src/features/admin/users/access-requests/list/rejected/AccessRequestRejectedList.tsx @@ -0,0 +1,128 @@ +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import { GridColDef } from '@mui/x-data-grid'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { getAccessRequestStatusColour } from 'constants/colours'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import dayjs from 'dayjs'; +import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; +import { useState } from 'react'; +import { ViewAccessRequestForm } from '../../components/ViewAccessRequestForm'; + +interface IAccessRequestRejectedListProps { + accessRequests: IGetAccessRequestsListResponse[]; +} + +/** + * Returns a data grid component displaying denied access requests + * + * @param props {IAccessRequestRejectedListProps} + * @returns + */ +const AccessRequestRejectedList = (props: IAccessRequestRejectedListProps) => { + const { accessRequests } = props; + + const [showReviewDialog, setShowReviewDialog] = useState(false); + const [activeReview, setActiveReview] = useState(null); + + const accessRequestsColumnDefs: GridColDef[] = [ + { + field: 'display_name', + headerName: 'Display Name', + flex: 1, + disableColumnMenu: true, + valueGetter: (params) => { + return params.row.data?.displayName; + } + }, + { + field: 'username', + headerName: 'Username', + flex: 1, + disableColumnMenu: true, + valueGetter: (params) => { + return params.row.data?.username; + } + }, + { + field: 'create_date', + flex: 1, + headerName: 'Date of Request', + disableColumnMenu: true, + valueFormatter: (params) => { + return dayjs(params.value).format(DATE_FORMAT.ShortMediumDateTimeFormat); + } + }, + { + field: 'status_name', + width: 170, + headerName: 'Status', + disableColumnMenu: true, + renderCell: (params) => { + return ( + + ); + } + }, + { + field: 'actions', + headerName: '', + type: 'actions', + flex: 1, + sortable: false, + disableColumnMenu: true, + resizable: false, + align: 'right', + renderCell: (params) => ( + + ) + } + ]; + + return ( + <> + {activeReview && ( + setShowReviewDialog(false)}> + Access Request + + + + + )} + + + ); +}; + +export default AccessRequestRejectedList; diff --git a/app/src/features/admin/users/ActiveUsersList.test.tsx b/app/src/features/admin/users/active/ActiveUsersList.test.tsx similarity index 53% rename from app/src/features/admin/users/ActiveUsersList.test.tsx rename to app/src/features/admin/users/active/ActiveUsersList.test.tsx index ece9f2f459..272db2df25 100644 --- a/app/src/features/admin/users/ActiveUsersList.test.tsx +++ b/app/src/features/admin/users/active/ActiveUsersList.test.tsx @@ -1,6 +1,8 @@ import { AuthStateContext } from 'contexts/authStateContext'; +import { CodesContext, ICodesContext } from 'contexts/codesContext'; import { createMemoryHistory } from 'history'; import { useBiohubApi } from 'hooks/useBioHubApi'; +import { DataLoader } from 'hooks/useDataLoader'; import { Router } from 'react-router'; import { getMockAuthState, SystemAdminAuthState } from 'test-helpers/auth-helpers'; import { codes } from 'test-helpers/code-helpers'; @@ -9,7 +11,7 @@ import ActiveUsersList, { IActiveUsersListProps } from './ActiveUsersList'; const history = createMemoryHistory(); -jest.mock('../../../hooks/useBioHubApi'); +jest.mock('../../../../hooks/useBioHubApi'); const mockBiohubApi = useBiohubApi as jest.Mock; const mockUseApi = { @@ -19,17 +21,29 @@ const mockUseApi = { }, admin: { addSystemUser: jest.fn() + }, + codes: { + getAllCodeSets: jest.fn() } }; +const mockCodesContext: ICodesContext = { + codesDataLoader: { + data: codes, + load: () => {} + } as DataLoader +}; + const renderContainer = (props: IActiveUsersListProps) => { const authState = getMockAuthState({ base: SystemAdminAuthState }); return render( - - - + + + + + ); }; @@ -55,57 +69,6 @@ describe('ActiveUsersList', () => { }); }); - it('shows a table row for an active user with all fields having values', async () => { - const { getByText } = renderContainer({ - activeUsers: [ - { - system_user_id: 1, - user_identifier: 'username', - user_guid: 'user-guid', - record_end_date: '2020-10-10', - role_names: ['role 1'], - identity_source: 'idir', - role_ids: [1], - email: '', - display_name: '', - agency: '' - } - ], - codes: codes, - refresh: () => {} - }); - - await waitFor(() => { - expect(getByText('username')).toBeVisible(); - expect(getByText('role 1')).toBeVisible(); - }); - }); - - it('shows a table row for an active user with fields not having values', async () => { - const { getByTestId } = renderContainer({ - activeUsers: [ - { - system_user_id: 1, - user_identifier: 'username', - user_guid: 'user-guid', - record_end_date: '2020-10-10', - role_names: [], - identity_source: 'idir', - role_ids: [], - email: '', - display_name: '', - agency: '' - } - ], - codes: codes, - refresh: () => {} - }); - - await waitFor(() => { - expect(getByTestId('custom-menu-button-NotApplicable')).toBeInTheDocument(); - }); - }); - it('renders the add new users button correctly', async () => { const { getByTestId } = renderContainer({ activeUsers: [], diff --git a/app/src/features/admin/users/ActiveUsersList.tsx b/app/src/features/admin/users/active/ActiveUsersList.tsx similarity index 82% rename from app/src/features/admin/users/ActiveUsersList.tsx rename to app/src/features/admin/users/active/ActiveUsersList.tsx index d6e48eabd0..f6380948e2 100644 --- a/app/src/features/admin/users/ActiveUsersList.tsx +++ b/app/src/features/admin/users/active/ActiveUsersList.tsx @@ -2,6 +2,7 @@ import { mdiAccountDetailsOutline, mdiChevronDown, mdiDotsVertical, mdiPlus, mdi import Icon from '@mdi/react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; +import grey from '@mui/material/colors/grey'; import Divider from '@mui/material/Divider'; import Link from '@mui/material/Link'; import Paper from '@mui/material/Paper'; @@ -16,16 +17,18 @@ import { DialogContext, ISnackbarProps } from 'contexts/dialogContext'; import { APIError } from 'hooks/api/useAxios'; import { useAuthStateContext } from 'hooks/useAuthStateContext'; import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useCodesContext } from 'hooks/useContext'; import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; import { ISystemUser } from 'interfaces/useUserApi.interface'; -import { useContext, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { useHistory } from 'react-router'; import { Link as RouterLink } from 'react-router-dom'; +import { getCodesName } from 'utils/Utils'; import AddSystemUsersForm, { AddSystemUsersFormInitialValues, AddSystemUsersFormYupSchema, IAddSystemUsersForm -} from './AddSystemUsersForm'; +} from '../add/AddSystemUsersForm'; export interface IActiveUsersListProps { activeUsers: ISystemUser[]; @@ -50,10 +53,32 @@ const ActiveUsersList = (props: IActiveUsersListProps) => { const [openAddUserDialog, setOpenAddUserDialog] = useState(false); + const codesContext = useCodesContext(); + + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext.codesDataLoader]); + const activeUsersColumnDefs: GridColDef[] = [ { - field: 'user_identifier', - headerName: 'Username', + field: 'system_user_id', + headerName: 'ID', + width: 70, + minWidth: 70, + renderHeader: () => ( + + ID + + ), + renderCell: (params) => ( + + {params.row.system_user_id} + + ) + }, + { + field: 'display_name', + headerName: 'Display Name', flex: 1, disableColumnMenu: true, renderCell: (params) => { @@ -63,11 +88,29 @@ const ActiveUsersList = (props: IActiveUsersListProps) => { underline="always" to={`/admin/users/${params.row.system_user_id}`} component={RouterLink}> - {params.row.user_identifier || 'No identifier'} + {params.row.display_name || 'No identifier'} ); } }, + { + field: 'identity_source', + headerName: 'Account Type', + flex: 1, + disableColumnMenu: true, + valueGetter: (params) => { + return params.row.identity_source; + } + }, + { + field: 'user_identifier', + headerName: 'Username', + flex: 1, + disableColumnMenu: true, + valueGetter: (params) => { + return params.row.user_identifier; + } + }, { field: 'role_names', flex: 1, @@ -279,16 +322,16 @@ const ActiveUsersList = (props: IActiveUsersListProps) => { const handleAddSystemUsersSave = async (values: IAddSystemUsersForm) => { setOpenAddUserDialog(false); + const systemUser = values.systemUser; + try { - for (const systemUser of values.systemUsers) { - await biohubApi.admin.addSystemUser( - systemUser.userIdentifier, - systemUser.identitySource, - systemUser.displayName, - systemUser.email, - systemUser.systemRole - ); - } + await biohubApi.admin.addSystemUser( + systemUser.userIdentifier, + systemUser.identitySource, + systemUser.displayName, + systemUser.email, + systemUser.systemRole + ); // Refresh users list refresh(); @@ -297,7 +340,7 @@ const ActiveUsersList = (props: IActiveUsersListProps) => { open: true, snackbarMessage: ( - {values.systemUsers.length} system {values.systemUsers.length > 1 ? 'users' : 'user'} added. + Successfully added {systemUser.displayName} ) }); @@ -307,8 +350,12 @@ const ActiveUsersList = (props: IActiveUsersListProps) => { if (apiError.status === 409) { dialogContext.setErrorDialog({ open: true, - dialogTitle: 'Failed to create users', - dialogText: 'One of the users you added already exists.', + dialogTitle: 'User already exists', + dialogText: `${systemUser.displayName} already exists as a ${getCodesName( + codesContext.codesDataLoader.data, + 'system_roles', + systemUser.systemRole + )}`, onClose: () => { dialogContext.setErrorDialog({ open: false }); }, @@ -339,7 +386,7 @@ const ActiveUsersList = (props: IActiveUsersListProps) => { - Active Users{' '} + Active Users  { { - return { value: item.id, label: item.name }; - }) || [] - } - /> + <> + + This form creates a new user that will be linked to an IDIR/BCeID when an account with a matching + username, email, and account type logs in. + + { + return { value: item.id, label: item.name }; + }) || [] + } + /> + ), initialValues: AddSystemUsersFormInitialValues, - validationSchema: AddSystemUsersFormYupSchema + validationSchema: AddSystemUsersFormYupSchema, + validateOnBlur: false }} onCancel={() => setOpenAddUserDialog(false)} onSave={(values) => { diff --git a/app/src/features/admin/users/add/AddSystemUsersForm.tsx b/app/src/features/admin/users/add/AddSystemUsersForm.tsx new file mode 100644 index 0000000000..2f13b1d813 --- /dev/null +++ b/app/src/features/admin/users/add/AddSystemUsersForm.tsx @@ -0,0 +1,136 @@ +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import AutocompleteField, { IAutocompleteFieldOption } from 'components/fields/AutocompleteField'; +import CustomTextField from 'components/fields/CustomTextField'; +import { SYSTEM_IDENTITY_SOURCE } from 'constants/auth'; +import { useFormikContext } from 'formik'; +import React from 'react'; +import yup from 'utils/YupSchema'; + +export interface IAddSystemUsersFormArrayItem { + userIdentifier: string; + displayName: string; + email: string; + identitySource: string; + systemRole: number; +} + +export interface IAddSystemUsersForm { + systemUser: IAddSystemUsersFormArrayItem; +} + +export const AddSystemUsersFormArrayItemInitialValues: IAddSystemUsersFormArrayItem = { + userIdentifier: '', + displayName: '', + email: '', + identitySource: '', + systemRole: '' as unknown as number +}; + +export const AddSystemUsersFormInitialValues: IAddSystemUsersForm = { + systemUser: AddSystemUsersFormArrayItemInitialValues +}; + +export const AddSystemUsersFormYupSchema = yup.object().shape({ + systemUser: yup.object().shape({ + userIdentifier: yup.string().required('Username is required'), + displayName: yup.string().required('Display Name is required'), + email: yup.string().email('Must be a valid email').required('Email is required'), + identitySource: yup.string().required('Account Type is required'), + systemRole: yup.number().required('System Role is required') + }) +}); + +export interface AddSystemUsersFormProps { + systemRoles: IAutocompleteFieldOption[]; +} + +/** + * Returns form component for manually adding system users before access is requested + * + * @param props + * @returns + */ +const AddSystemUsersForm: React.FC = (props) => { + const { values, handleSubmit, getFieldMeta } = useFormikContext(); + + const userIdentifierMeta = getFieldMeta('systemUser.userIdentifier'); + const displayNameMeta = getFieldMeta('systemUser.displayName'); + const emailMeta = getFieldMeta('systemUser.email'); + + const { systemRoles } = props; + const identitySources: IAutocompleteFieldOption[] = [ + { value: SYSTEM_IDENTITY_SOURCE.IDIR as string, label: SYSTEM_IDENTITY_SOURCE.IDIR }, + { value: SYSTEM_IDENTITY_SOURCE.BCEID_BASIC as string, label: SYSTEM_IDENTITY_SOURCE.BCEID_BASIC }, + { value: SYSTEM_IDENTITY_SOURCE.BCEID_BUSINESS as string, label: SYSTEM_IDENTITY_SOURCE.BCEID_BUSINESS }, + { value: SYSTEM_IDENTITY_SOURCE.UNVERIFIED as string, label: SYSTEM_IDENTITY_SOURCE.UNVERIFIED } + ]; + + return ( + + + + + + + + + + + + + + + + + + + + ); +}; + +export default AddSystemUsersForm; diff --git a/app/src/features/admin/users/UsersDetailHeader.test.tsx b/app/src/features/admin/users/projects/UsersDetailHeader.test.tsx similarity index 97% rename from app/src/features/admin/users/UsersDetailHeader.test.tsx rename to app/src/features/admin/users/projects/UsersDetailHeader.test.tsx index 1e24966b8b..cda6451ea1 100644 --- a/app/src/features/admin/users/UsersDetailHeader.test.tsx +++ b/app/src/features/admin/users/projects/UsersDetailHeader.test.tsx @@ -1,14 +1,14 @@ import { DialogContextProvider } from 'contexts/dialogContext'; import { createMemoryHistory } from 'history'; +import { useBiohubApi } from 'hooks/useBioHubApi'; import { ISystemUser } from 'interfaces/useUserApi.interface'; import { Router } from 'react-router'; import { cleanup, fireEvent, render, waitFor } from 'test-helpers/test-utils'; -import { useBiohubApi } from '../../../hooks/useBioHubApi'; import UsersDetailHeader from './UsersDetailHeader'; const history = createMemoryHistory(); -jest.mock('../../../hooks/useBioHubApi'); +jest.mock('../../../../hooks/useBioHubApi'); const mockBiohubApi = useBiohubApi as jest.Mock; diff --git a/app/src/features/admin/users/UsersDetailHeader.tsx b/app/src/features/admin/users/projects/UsersDetailHeader.tsx similarity index 88% rename from app/src/features/admin/users/UsersDetailHeader.tsx rename to app/src/features/admin/users/projects/UsersDetailHeader.tsx index 8e096f2741..48d73acebd 100644 --- a/app/src/features/admin/users/UsersDetailHeader.tsx +++ b/app/src/features/admin/users/projects/UsersDetailHeader.tsx @@ -4,17 +4,17 @@ import Breadcrumbs from '@mui/material/Breadcrumbs'; import Button from '@mui/material/Button'; import Link from '@mui/material/Link'; import Typography from '@mui/material/Typography'; +import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; +import { IYesNoDialogProps } from 'components/dialog/YesNoDialog'; import PageHeader from 'components/layout/PageHeader'; +import { SystemUserI18N } from 'constants/i18n'; +import { DialogContext } from 'contexts/dialogContext'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { ISystemUser } from 'interfaces/useUserApi.interface'; import React, { useCallback, useContext, useMemo } from 'react'; import { useHistory } from 'react-router'; import { Link as RouterLink } from 'react-router-dom'; -import { IErrorDialogProps } from '../../../components/dialog/ErrorDialog'; -import { IYesNoDialogProps } from '../../../components/dialog/YesNoDialog'; -import { SystemUserI18N } from '../../../constants/i18n'; -import { DialogContext } from '../../../contexts/dialogContext'; -import { APIError } from '../../../hooks/api/useAxios'; -import { useBiohubApi } from '../../../hooks/useBioHubApi'; -import { ISystemUser } from '../../../interfaces/useUserApi.interface'; export interface IUsersHeaderProps { userDetails: ISystemUser; @@ -97,11 +97,11 @@ const UsersDetailHeader: React.FC = (props) => { Manage Users - {userDetails.user_identifier} + {userDetails.display_name} } - title={userDetails.user_identifier} + title={userDetails.display_name} subTitleJSX={ {userDetails.role_names[0]} diff --git a/app/src/features/admin/users/UsersDetailPage.test.tsx b/app/src/features/admin/users/projects/UsersDetailPage.test.tsx similarity index 90% rename from app/src/features/admin/users/UsersDetailPage.test.tsx rename to app/src/features/admin/users/projects/UsersDetailPage.test.tsx index 1de69e46d3..e12cf2326b 100644 --- a/app/src/features/admin/users/UsersDetailPage.test.tsx +++ b/app/src/features/admin/users/projects/UsersDetailPage.test.tsx @@ -1,15 +1,15 @@ import { createMemoryHistory } from 'history'; +import { useBiohubApi } from 'hooks/useBioHubApi'; import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; +import { IGetUserProjectsListResponse } from 'interfaces/useProjectApi.interface'; +import { ISystemUser } from 'interfaces/useUserApi.interface'; import { Router } from 'react-router'; import { cleanup, render, waitFor } from 'test-helpers/test-utils'; -import { useBiohubApi } from '../../../hooks/useBioHubApi'; -import { IGetUserProjectsListResponse } from '../../../interfaces/useProjectApi.interface'; -import { ISystemUser } from '../../../interfaces/useUserApi.interface'; import UsersDetailPage from './UsersDetailPage'; const history = createMemoryHistory(); -jest.mock('../../../hooks/useBioHubApi'); +jest.mock('../../../../hooks/useBioHubApi'); const mockBiohubApi = useBiohubApi as jest.Mock; diff --git a/app/src/features/admin/users/UsersDetailPage.tsx b/app/src/features/admin/users/projects/UsersDetailPage.tsx similarity index 90% rename from app/src/features/admin/users/UsersDetailPage.tsx rename to app/src/features/admin/users/projects/UsersDetailPage.tsx index 5a0dd9b5e6..a9785a8da2 100644 --- a/app/src/features/admin/users/UsersDetailPage.tsx +++ b/app/src/features/admin/users/projects/UsersDetailPage.tsx @@ -1,9 +1,9 @@ import CircularProgress from '@mui/material/CircularProgress'; import Container from '@mui/material/Container'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { ISystemUser } from 'interfaces/useUserApi.interface'; import React, { useEffect, useState } from 'react'; import { useParams } from 'react-router'; -import { useBiohubApi } from '../../../hooks/useBioHubApi'; -import { ISystemUser } from '../../../interfaces/useUserApi.interface'; import UsersDetailHeader from './UsersDetailHeader'; import UsersDetailProjects from './UsersDetailProjects'; diff --git a/app/src/features/admin/users/UsersDetailProjects.test.tsx b/app/src/features/admin/users/projects/UsersDetailProjects.test.tsx similarity index 98% rename from app/src/features/admin/users/UsersDetailProjects.test.tsx rename to app/src/features/admin/users/projects/UsersDetailProjects.test.tsx index 4759fcb244..ebac9cf029 100644 --- a/app/src/features/admin/users/UsersDetailProjects.test.tsx +++ b/app/src/features/admin/users/projects/UsersDetailProjects.test.tsx @@ -1,16 +1,16 @@ import { DialogContextProvider } from 'contexts/dialogContext'; import { createMemoryHistory } from 'history'; +import { useBiohubApi } from 'hooks/useBioHubApi'; import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; +import { IGetUserProjectsListResponse } from 'interfaces/useProjectApi.interface'; import { ISystemUser } from 'interfaces/useUserApi.interface'; import { Router } from 'react-router'; import { cleanup, fireEvent, render, waitFor } from 'test-helpers/test-utils'; -import { useBiohubApi } from '../../../hooks/useBioHubApi'; -import { IGetUserProjectsListResponse } from '../../../interfaces/useProjectApi.interface'; import UsersDetailProjects from './UsersDetailProjects'; const history = createMemoryHistory(); -jest.mock('../../../hooks/useBioHubApi'); +jest.mock('../../../../hooks/useBioHubApi'); const mockBiohubApi = useBiohubApi as jest.Mock; diff --git a/app/src/features/admin/users/UsersDetailProjects.tsx b/app/src/features/admin/users/projects/UsersDetailProjects.tsx similarity index 93% rename from app/src/features/admin/users/UsersDetailProjects.tsx rename to app/src/features/admin/users/projects/UsersDetailProjects.tsx index 7000e425e0..47e7827515 100644 --- a/app/src/features/admin/users/UsersDetailProjects.tsx +++ b/app/src/features/admin/users/projects/UsersDetailProjects.tsx @@ -13,19 +13,19 @@ import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; +import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; +import { IYesNoDialogProps } from 'components/dialog/YesNoDialog'; +import { CustomMenuButton } from 'components/toolbar/ActionToolbars'; +import { ProjectParticipantsI18N, SystemUserI18N } from 'constants/i18n'; +import { DialogContext } from 'contexts/dialogContext'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { CodeSet, IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; +import { IGetUserProjectsListResponse } from 'interfaces/useProjectApi.interface'; +import { ISystemUser } from 'interfaces/useUserApi.interface'; import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router'; import { Link as RouterLink } from 'react-router-dom'; -import { IErrorDialogProps } from '../../../components/dialog/ErrorDialog'; -import { IYesNoDialogProps } from '../../../components/dialog/YesNoDialog'; -import { CustomMenuButton } from '../../../components/toolbar/ActionToolbars'; -import { ProjectParticipantsI18N, SystemUserI18N } from '../../../constants/i18n'; -import { DialogContext } from '../../../contexts/dialogContext'; -import { APIError } from '../../../hooks/api/useAxios'; -import { useBiohubApi } from '../../../hooks/useBioHubApi'; -import { CodeSet, IGetAllCodeSetsResponse } from '../../../interfaces/useCodesApi.interface'; -import { IGetUserProjectsListResponse } from '../../../interfaces/useProjectApi.interface'; -import { ISystemUser } from '../../../interfaces/useUserApi.interface'; export interface IProjectDetailsProps { userDetails: ISystemUser; diff --git a/app/src/features/funding-sources/components/FundingSourceForm.tsx b/app/src/features/funding-sources/components/FundingSourceForm.tsx index 8bffd0db04..569a8f3cc6 100644 --- a/app/src/features/funding-sources/components/FundingSourceForm.tsx +++ b/app/src/features/funding-sources/components/FundingSourceForm.tsx @@ -2,7 +2,6 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import CustomTextField from 'components/fields/CustomTextField'; import StartEndDateFields from 'components/fields/StartEndDateFields'; -import { useFormikContext } from 'formik'; import React from 'react'; export interface IFundingSourceData { @@ -15,8 +14,6 @@ export interface IFundingSourceData { } const FundingSourceForm: React.FC = () => { - const formikProps = useFormikContext(); - return (
@@ -41,13 +38,7 @@ const FundingSourceForm: React.FC = () => { Effective Dates - + diff --git a/app/src/features/funding-sources/details/FundingSourceSurveyReferences.tsx b/app/src/features/funding-sources/details/FundingSourceSurveyReferences.tsx index d6bd0126c3..a1b544e47a 100644 --- a/app/src/features/funding-sources/details/FundingSourceSurveyReferences.tsx +++ b/app/src/features/funding-sources/details/FundingSourceSurveyReferences.tsx @@ -8,6 +8,7 @@ import Paper from '@mui/material/Paper'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import { DataGrid, GridColDef, GridOverlay } from '@mui/x-data-grid'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; import { IGetFundingSourceResponse } from 'interfaces/useFundingSourceApi.interface'; import { debounce } from 'lodash'; import { useCallback, useMemo, useState } from 'react'; @@ -149,22 +150,24 @@ const FundingSourceSurveyReferences = (props: IFundingSourceSurveyReferencesProp fullWidth={true} /> - {fundingSourceSurveyReferences.length === 0 ? ( - - - - No surveys found - - - - ) : ( + + + + No surveys found + + + + }> - )} + ); diff --git a/app/src/features/projects/ProjectsRouter.tsx b/app/src/features/projects/ProjectsRouter.tsx index fd59a5f8a4..671a54c9e2 100644 --- a/app/src/features/projects/ProjectsRouter.tsx +++ b/app/src/features/projects/ProjectsRouter.tsx @@ -5,7 +5,6 @@ import { ObservationsContextProvider } from 'contexts/observationsContext'; import { ProjectAuthStateContextProvider } from 'contexts/projectAuthStateContext'; import { ProjectContextProvider } from 'contexts/projectContext'; import { SurveyContextProvider } from 'contexts/surveyContext'; -import { TelemetryDataContextProvider } from 'contexts/telemetryDataContext'; import ProjectPage from 'features/projects/view/ProjectPage'; import CreateSurveyPage from 'features/surveys/CreateSurveyPage'; import SurveyRouter from 'features/surveys/SurveyRouter'; @@ -95,9 +94,7 @@ const ProjectsRouter: React.FC = () => { validSystemRoles={[SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR]}> - - - + diff --git a/app/src/features/projects/components/ProjectUserForm.tsx b/app/src/features/projects/components/ProjectUserForm.tsx index 6baa68ebb4..08474c0236 100644 --- a/app/src/features/projects/components/ProjectUserForm.tsx +++ b/app/src/features/projects/components/ProjectUserForm.tsx @@ -218,8 +218,8 @@ const ProjectUserForm = (props: IProjectUserFormProps) => { borderTop: '1px solid' + grey[300] } }} - key={renderOption.system_user_id} - {...renderProps}> + {...renderProps} + key={renderOption.system_user_id}> { const mockProjectContext: IProjectContext = { artifactDataLoader: { data: null, - load: jest.fn() + load: jest.fn(), + isLoading: false, + isReady: true } as unknown as DataLoader, projectId: 1, projectDataLoader: { @@ -119,7 +121,7 @@ describe('ProjectAttachments', () => { hasLoadedParticipantInfo: true }; - const { getByText } = render( + const { getByTestId } = render( @@ -133,7 +135,7 @@ describe('ProjectAttachments', () => { ); await waitFor(() => { - expect(getByText('No shared files found')).toBeInTheDocument(); + expect(getByTestId('project-attachments-list-no-data-overlay')).toBeInTheDocument(); }); }); @@ -155,7 +157,9 @@ describe('ProjectAttachments', () => { projectId: 1, projectDataLoader: { data: { projectData: { project: { project_name: 'name' } } }, - load: jest.fn() + load: jest.fn(), + isLoading: false, + isReady: true } as unknown as DataLoader } as unknown as IProjectContext; @@ -361,12 +365,16 @@ describe('ProjectAttachments', () => { } ] }, - load: jest.fn() + load: jest.fn(), + isLoading: false, + isReady: true } as unknown as DataLoader, projectId: 1, projectDataLoader: { data: { projectData: { project: { project_name: 'name' } } }, - load: jest.fn() + load: jest.fn(), + isLoading: false, + isReady: true } as unknown as DataLoader } as unknown as IProjectContext; diff --git a/app/src/features/projects/view/ProjectAttachmentsList.tsx b/app/src/features/projects/view/ProjectAttachmentsList.tsx index f2fdbeb13f..f272a12b99 100644 --- a/app/src/features/projects/view/ProjectAttachmentsList.tsx +++ b/app/src/features/projects/view/ProjectAttachmentsList.tsx @@ -1,6 +1,10 @@ +import { mdiArrowTopRight } from '@mdi/js'; import Typography from '@mui/material/Typography'; import AttachmentsList from 'components/attachments/list/AttachmentsList'; import ProjectReportAttachmentDialog from 'components/dialog/attachments/project/ProjectReportAttachmentDialog'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; import { AttachmentType } from 'constants/attachments'; import { AttachmentsI18N } from 'constants/i18n'; import { DialogContext, ISnackbarProps } from 'contexts/dialogContext'; @@ -120,13 +124,29 @@ const ProjectAttachmentsList = () => { open={!!currentAttachment && currentAttachment?.fileType === AttachmentType.REPORT} onClose={handleViewDetailsClose} /> - - attachments={attachmentsList} - handleDownload={handleDownload} - handleDelete={handleDelete} - handleViewDetails={handleViewDetailsOpen} - emptyStateText="No shared files found" - /> + } + isLoadingFallbackDelay={100} + hasNoData={!attachmentsList.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + attachments={attachmentsList} + handleDownload={handleDownload} + handleDelete={handleDelete} + handleViewDetails={handleViewDetailsOpen} + emptyStateText="No shared files found" + /> + ); }; diff --git a/app/src/features/projects/view/components/TeamMember.tsx b/app/src/features/projects/view/components/TeamMember.tsx index 08b47ebba7..381f9e865d 100644 --- a/app/src/features/projects/view/components/TeamMember.tsx +++ b/app/src/features/projects/view/components/TeamMember.tsx @@ -8,6 +8,7 @@ import { PROJECT_ROLE_ICONS } from 'constants/roles'; import { ProjectContext } from 'contexts/projectContext'; import { useContext, useMemo } from 'react'; import { getRandomHexColor } from 'utils/Utils'; +import { TeamMemberAvatar } from './TeamMemberAvatar'; interface IProjectParticipantsRoles { display_name: string; @@ -59,32 +60,20 @@ const TeamMembers = () => { return ( {/* Avatar Box */} - - {member.initials} + + - {/* Member Display Name and Roles */} + {/* Member Display Name */} {member.display_name} - - {/* Roles with Icons */} - {member.roles.map((role) => ( - - - - ))} + {/* Member Roles with Icons */} + {member.roles.map((role) => ( + + + + ))} ); })} diff --git a/app/src/features/projects/view/components/TeamMemberAvatar.tsx b/app/src/features/projects/view/components/TeamMemberAvatar.tsx new file mode 100644 index 0000000000..4b07c16059 --- /dev/null +++ b/app/src/features/projects/view/components/TeamMemberAvatar.tsx @@ -0,0 +1,33 @@ +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +interface ITeamMemberAvatarProps { + color: string; + label: string; + title?: string; +} + +/** + * Returns a circular icon representing a user, typically displaying their initials as the label + * @param props + * @returns + */ +export const TeamMemberAvatar = (props: ITeamMemberAvatarProps) => { + const { color, label, title } = props; + return ( + + {label} + + ); +}; diff --git a/app/src/features/standards/SpeciesStandardsPage.tsx b/app/src/features/standards/SpeciesStandardsPage.tsx deleted file mode 100644 index 881d08a1aa..0000000000 --- a/app/src/features/standards/SpeciesStandardsPage.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Box, Container, Paper, Toolbar, Typography } from '@mui/material'; -import PageHeader from 'components/layout/PageHeader'; -import SpeciesAutocompleteField from 'components/species/components/SpeciesAutocompleteField'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import useDataLoader from 'hooks/useDataLoader'; -import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; -import SpeciesStandardsResults from './view/SpeciesStandardsResults'; - -/** - * Page to display species standards, which describes what data can be entered and in what structure - * - * @return {*} - */ -const SpeciesStandardsPage = () => { - const biohubApi = useBiohubApi(); - const standardsDataLoader = useDataLoader((species: IPartialTaxonomy) => - biohubApi.standards.getSpeciesStandards(species.tsn) - ); - - return ( - <> - - - - - - Discover data standards for species - - - - { - if (value) { - standardsDataLoader.refresh(value); - } - }} - /> - - - - - - - - ); -}; - -export default SpeciesStandardsPage; diff --git a/app/src/features/standards/StandardsPage.tsx b/app/src/features/standards/StandardsPage.tsx new file mode 100644 index 0000000000..038479645e --- /dev/null +++ b/app/src/features/standards/StandardsPage.tsx @@ -0,0 +1,60 @@ +import { mdiLeaf, mdiPaw, mdiToolbox } from '@mdi/js'; +import Box from '@mui/material/Box'; +import Container from '@mui/material/Container'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import PageHeader from 'components/layout/PageHeader'; +import { useState } from 'react'; +import { StandardsToolbar } from './components/StandardsToolbar'; +import { EnvironmentStandards } from './view/environment/EnvironmentStandards'; +import { MethodStandards } from './view/methods/MethodStandards'; +import { SpeciesStandards } from './view/species/SpeciesStandards'; + +export enum StandardsPageView { + SPECIES = 'SPECIES', + METHODS = 'METHODS', + ENVIRONMENT = 'ENVIRONMENT' +} + +export interface IStandardsPageView { + label: string; + value: StandardsPageView; + icon: string; +} + +const StandardsPage = () => { + const [currentView, setCurrentView] = useState(StandardsPageView.SPECIES); + + const views: IStandardsPageView[] = [ + { label: 'Species', value: StandardsPageView.SPECIES, icon: mdiPaw }, + { label: 'Sampling Methods', value: StandardsPageView.METHODS, icon: mdiToolbox }, + { label: 'Environment variables', value: StandardsPageView.ENVIRONMENT, icon: mdiLeaf } + ]; + + return ( + <> + + + + {/* TOOLBAR FOR SWITCHING VIEWS */} + + + + + + {/* SPECIES STANDARDS */} + {currentView === StandardsPageView.SPECIES && } + + {/* METHOD STANDARDS */} + {currentView === StandardsPageView.METHODS && } + + {/* ENVIRONMENT STANDARDS */} + {currentView === StandardsPageView.ENVIRONMENT && } + + + + + ); +}; + +export default StandardsPage; diff --git a/app/src/features/standards/components/StandardsToolbar.tsx b/app/src/features/standards/components/StandardsToolbar.tsx new file mode 100644 index 0000000000..44ff76623c --- /dev/null +++ b/app/src/features/standards/components/StandardsToolbar.tsx @@ -0,0 +1,64 @@ +import Icon from '@mdi/react'; +import Button from '@mui/material/Button'; +import ToggleButton from '@mui/material/ToggleButton/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import Typography from '@mui/material/Typography'; +import React, { SetStateAction } from 'react'; +import { IStandardsPageView, StandardsPageView } from '../StandardsPage'; + +interface IStandardsToolbar { + views: IStandardsPageView[]; + currentView: StandardsPageView; + setCurrentView: React.Dispatch>; +} + +/** + * Toolbar for setting the standards page view + * + * @param props + * @returns + */ +export const StandardsToolbar = (props: IStandardsToolbar) => { + const { views, currentView, setCurrentView } = props; + + return ( + <> + Data types + , view: StandardsPageView | null) => { + if (view) { + setCurrentView(view); + } + }} + exclusive + sx={{ + display: 'flex', + gap: 1, + '& Button': { + py: 1.25, + px: 2.5, + border: 'none', + borderRadius: '4px !important', + fontSize: '0.875rem', + fontWeight: 700, + letterSpacing: '0.02rem', + textAlign: 'left', + justifyContent: 'flex-start' + } + }}> + {views.map((view) => ( + } + key={view.value} + value={view.value} + color="primary"> + {view.label} + + ))} + + + ); +}; diff --git a/app/src/features/standards/view/SpeciesStandardsResults.tsx b/app/src/features/standards/view/SpeciesStandardsResults.tsx deleted file mode 100644 index 520adc80e7..0000000000 --- a/app/src/features/standards/view/SpeciesStandardsResults.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { mdiRuler, mdiTag } from '@mdi/js'; -import { Box, CircularProgress, Divider, Stack, Typography } from '@mui/material'; -import { IGetSpeciesStandardsResponse } from 'interfaces/useStandardsApi.interface'; -import { useState } from 'react'; -import MarkingBodyLocationStandardCard from './components/MarkingBodyLocationStandardCard'; -import MeasurementStandardCard from './components/MeasurementStandardCard'; -import SpeciesStandardsToolbar, { SpeciesStandardsViewEnum } from './components/SpeciesStandardsToolbar'; - -interface ISpeciesStandardsResultsProps { - data?: IGetSpeciesStandardsResponse; - isLoading: boolean; -} - -/** - * Component to display species standards results - * - * @return {*} - */ -const SpeciesStandardsResults = (props: ISpeciesStandardsResultsProps) => { - const [activeView, setActiveView] = useState(SpeciesStandardsViewEnum.MEASUREMENTS); - - if (props.isLoading) { - return ( - - - - ); - } - - if (!props.data) { - return <>; - } - - return ( - <> - - - Showing results for{' '} - {props.data.scientificName.split(' ').length >= 2 ? ( - {props.data.scientificName} - ) : ( - props.data.scientificName - )} - - - - - - - - {activeView === 'MEASUREMENTS' && ( - - {props.data.measurements.qualitative.map((measurement) => ( - - ))} - {props.data.measurements.quantitative.map((measurement) => ( - - ))} - - )} - {activeView === 'MARKING BODY LOCATIONS' && ( - - {props.data.markingBodyLocations.map((location) => ( - - ))} - - )} - - ); -}; - -export default SpeciesStandardsResults; diff --git a/app/src/features/standards/view/components/AccordionStandardCard.tsx b/app/src/features/standards/view/components/AccordionStandardCard.tsx new file mode 100644 index 0000000000..1779b4841a --- /dev/null +++ b/app/src/features/standards/view/components/AccordionStandardCard.tsx @@ -0,0 +1,71 @@ +import { mdiChevronDown, mdiChevronUp } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import { Collapse } from '@mui/material'; +import Box, { BoxProps } from '@mui/material/Box'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; +import { useState } from 'react'; + +interface IAccordionStandardCardProps extends BoxProps { + label: string; + subtitle?: string | null; + ornament?: JSX.Element; + children?: JSX.Element; + colour: string; + disableCollapse?: boolean; +} + +/** + * Returns a collapsible paper component for displaying lookup values + * @param props + * @returns + */ +export const AccordionStandardCard = (props: IAccordionStandardCardProps) => { + const { label, subtitle, children, colour, ornament, disableCollapse } = props; + + const [isCollapsed, setIsCollapsed] = useState(true); + + const expandable = (children || subtitle) && !disableCollapse; + + const handleHeaderClick = () => { + if (expandable) { + setIsCollapsed(!isCollapsed); + } + }; + + return ( + + + + + {label} + + {ornament} + + {expandable && } + + + + {subtitle && ( + + {subtitle} + + )} + {children} + + + + ); +}; diff --git a/app/src/features/standards/view/components/EnvironmentStandardCard.tsx b/app/src/features/standards/view/components/EnvironmentStandardCard.tsx deleted file mode 100644 index 036309e7c5..0000000000 --- a/app/src/features/standards/view/components/EnvironmentStandardCard.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { mdiChevronDown, mdiChevronUp } from '@mdi/js'; -import Icon from '@mdi/react'; -import { Box, Card, Collapse, Paper, Stack, Typography } from '@mui/material'; -import { grey } from '@mui/material/colors'; -import { EnvironmentQualitativeOption } from 'interfaces/useReferenceApi.interface'; -import { useState } from 'react'; - -interface IEnvironmentStandardCard { - label: string; - description?: string; - options?: EnvironmentQualitativeOption[]; - unit?: string; - small?: boolean; -} - -/** - * Card to display environment information. - * - * @return {*} - */ -const EnvironmentStandardCard = (props: IEnvironmentStandardCard) => { - const [isCollapsed, setIsCollapsed] = useState(true); - const { small } = props; - - return ( - setIsCollapsed(!isCollapsed)}> - - - {props.label} - - - - - - - {props.description ? props.description : 'No description'} - - - - {props.options?.map((option) => ( - - - {option.name} - - - {option?.description} - - - ))} - - - - ); -}; - -export default EnvironmentStandardCard; diff --git a/app/src/features/standards/view/components/MarkingBodyLocationStandardCard.tsx b/app/src/features/standards/view/components/MarkingBodyLocationStandardCard.tsx deleted file mode 100644 index d271785418..0000000000 --- a/app/src/features/standards/view/components/MarkingBodyLocationStandardCard.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Paper, Typography } from '@mui/material'; -import { grey } from '@mui/material/colors'; - -interface IMarkingBodyLocationStandardCard { - label: string; -} - -/** - * Card to display marking body location for species standards - * - * @return {*} - */ -const MarkingBodyLocationStandardCard = (props: IMarkingBodyLocationStandardCard) => { - return ( - - - {props.label} - - - ); -}; - -export default MarkingBodyLocationStandardCard; diff --git a/app/src/features/standards/view/components/MeasurementStandardCard.tsx b/app/src/features/standards/view/components/MeasurementStandardCard.tsx deleted file mode 100644 index 095031a323..0000000000 --- a/app/src/features/standards/view/components/MeasurementStandardCard.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { mdiChevronDown, mdiChevronUp } from '@mdi/js'; -import Icon from '@mdi/react'; -import { Box, Card, Collapse, Paper, Stack, Typography } from '@mui/material'; -import { grey } from '@mui/material/colors'; -import { CBQualitativeOption } from 'interfaces/useCritterApi.interface'; -import { useState } from 'react'; - -interface IMeasurementStandardCard { - label: string; - description?: string; - options?: CBQualitativeOption[]; - unit?: string; - small?: boolean; -} - -/** - * Card to display measurements information for species standards - * - * @return {*} - */ -const MeasurementStandardCard = (props: IMeasurementStandardCard) => { - const [isCollapsed, setIsCollapsed] = useState(true); - const { small } = props; - - return ( - setIsCollapsed(!isCollapsed)}> - - - {props.label} - - - - - - - {props.description ? props.description : 'No description'} - - - - {props.options?.map((option) => ( - - - {option.option_label} - - - {option?.option_desc} - - - ))} - - - - ); -}; - -export default MeasurementStandardCard; diff --git a/app/src/features/standards/view/environment/EnvironmentStandards.tsx b/app/src/features/standards/view/environment/EnvironmentStandards.tsx new file mode 100644 index 0000000000..b273a903eb --- /dev/null +++ b/app/src/features/standards/view/environment/EnvironmentStandards.tsx @@ -0,0 +1,64 @@ +import Box from '@mui/material/Box'; +import Skeleton from '@mui/material/Skeleton'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { debounce } from 'lodash-es'; +import { useEffect, useMemo } from 'react'; +import { EnvironmentStandardsResults } from './EnvironmentStandardsResults'; + +/** + * Returns environmental variable lookup values for the standards page + * + * @returns + */ +export const EnvironmentStandards = () => { + const biohubApi = useBiohubApi(); + + const environmentsDataLoader = useDataLoader((keyword?: string) => + biohubApi.standards.getEnvironmentStandards(keyword) + ); + + const debouncedRefresh = useMemo( + () => + debounce((value: string) => { + environmentsDataLoader.refresh(value); + }, 500), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + useEffect(() => { + environmentsDataLoader.load(); + }, [environmentsDataLoader]); + + return ( + <> + { + const value = event.currentTarget.value; + debouncedRefresh(value); + }} + /> + + {environmentsDataLoader.data ? ( + + ) : ( + + + + + + + + + )} + + + ); +}; diff --git a/app/src/features/standards/view/environment/EnvironmentStandardsResults.tsx b/app/src/features/standards/view/environment/EnvironmentStandardsResults.tsx new file mode 100644 index 0000000000..d4d22d0e57 --- /dev/null +++ b/app/src/features/standards/view/environment/EnvironmentStandardsResults.tsx @@ -0,0 +1,38 @@ +import { grey } from '@mui/material/colors'; +import Stack from '@mui/material/Stack'; +import { AccordionStandardCard } from 'features/standards/view/components/AccordionStandardCard'; +import { IEnvironmentStandards } from 'interfaces/useStandardsApi.interface'; + +interface ISpeciesStandardsResultsProps { + data: IEnvironmentStandards; +} + +/** + * Component to display environments standards results + * + * @return {*} + */ +export const EnvironmentStandardsResults = (props: ISpeciesStandardsResultsProps) => { + const { data } = props; + + return ( + + {data.quantitative.map((environment) => ( + + ))} + {data.qualitative.map((environment) => ( + + ))} + + ); +}; diff --git a/app/src/features/standards/view/methods/MethodStandards.tsx b/app/src/features/standards/view/methods/MethodStandards.tsx new file mode 100644 index 0000000000..2d540e13a9 --- /dev/null +++ b/app/src/features/standards/view/methods/MethodStandards.tsx @@ -0,0 +1,61 @@ +import { Skeleton, Stack, TextField } from '@mui/material'; +import Box from '@mui/material/Box'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { debounce } from 'lodash-es'; +import { useEffect, useMemo } from 'react'; +import { MethodStandardsResults } from './MethodStandardsResults'; + +/** + * + * Returns information about method lookup options + * + * @returns + */ +export const MethodStandards = () => { + const biohubApi = useBiohubApi(); + + const methodDataLoader = useDataLoader((keyword?: string) => biohubApi.standards.getMethodStandards(keyword)); + + const debouncedRefresh = useMemo( + () => + debounce((value: string) => { + methodDataLoader.refresh(value); + }, 500), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + useEffect(() => { + methodDataLoader.load(); + }, [methodDataLoader]); + + return ( + <> + { + const value = event.currentTarget.value; + debouncedRefresh(value); + }} + /> + + {methodDataLoader.data ? ( + + ) : ( + + + + + + + + + )} + + + ); +}; diff --git a/app/src/features/standards/view/methods/MethodStandardsResults.tsx b/app/src/features/standards/view/methods/MethodStandardsResults.tsx new file mode 100644 index 0000000000..001e9bb640 --- /dev/null +++ b/app/src/features/standards/view/methods/MethodStandardsResults.tsx @@ -0,0 +1,66 @@ +import { blueGrey, grey } from '@mui/material/colors'; +import Stack from '@mui/material/Stack'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { IMethodStandard } from 'interfaces/useStandardsApi.interface'; +import { AccordionStandardCard } from '../components/AccordionStandardCard'; + +interface ISpeciesStandardsResultsProps { + data: IMethodStandard[]; +} + +/** + * Component to display methods standards results + * + * @return {*} + */ +export const MethodStandardsResults = (props: ISpeciesStandardsResultsProps) => { + const { data } = props; + + return ( + + {data.map((method) => ( + + {method.attributes.qualitative.map((attribute) => ( + + {attribute.options.map((option) => ( + + ))} + + } + /> + ))} + {method.attributes.quantitative.map((attribute) => ( + } + /> + ))} + + } + /> + ))} + + ); +}; + +export default MethodStandardsResults; diff --git a/app/src/features/standards/view/species/SpeciesStandards.tsx b/app/src/features/standards/view/species/SpeciesStandards.tsx new file mode 100644 index 0000000000..44602950d5 --- /dev/null +++ b/app/src/features/standards/view/species/SpeciesStandards.tsx @@ -0,0 +1,63 @@ +import { mdiArrowTopRight } from '@mdi/js'; +import { Skeleton } from '@mui/material'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; +import SpeciesAutocompleteField from 'components/species/components/SpeciesAutocompleteField'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import SpeciesStandardsResults from './SpeciesStandardsResults'; + +/** + * Returns species standards page for searching species and viewing lookup values available for selected species. + * This component handles the data request, then passes the data to its children components. + * + * @returns + */ +export const SpeciesStandards = () => { + const biohubApi = useBiohubApi(); + + const standardsDataLoader = useDataLoader((species: IPartialTaxonomy) => + biohubApi.standards.getSpeciesStandards(species.tsn) + ); + + return ( + <> + { + standardsDataLoader.clearData(); + }} + handleSpecies={(value) => { + if (value) { + standardsDataLoader.refresh(value); + } + }} + /> + + {standardsDataLoader.data && } + {standardsDataLoader.isLoading ? ( + + + + + + + + + + ) : ( + + + + )} + + + ); +}; diff --git a/app/src/features/standards/view/species/SpeciesStandardsResults.tsx b/app/src/features/standards/view/species/SpeciesStandardsResults.tsx new file mode 100644 index 0000000000..92ca47e97b --- /dev/null +++ b/app/src/features/standards/view/species/SpeciesStandardsResults.tsx @@ -0,0 +1,97 @@ +import { mdiRuler, mdiTag } from '@mdi/js'; +import { Box, Divider, Stack, Typography } from '@mui/material'; +import { grey } from '@mui/material/colors'; +import { AccordionStandardCard } from 'features/standards/view/components/AccordionStandardCard'; +import { ScientificNameTypography } from 'features/surveys/animals/components/ScientificNameTypography'; +import { ISpeciesStandards } from 'interfaces/useStandardsApi.interface'; +import { useState } from 'react'; +import SpeciesStandardsToolbar, { SpeciesStandardsViewEnum } from './components/SpeciesStandardsToolbar'; + +interface ISpeciesStandardsResultsProps { + data: ISpeciesStandards; +} + +/** + * Component to display species standards results + * + * @return {*} + */ +const SpeciesStandardsResults = (props: ISpeciesStandardsResultsProps) => { + const [activeView, setActiveView] = useState(SpeciesStandardsViewEnum.MEASUREMENTS); + + return ( + <> + + + Showing results for  + + + + + + + + + {activeView === SpeciesStandardsViewEnum.MEASUREMENTS && ( + <> + {props.data.measurements.qualitative.map((measurement) => ( + + {measurement.options.map((option) => ( + + ))} + + } + /> + ))} + {props.data.measurements.quantitative.map((measurement) => ( + + ))} + + )} + {activeView === SpeciesStandardsViewEnum.MARKING_BODY_LOCATIONS && ( + <> + {props.data.markingBodyLocations.map((location) => ( + + ))} + + )} + + + ); +}; + +export default SpeciesStandardsResults; diff --git a/app/src/features/standards/view/components/SpeciesStandardsToolbar.tsx b/app/src/features/standards/view/species/components/SpeciesStandardsToolbar.tsx similarity index 100% rename from app/src/features/standards/view/components/SpeciesStandardsToolbar.tsx rename to app/src/features/standards/view/species/components/SpeciesStandardsToolbar.tsx diff --git a/app/src/features/summary/components/FilterFieldsContainer.tsx b/app/src/features/summary/components/FilterFieldsContainer.tsx index df6086cc96..9400c2706c 100644 --- a/app/src/features/summary/components/FilterFieldsContainer.tsx +++ b/app/src/features/summary/components/FilterFieldsContainer.tsx @@ -44,8 +44,8 @@ export const FilterFieldsContainer = { const { showSearch } = props; const biohubApi = useBiohubApi(); - const codesContext = useCodesContext(); - const taxonomyContext = useTaxonomyContext(); const { searchParams, setSearchParams } = useSearchParams>(); - useEffect(() => { - codesContext.codesDataLoader.load(); - }, [codesContext.codesDataLoader]); - const [paginationModel, setPaginationModel] = useState({ pageSize: Number(searchParams.get('p_limit') ?? ApiPaginationRequestOptionsInitialValues.limit), page: Number(searchParams.get('p_page') ?? ApiPaginationRequestOptionsInitialValues.page) @@ -104,17 +100,17 @@ const ProjectsListContainer = (props: IProjectsListContainerProps) => { [paginationModel.page, paginationModel.pageSize, sort?.field, sort?.sort] ); - const { refresh, isReady, data } = useDataLoader( + const projectsDataLoader = useDataLoader( (pagination: ApiPaginationRequestOptions, filter?: IProjectAdvancedFilters) => biohubApi.project.findProjects(pagination, filter) ); // Fetch projects when either the pagination, sort, or advanced filters change useDeepCompareEffect(() => { - refresh(paginationSort, advancedFiltersModel); + projectsDataLoader.refresh(paginationSort, advancedFiltersModel); }, [advancedFiltersModel, paginationSort]); - const rows = data?.projects ?? []; + const rows = projectsDataLoader.data?.projects ?? []; // Define the columns for the DataGrid const columns: GridColDef[] = [ @@ -140,18 +136,6 @@ const ProjectsListContainer = (props: IProjectsListContainerProps) => { flex: 1, disableColumnMenu: true, renderCell: (params) => { - const focalSpecies = params.row.focal_species - .map((species) => taxonomyContext.getCachedSpeciesTaxonomyById(species)?.commonNames) - .filter(Boolean) - .join(' \u2013 '); // n-dash with spaces - - const types = params.row.types - .map((type) => getCodesName(codesContext.codesDataLoader.data, 'type', type || 0)) - .filter(Boolean) - .join(' \u2013 '); // n-dash with spaces - - const detailsArray = [focalSpecies, types].filter(Boolean).join(' \u2013 '); - return ( { to={`/admin/projects/${params.row.project_id}`} children={params.row.name} /> - {/* Only administrators see the second title to help them find Projects with a standard naming convention */} - - {detailsArray.length > 0 ? ( - - {detailsArray} - - ) : ( - - There are no Surveys in this Project - - )} - ); } }, + { + field: 'members', + headerName: 'Members', + flex: 0.4, + renderCell: (params) => ( + + {params.row.members.map((member) => ( + name.trim().slice(0, 1).toUpperCase()) + .reverse() + .join('')} + color={getRandomHexColor(member.system_user_id)} + /> + ))} + + ) + }, { field: 'regions', headerName: 'Region', type: 'string', - flex: 0.4, + flex: 0.5, renderCell: (params) => ( {params.row.regions.map((region) => { @@ -200,7 +193,7 @@ const ProjectsListContainer = (props: IProjectsListContainerProps) => { return ( <> - + { @@ -216,63 +209,67 @@ const ProjectsListContainer = (props: IProjectsListContainerProps) => { - - row.project_id} - // Pagination - paginationMode="server" - paginationModel={paginationModel} - pageSizeOptions={pageSizeOptions} - onPaginationModelChange={(model) => { - if (!model) { - return; - } - setSearchParams(searchParams.set('p_page', String(model.page)).set('p_limit', String(model.pageSize))); - setPaginationModel(model); - }} - // Sorting - sortingMode="server" - sortModel={sortModel} - sortingOrder={['asc', 'desc']} - onSortModelChange={(model) => { - if (!model.length) { - return; - } - setSearchParams(searchParams.set('p_sort', model[0].field).set('p_order', model[0].sort ?? 'desc')); - setSortModel(model); - }} - // Row options - rowSelection={false} - checkboxSelection={false} - disableRowSelectionOnClick - // Column options - disableColumnSelector - disableColumnFilter - disableColumnMenu - // Styling - rowHeight={70} - getRowHeight={() => 'auto'} - autoHeight={false} - sx={{ - '& .MuiDataGrid-overlay': { - background: grey[50] - }, - '& .MuiDataGrid-cell': { - py: 0.75, - background: '#fff', - '&.MuiDataGrid-cell--editing:focus-within': { - outline: 'none' + + + } + isLoadingFallbackDelay={100} + hasNoData={!rows.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + row.project_id} + // Pagination + paginationMode="server" + paginationModel={paginationModel} + pageSizeOptions={pageSizeOptions} + onPaginationModelChange={(model) => { + if (!model) { + return; } - } - }} - /> + setSearchParams(searchParams.set('p_page', String(model.page)).set('p_limit', String(model.pageSize))); + setPaginationModel(model); + }} + // Sorting + sortingMode="server" + sortModel={sortModel} + sortingOrder={['asc', 'desc']} + onSortModelChange={(model) => { + if (!model.length) { + return; + } + setSearchParams(searchParams.set('p_sort', model[0].field).set('p_order', model[0].sort ?? 'desc')); + setSortModel(model); + }} + // Row options + rowSelection={false} + checkboxSelection={false} + disableRowSelectionOnClick + // Column options + disableColumnSelector + disableColumnFilter + disableColumnMenu + // Styling + rowHeight={70} + getRowHeight={() => 'auto'} + autoHeight={false} + /> + ); diff --git a/app/src/features/summary/list-data/survey/SurveysListContainer.tsx b/app/src/features/summary/list-data/survey/SurveysListContainer.tsx index 0f9dccb619..5226ea5e04 100644 --- a/app/src/features/summary/list-data/survey/SurveysListContainer.tsx +++ b/app/src/features/summary/list-data/survey/SurveysListContainer.tsx @@ -1,3 +1,4 @@ +import { mdiArrowTopRight } from '@mdi/js'; import Box from '@mui/material/Box'; import Collapse from '@mui/material/Collapse'; import grey from '@mui/material/colors/grey'; @@ -8,23 +9,23 @@ import Typography from '@mui/material/Typography'; import { GridColDef, GridPaginationModel, GridSortDirection, GridSortModel } from '@mui/x-data-grid'; import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; -import { SystemRoleGuard } from 'components/security/Guards'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; import { getNrmRegionColour, NrmRegionKeys } from 'constants/colours'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; import { NRM_REGION_APPENDED_TEXT } from 'constants/regions'; -import { SYSTEM_ROLE } from 'constants/roles'; import dayjs from 'dayjs'; +import { SurveyProgressChip } from 'features/surveys/components/SurveyProgressChip'; import { useBiohubApi } from 'hooks/useBioHubApi'; -import { useCodesContext, useTaxonomyContext } from 'hooks/useContext'; import useDataLoader from 'hooks/useDataLoader'; import { useDeepCompareEffect } from 'hooks/useDeepCompareEffect'; import { useSearchParams } from 'hooks/useSearchParams'; import { SurveyBasicFieldsObject } from 'interfaces/useSurveyApi.interface'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { ApiPaginationRequestOptions, StringValues } from 'types/misc'; -import { firstOrNull, getCodesName } from 'utils/Utils'; -import { SurveyProgressChip } from '../../../surveys/components/SurveyProgressChip'; +import { firstOrNull } from 'utils/Utils'; import SurveysListFilterForm, { ISurveyAdvancedFilters, SurveyAdvancedFiltersInitialValues @@ -67,15 +68,9 @@ const SurveysListContainer = (props: ISurveysListContainerProps) => { const { showSearch } = props; const biohubApi = useBiohubApi(); - const codesContext = useCodesContext(); - const taxonomyContext = useTaxonomyContext(); const { searchParams, setSearchParams } = useSearchParams>(); - useEffect(() => { - codesContext.codesDataLoader.load(); - }, [codesContext.codesDataLoader]); - const [paginationModel, setPaginationModel] = useState({ pageSize: Number(searchParams.get('s_limit') ?? initialPaginationParams.limit), page: Number(searchParams.get('s_page') ?? initialPaginationParams.page) @@ -138,22 +133,6 @@ const SurveysListContainer = (props: ISurveysListContainerProps) => { flex: 1, disableColumnMenu: true, renderCell: (params) => { - const dates = [params.row.start_date?.split('-')[0], params.row.end_date?.split('-')[0]] - .filter(Boolean) - .join(' \u2013 '); // n-dash with spaces - - const focalSpecies = params.row.focal_species - .map((species) => taxonomyContext.getCachedSpeciesTaxonomyById(species)?.commonNames) - .filter(Boolean) - .join(' \u2013 '); // n-dash with spaces - - const types = params.row.types - .map((type) => getCodesName(codesContext.codesDataLoader.data, 'type', type || 0)) - .filter(Boolean) - .join(' \u2013 '); // n-dash with spaces - - const detailsArray = [dates, focalSpecies, types].filter(Boolean).join(' \u2013 '); - return ( { to={`/admin/projects/${params.row.project_id}/surveys/${params.row.survey_id}`} children={params.row.name} /> - {/* Only administrators see the second title to help them find Projects with a standard naming convention */} - - - {detailsArray} - - ); } @@ -227,7 +200,7 @@ const SurveysListContainer = (props: ISurveysListContainerProps) => { return ( <> - + { @@ -243,63 +216,67 @@ const SurveysListContainer = (props: ISurveysListContainerProps) => { - - row.survey_id} - // Pagination - paginationMode="server" - paginationModel={paginationModel} - pageSizeOptions={pageSizeOptions} - onPaginationModelChange={(model) => { - if (!model) { - return; - } - setSearchParams(searchParams.set('s_page', String(model.page)).set('s_limit', String(model.pageSize))); - setPaginationModel(model); - }} - // Sorting - sortingMode="server" - sortModel={sortModel} - sortingOrder={['asc', 'desc']} - onSortModelChange={(model) => { - if (!model.length) { - return; - } - setSearchParams(searchParams.set('s_sort', model[0].field).set('s_order', model[0].sort ?? 'desc')); - setSortModel(model); - }} - // Row options - checkboxSelection={false} - disableRowSelectionOnClick - rowSelection={false} - // Column options - disableColumnSelector - disableColumnFilter - disableColumnMenu - // Styling - rowHeight={70} - getRowHeight={() => 'auto'} - autoHeight={false} - sx={{ - '& .MuiDataGrid-overlay': { - background: grey[50] - }, - '& .MuiDataGrid-cell': { - py: 0.75, - background: '#fff', - '&.MuiDataGrid-cell--editing:focus-within': { - outline: 'none' + + + } + isLoadingFallbackDelay={100} + hasNoData={!rows.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + row.survey_id} + // Pagination + paginationMode="server" + paginationModel={paginationModel} + pageSizeOptions={pageSizeOptions} + onPaginationModelChange={(model) => { + if (!model) { + return; + } + setSearchParams(searchParams.set('s_page', String(model.page)).set('s_limit', String(model.pageSize))); + setPaginationModel(model); + }} + // Sorting + sortingMode="server" + sortModel={sortModel} + sortingOrder={['asc', 'desc']} + onSortModelChange={(model) => { + if (!model.length) { + return; } - } - }} - /> + setSearchParams(searchParams.set('s_sort', model[0].field).set('s_order', model[0].sort ?? 'desc')); + setSortModel(model); + }} + // Row options + rowSelection={false} + checkboxSelection={false} + disableRowSelectionOnClick + // Column options + disableColumnSelector + disableColumnFilter + disableColumnMenu + // Styling + rowHeight={70} + getRowHeight={() => 'auto'} + autoHeight={false} + /> + ); diff --git a/app/src/features/summary/list-data/survey/SurveysListFilterForm.tsx b/app/src/features/summary/list-data/survey/SurveysListFilterForm.tsx index d015ca82c1..8f1adefade 100644 --- a/app/src/features/summary/list-data/survey/SurveysListFilterForm.tsx +++ b/app/src/features/summary/list-data/survey/SurveysListFilterForm.tsx @@ -71,7 +71,6 @@ const SurveysListFilterForm = (props: ISurveysListFilterFormProps) => { { if (value?.system_user_id) { formikProps.setFieldValue('system_user_id', value.system_user_id); diff --git a/app/src/features/summary/tabular-data/TabularDataTableContainer.tsx b/app/src/features/summary/tabular-data/TabularDataTableContainer.tsx index 7f34f51b7d..ba9e879dc1 100644 --- a/app/src/features/summary/tabular-data/TabularDataTableContainer.tsx +++ b/app/src/features/summary/tabular-data/TabularDataTableContainer.tsx @@ -1,10 +1,12 @@ -import { mdiEye, mdiMagnify, mdiPaw, mdiWifiMarker } from '@mdi/js'; +import { mdiEye, mdiPaw, mdiWifiMarker } from '@mdi/js'; import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Divider from '@mui/material/Divider'; +import Stack from '@mui/material/Stack'; import ToggleButton from '@mui/material/ToggleButton'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; -import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; import AnimalsListContainer from 'features/summary/tabular-data/animal/AnimalsListContainer'; import ObservationsListContainer from 'features/summary/tabular-data/observation/ObservationsListContainer'; import TelemetryListContainer from 'features/summary/tabular-data/telemetry/TelemetryListContainer'; @@ -32,12 +34,14 @@ type TabularDataTableURLParams = { const buttonSx = { py: 0.5, - px: 1.5, + px: 2, border: 'none', fontWeight: 700, borderRadius: '4px !important', fontSize: '0.875rem', - letterSpacing: '0.02rem' + letterSpacing: '0.02rem', + minHeight: '35px', + justifyContent: 'flex-start' }; /** @@ -49,7 +53,7 @@ export const TabularDataTableContainer = () => { const { searchParams, setSearchParams } = useSearchParams(); const [activeView, setActiveView] = useState(searchParams.get(ACTIVE_VIEW_KEY) ?? ACTIVE_VIEW_VALUE.observations); - const [showSearch, setShowSearch] = useState(searchParams.get(SHOW_SEARCH_KEY) === SHOW_SEARCH_VALUE.true); + const showSearch = true; const views = [ { value: ACTIVE_VIEW_VALUE.observations, label: 'observations', icon: mdiEye }, @@ -68,47 +72,37 @@ export const TabularDataTableContainer = () => { }; return ( - <> - - - {views.map((view) => ( - } - value={view.value}> - {view.label} - - ))} - - - - - - {activeView === ACTIVE_VIEW_VALUE.observations && } - {activeView === ACTIVE_VIEW_VALUE.animals && } - {activeView === ACTIVE_VIEW_VALUE.telemetry && } - + + + Data + {views.map((view) => ( + } + value={view.value}> + {view.label} + + ))} + + + + {activeView === ACTIVE_VIEW_VALUE.observations && } + {activeView === ACTIVE_VIEW_VALUE.animals && } + {activeView === ACTIVE_VIEW_VALUE.telemetry && } + + ); }; diff --git a/app/src/features/summary/tabular-data/animal/AnimalsListContainer.tsx b/app/src/features/summary/tabular-data/animal/AnimalsListContainer.tsx index fc53dd8b74..dcafbcd54d 100644 --- a/app/src/features/summary/tabular-data/animal/AnimalsListContainer.tsx +++ b/app/src/features/summary/tabular-data/animal/AnimalsListContainer.tsx @@ -1,3 +1,4 @@ +import { mdiArrowTopRight } from '@mdi/js'; import Box from '@mui/material/Box'; import Collapse from '@mui/material/Collapse'; import grey from '@mui/material/colors/grey'; @@ -5,6 +6,9 @@ import Divider from '@mui/material/Divider'; import Typography from '@mui/material/Typography'; import { GridColDef, GridPaginationModel, GridSortDirection, GridSortModel } from '@mui/x-data-grid'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader from 'hooks/useDataLoader'; import { useDeepCompareEffect } from 'hooks/useDeepCompareEffect'; @@ -91,7 +95,7 @@ const AnimalsListContainer = (props: IAnimalsListContainerProps) => { animalsDataLoader.refresh(paginationSort, advancedFiltersModel); }, [advancedFiltersModel, paginationSort]); - const animalRows = animalsDataLoader.data?.animals ?? []; + const rows = animalsDataLoader.data?.animals ?? []; const columns: GridColDef[] = [ { @@ -141,7 +145,7 @@ const AnimalsListContainer = (props: IAnimalsListContainerProps) => { return ( <> - + { @@ -152,63 +156,67 @@ const AnimalsListContainer = (props: IAnimalsListContainerProps) => { - - row.critter_id} - // Pagination - paginationMode="server" - pageSizeOptions={pageSizeOptions} - paginationModel={paginationModel} - onPaginationModelChange={(model) => { - if (!model) { - return; - } - setSearchParams(searchParams.set('a_page', String(model.page)).set('a_limit', String(model.pageSize))); - setPaginationModel(model); - }} - // Sorting - sortingMode="server" - sortModel={sortModel} - sortingOrder={['asc', 'desc']} - onSortModelChange={(model) => { - if (!model.length) { - return; - } - setSearchParams(searchParams.set('a_sort', model[0].field).set('a_order', model[0].sort ?? 'desc')); - setSortModel(model); - }} - // Row options - checkboxSelection={false} - disableRowSelectionOnClick - rowSelection={false} - // Column options - disableColumnSelector - disableColumnFilter - disableColumnMenu - // Styling - rowHeight={70} - getRowHeight={() => 'auto'} - autoHeight={false} - sx={{ - '& .MuiDataGrid-overlay': { - background: grey[50] - }, - '& .MuiDataGrid-cell': { - py: 0.75, - background: '#fff', - '&.MuiDataGrid-cell--editing:focus-within': { - outline: 'none' + + + } + isLoadingFallbackDelay={100} + hasNoData={!rows.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + row.critter_id} + // Pagination + paginationMode="server" + paginationModel={paginationModel} + pageSizeOptions={pageSizeOptions} + onPaginationModelChange={(model) => { + if (!model) { + return; + } + setSearchParams(searchParams.set('a_page', String(model.page)).set('a_limit', String(model.pageSize))); + setPaginationModel(model); + }} + // Sorting + sortingMode="server" + sortModel={sortModel} + sortingOrder={['asc', 'desc']} + onSortModelChange={(model) => { + if (!model.length) { + return; } - } - }} - /> + setSearchParams(searchParams.set('a_sort', model[0].field).set('a_order', model[0].sort ?? 'desc')); + setSortModel(model); + }} + // Row options + rowSelection={false} + checkboxSelection={false} + disableRowSelectionOnClick + // Column options + disableColumnSelector + disableColumnFilter + disableColumnMenu + // Styling + rowHeight={70} + getRowHeight={() => 'auto'} + autoHeight={false} + /> + ); diff --git a/app/src/features/summary/tabular-data/observation/ObservationsListContainer.tsx b/app/src/features/summary/tabular-data/observation/ObservationsListContainer.tsx index 4b4667a275..5f43fff388 100644 --- a/app/src/features/summary/tabular-data/observation/ObservationsListContainer.tsx +++ b/app/src/features/summary/tabular-data/observation/ObservationsListContainer.tsx @@ -1,3 +1,4 @@ +import { mdiArrowTopRight } from '@mdi/js'; import Box from '@mui/material/Box'; import Collapse from '@mui/material/Collapse'; import grey from '@mui/material/colors/grey'; @@ -5,16 +6,18 @@ import Divider from '@mui/material/Divider'; import Typography from '@mui/material/Typography'; import { GridColDef, GridPaginationModel, GridSortDirection, GridSortModel } from '@mui/x-data-grid'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; import { IObservationTableRow } from 'contexts/observationsTableContext'; import dayjs from 'dayjs'; import { useBiohubApi } from 'hooks/useBioHubApi'; -import { useCodesContext } from 'hooks/useContext'; import useDataLoader from 'hooks/useDataLoader'; import { useDeepCompareEffect } from 'hooks/useDeepCompareEffect'; import { useSearchParams } from 'hooks/useSearchParams'; import { IGetSurveyObservationsResponse } from 'interfaces/useObservationApi.interface'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; import { ApiPaginationRequestOptions, StringValues } from 'types/misc'; import { firstOrNull } from 'utils/Utils'; import { @@ -53,7 +56,7 @@ const initialPaginationParams: Required = { page: 0, limit: 10, sort: 'survey_observation_id', - order: 'desc' + order: 'asc' }; /** @@ -65,14 +68,9 @@ const ObservationsListContainer = (props: IObservationsListContainerProps) => { const { showSearch } = props; const biohubApi = useBiohubApi(); - const codesContext = useCodesContext(); const { searchParams, setSearchParams } = useSearchParams>(); - useEffect(() => { - codesContext.codesDataLoader.load(); - }, [codesContext.codesDataLoader]); - const [paginationModel, setPaginationModel] = useState({ pageSize: Number(searchParams.get('o_limit') ?? initialPaginationParams.limit), page: Number(searchParams.get('o_page') ?? initialPaginationParams.page) @@ -163,7 +161,7 @@ const ObservationsListContainer = (props: IObservationsListContainerProps) => { [] ); - const observationRows = observationsDataLoader.data ? getRowsFromObservations(observationsDataLoader.data) : []; + const rows = observationsDataLoader.data ? getRowsFromObservations(observationsDataLoader.data) : []; const columns: GridColDef[] = [ { @@ -226,7 +224,7 @@ const ObservationsListContainer = (props: IObservationsListContainerProps) => { return ( <> - + { @@ -247,63 +245,67 @@ const ObservationsListContainer = (props: IObservationsListContainerProps) => { - - row.observation_subcount_id} - // Pagination - paginationMode="server" - pageSizeOptions={pageSizeOptions} - paginationModel={paginationModel} - onPaginationModelChange={(model) => { - if (!model) { - return; - } - setSearchParams(searchParams.set('o_page', String(model.page)).set('o_limit', String(model.pageSize))); - setPaginationModel(model); - }} - // Sorting - sortingMode="server" - sortModel={sortModel} - sortingOrder={['asc', 'desc']} - onSortModelChange={(model) => { - if (!model.length) { - return; - } - setSearchParams(searchParams.set('o_sort', model[0].field).set('o_order', model[0].sort ?? 'desc')); - setSortModel(model); - }} - // Row options - checkboxSelection={false} - disableRowSelectionOnClick - rowSelection={false} - // Column options - disableColumnSelector - disableColumnFilter - disableColumnMenu - // Styling - rowHeight={70} - getRowHeight={() => 'auto'} - autoHeight={false} - sx={{ - '& .MuiDataGrid-overlay': { - background: grey[50] - }, - '& .MuiDataGrid-cell': { - py: 0.75, - background: '#fff', - '&.MuiDataGrid-cell--editing:focus-within': { - outline: 'none' + + + } + isLoadingFallbackDelay={100} + hasNoData={!rows.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + row.observation_subcount_id} + // Pagination + paginationMode="server" + paginationModel={paginationModel} + pageSizeOptions={pageSizeOptions} + onPaginationModelChange={(model) => { + if (!model) { + return; + } + setSearchParams(searchParams.set('o_page', String(model.page)).set('o_limit', String(model.pageSize))); + setPaginationModel(model); + }} + // Sorting + sortingMode="server" + sortModel={sortModel} + sortingOrder={['asc', 'desc']} + onSortModelChange={(model) => { + if (!model.length) { + return; } - } - }} - /> + setSearchParams(searchParams.set('o_sort', model[0].field).set('o_order', model[0].sort ?? 'desc')); + setSortModel(model); + }} + // Row options + rowSelection={false} + checkboxSelection={false} + disableRowSelectionOnClick + // Column options + disableColumnSelector + disableColumnFilter + disableColumnMenu + // Styling + rowHeight={70} + getRowHeight={() => 'auto'} + autoHeight={false} + /> + ); diff --git a/app/src/features/summary/tabular-data/telemetry/TelemetryListContainer.tsx b/app/src/features/summary/tabular-data/telemetry/TelemetryListContainer.tsx index 843ed8ffa1..1609839ea1 100644 --- a/app/src/features/summary/tabular-data/telemetry/TelemetryListContainer.tsx +++ b/app/src/features/summary/tabular-data/telemetry/TelemetryListContainer.tsx @@ -1,3 +1,4 @@ +import { mdiArrowTopRight } from '@mdi/js'; import Box from '@mui/material/Box'; import Collapse from '@mui/material/Collapse'; import grey from '@mui/material/colors/grey'; @@ -5,6 +6,9 @@ import Divider from '@mui/material/Divider'; import Typography from '@mui/material/Typography'; import { GridColDef, GridPaginationModel, GridSortDirection, GridSortModel } from '@mui/x-data-grid'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; import dayjs from 'dayjs'; import { useBiohubApi } from 'hooks/useBioHubApi'; @@ -16,7 +20,7 @@ import { useState } from 'react'; import { ApiPaginationRequestOptions, StringValues } from 'types/misc'; import { firstOrNull } from 'utils/Utils'; import TelemetryListFilterForm, { - ITelemetryAdvancedFilters, + IAllTelemetryAdvancedFilters, TelemetryAdvancedFiltersInitialValues } from './TelemetryListFilterForm'; @@ -34,7 +38,7 @@ type TelemetryDataTableURLParams = { const pageSizeOptions = [10, 25, 50]; -interface ITelemetryListContainerProps { +interface IAllTelemetryListContainerProps { showSearch: boolean; } @@ -51,7 +55,7 @@ const initialPaginationParams: ApiPaginationRequestOptions = { * * @return {*} */ -const TelemetryListContainer = (props: ITelemetryListContainerProps) => { +const TelemetryListContainer = (props: IAllTelemetryListContainerProps) => { const { showSearch } = props; const biohubApi = useBiohubApi(); @@ -70,7 +74,7 @@ const TelemetryListContainer = (props: ITelemetryListContainerProps) => { } ]); - const [advancedFiltersModel, setAdvancedFiltersModel] = useState({ + const [advancedFiltersModel, setAdvancedFiltersModel] = useState({ itis_tsn: searchParams.get('t_itis_tsn') ? Number(searchParams.get('t_itis_tsn')) : TelemetryAdvancedFiltersInitialValues.itis_tsn @@ -85,7 +89,7 @@ const TelemetryListContainer = (props: ITelemetryListContainerProps) => { }; const telemetryDataLoader = useDataLoader( - (pagination?: ApiPaginationRequestOptions, filter?: ITelemetryAdvancedFilters) => + (pagination?: ApiPaginationRequestOptions, filter?: IAllTelemetryAdvancedFilters) => biohubApi.telemetry.findTelemetry(pagination, filter) ); @@ -93,14 +97,13 @@ const TelemetryListContainer = (props: ITelemetryListContainerProps) => { telemetryDataLoader.refresh(paginationSort, advancedFiltersModel); }, [advancedFiltersModel, paginationSort]); - const telemetryRows = telemetryDataLoader.data?.telemetry ?? []; + const rows = telemetryDataLoader.data?.telemetry ?? []; const columns: GridColDef[] = [ { field: 'telemetry_id', headerName: 'ID', - width: 50, - minWidth: 50, + minWidth: 200, sortable: false, renderHeader: () => ( @@ -145,7 +148,7 @@ const TelemetryListContainer = (props: ITelemetryListContainerProps) => { return ( <> - + { @@ -156,63 +159,67 @@ const TelemetryListContainer = (props: ITelemetryListContainerProps) => { - - row.telemetry_id} - // Pagination - paginationMode="server" - pageSizeOptions={pageSizeOptions} - paginationModel={paginationModel} - onPaginationModelChange={(model) => { - if (!model) { - return; - } - setSearchParams(searchParams.set('t_page', String(model.page)).set('t_limit', String(model.pageSize))); - setPaginationModel(model); - }} - // Sorting - sortingMode="server" - sortModel={sortModel} - sortingOrder={['asc', 'desc']} - onSortModelChange={(model) => { - if (!model[0]) { - return; - } - setSearchParams(searchParams.set('t_sort', model[0].field).set('t_order', model[0].sort ?? 'desc')); - setSortModel(model); - }} - // Row options - checkboxSelection={false} - disableRowSelectionOnClick - rowSelection={false} - // Column options - disableColumnSelector - disableColumnFilter - disableColumnMenu - // Styling - rowHeight={70} - getRowHeight={() => 'auto'} - autoHeight={false} - sx={{ - '& .MuiDataGrid-overlay': { - background: grey[50] - }, - '& .MuiDataGrid-cell': { - py: 0.75, - background: '#fff', - '&.MuiDataGrid-cell--editing:focus-within': { - outline: 'none' + + + } + isLoadingFallbackDelay={100} + hasNoData={!rows.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + row.telemetry_id} + // Pagination + paginationMode="server" + paginationModel={paginationModel} + pageSizeOptions={pageSizeOptions} + onPaginationModelChange={(model) => { + if (!model) { + return; + } + setSearchParams(searchParams.set('t_page', String(model.page)).set('t_limit', String(model.pageSize))); + setPaginationModel(model); + }} + // Sorting + sortingMode="server" + sortModel={sortModel} + sortingOrder={['asc', 'desc']} + onSortModelChange={(model) => { + if (!model.length) { + return; } - } - }} - /> + setSearchParams(searchParams.set('t_sort', model[0].field).set('t_order', model[0].sort ?? 'desc')); + setSortModel(model); + }} + // Row options + rowSelection={false} + checkboxSelection={false} + disableRowSelectionOnClick + // Column options + disableColumnSelector + disableColumnFilter + disableColumnMenu + // Styling + rowHeight={70} + getRowHeight={() => 'auto'} + autoHeight={false} + /> + ); diff --git a/app/src/features/summary/tabular-data/telemetry/TelemetryListFilterForm.tsx b/app/src/features/summary/tabular-data/telemetry/TelemetryListFilterForm.tsx index 34fdc80766..d16e02529d 100644 --- a/app/src/features/summary/tabular-data/telemetry/TelemetryListFilterForm.tsx +++ b/app/src/features/summary/tabular-data/telemetry/TelemetryListFilterForm.tsx @@ -4,17 +4,17 @@ import { FilterFieldsContainer } from 'features/summary/components/FilterFieldsC import { Formik } from 'formik'; import { useTaxonomyContext } from 'hooks/useContext'; -export type ITelemetryAdvancedFilters = { +export type IAllTelemetryAdvancedFilters = { itis_tsn?: number; }; -export const TelemetryAdvancedFiltersInitialValues: ITelemetryAdvancedFilters = { +export const TelemetryAdvancedFiltersInitialValues: IAllTelemetryAdvancedFilters = { itis_tsn: undefined }; -export interface ITelemetryListFilterFormProps { - handleSubmit: (filterValues: ITelemetryAdvancedFilters) => void; - initialValues?: ITelemetryAdvancedFilters; +export interface IAllTelemetryListFilterFormProps { + handleSubmit: (filterValues: IAllTelemetryAdvancedFilters) => void; + initialValues?: IAllTelemetryAdvancedFilters; } /** @@ -23,10 +23,10 @@ export interface ITelemetryListFilterFormProps { * TODO: The filter fields are disabled for now. The fields are functional (the values are captured and passed to the * backend), but the backend does not currently use them for filtering. * - * @param {ITelemetryListFilterFormProps} props + * @param {IAllTelemetryListFilterFormProps} props * @return {*} */ -const TelemetryListFilterForm = (props: ITelemetryListFilterFormProps) => { +const TelemetryListFilterForm = (props: IAllTelemetryListFilterFormProps) => { const { handleSubmit, initialValues } = props; const taxonomyContext = useTaxonomyContext(); diff --git a/app/src/features/surveys/CreateSurveyPage.tsx b/app/src/features/surveys/CreateSurveyPage.tsx index 4212920be8..54436b3f8d 100644 --- a/app/src/features/surveys/CreateSurveyPage.tsx +++ b/app/src/features/surveys/CreateSurveyPage.tsx @@ -13,6 +13,7 @@ import { CreateSurveyI18N } from 'constants/i18n'; import { CodesContext } from 'contexts/codesContext'; import { DialogContext } from 'contexts/dialogContext'; import { ProjectContext } from 'contexts/projectContext'; +import { ISurveyPermitForm, SurveyPermitFormInitialValues } from 'features/surveys/components/permit/SurveyPermitForm'; import { SurveyPartnershipsFormInitialValues } from 'features/surveys/view/components/SurveyPartnershipsForm'; import { FormikProps } from 'formik'; import { APIError } from 'hooks/api/useAxios'; @@ -24,17 +25,22 @@ import { Prompt, useHistory } from 'react-router'; import { Link as RouterLink } from 'react-router-dom'; import { AgreementsInitialValues } from './components/agreements/AgreementsForm'; import { ProprietaryDataInitialValues } from './components/agreements/ProprietaryDataForm'; -import { SurveyFundingSourceFormInitialValues } from './components/funding/SurveyFundingSourceForm'; +import { + ISurveyFundingSourceForm, + SurveyFundingSourceFormInitialValues +} from './components/funding/SurveyFundingSourceForm'; import { GeneralInformationInitialValues } from './components/general-information/GeneralInformationForm'; import { SurveyLocationInitialValues } from './components/locations/StudyAreaForm'; import { PurposeAndMethodologyInitialValues } from './components/methodology/PurposeAndMethodologyForm'; import { SurveyUserJobFormInitialValues } from './components/participants/SurveyUserForm'; import { SurveyBlockInitialValues } from './components/sampling-strategy/blocks/SurveyBlockForm'; import { SurveySiteSelectionInitialValues } from './components/sampling-strategy/SurveySiteSelectionForm'; +import { SpeciesInitialValues } from './components/species/SpeciesForm'; import EditSurveyForm from './edit/EditSurveyForm'; -export const defaultSurveyDataFormValues: ICreateSurveyRequest = { +export const defaultSurveyDataFormValues: ICreateSurveyRequest & ISurveyPermitForm & ISurveyFundingSourceForm = { ...GeneralInformationInitialValues, + ...SurveyPermitFormInitialValues, ...PurposeAndMethodologyInitialValues, ...SurveyFundingSourceFormInitialValues, ...SurveyPartnershipsFormInitialValues, @@ -43,7 +49,8 @@ export const defaultSurveyDataFormValues: ICreateSurveyRequest = { ...SurveyLocationInitialValues, ...SurveySiteSelectionInitialValues, ...SurveyUserJobFormInitialValues, - ...SurveyBlockInitialValues + ...SurveyBlockInitialValues, + ...SpeciesInitialValues }; /** @@ -101,10 +108,39 @@ const CreateSurveyPage = () => { * * @return {*} */ - const handleSubmit = async (values: ICreateSurveyRequest) => { + const handleSubmit = async (values: ICreateSurveyRequest & ISurveyPermitForm & ISurveyFundingSourceForm) => { setIsSaving(true); try { - const response = await biohubApi.survey.createSurvey(Number(projectData?.project.project_id), values); + // Remove the permit_used and funding_used properties + const response = await biohubApi.survey.createSurvey(Number(projectData?.project.project_id), { + blocks: values.blocks, + funding_sources: values.funding_sources, + locations: values.locations.map((location) => ({ + survey_location_id: location.survey_location_id, + geojson: location.geojson, + name: location.name, + description: location.description, + revision_count: location.revision_count + })), + participants: values.participants, + partnerships: values.partnerships, + permit: { + permits: values.permit.permits + }, + proprietor: values.proprietor, + site_selection: { + stratums: values.site_selection.stratums.map((stratum) => ({ + survey_stratum_id: stratum.survey_stratum_id, + name: stratum.name, + description: stratum.description + })), + strategies: values.site_selection.strategies + }, + species: values.species, + survey_details: values.survey_details, + purpose_and_methodology: values.purpose_and_methodology, + agreements: values.agreements + }); if (!response?.id) { showCreateErrorDialog({ @@ -170,7 +206,9 @@ const CreateSurveyPage = () => { handleSubmit(formikData as unknown as ICreateSurveyRequest)} + handleSubmit={(formikData) => + handleSubmit(formikData as unknown as ICreateSurveyRequest & ISurveyPermitForm & ISurveyFundingSourceForm) + } formikRef={formikRef} /> diff --git a/app/src/features/surveys/SurveyRouter.tsx b/app/src/features/surveys/SurveyRouter.tsx index cb3c251cfe..1ee306311c 100644 --- a/app/src/features/surveys/SurveyRouter.tsx +++ b/app/src/features/surveys/SurveyRouter.tsx @@ -2,16 +2,17 @@ import { ProjectRoleRouteGuard } from 'components/security/RouteGuards'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; import { AnimalPageContextProvider } from 'contexts/animalPageContext'; import { DialogContextProvider } from 'contexts/dialogContext'; +import { TelemetryDataContextProvider } from 'contexts/telemetryDataContext'; import { AnimalRouter } from 'features/surveys/animals/AnimalRouter'; import EditSurveyPage from 'features/surveys/edit/EditSurveyPage'; import { SurveyObservationPage } from 'features/surveys/observations/SurveyObservationPage'; import { SamplingRouter } from 'features/surveys/sampling-information/SamplingRouter'; -import ManualTelemetryPage from 'features/surveys/telemetry/ManualTelemetryPage'; import SurveyPage from 'features/surveys/view/SurveyPage'; import React from 'react'; import { Redirect, Switch } from 'react-router'; import RouteWithTitle from 'utils/RouteWithTitle'; import { getTitle } from 'utils/Utils'; +import { TelemetryRouter } from './telemetry/TelemetryRouter'; /** * Router for all `/admin/projects/:id/surveys/:survey_id/*` pages. @@ -53,26 +54,28 @@ const SurveyRouter: React.FC = () => { - {/* Observations Routes */} - + {/* Telemetry Routes */} + - + + + + + - {/* Telemetry Routes */} - + {/* Observations Routes */} + - - - + diff --git a/app/src/features/surveys/animals/AnimalRouter.tsx b/app/src/features/surveys/animals/AnimalRouter.tsx index 0bb7eb1d39..6ca4d02e44 100644 --- a/app/src/features/surveys/animals/AnimalRouter.tsx +++ b/app/src/features/surveys/animals/AnimalRouter.tsx @@ -56,7 +56,7 @@ export const AnimalRouter: React.FC = () => { { { { { { unit.collection_category_id)} + ecologicalUnits={ecologicalUnitsDataLoader?.data ?? []} arrayHelpers={arrayHelpers} index={index} /> diff --git a/app/src/features/surveys/animals/animal-form/components/ecological-units/components/EcologicalUnitsSelect.tsx b/app/src/features/surveys/animals/animal-form/components/ecological-units/components/EcologicalUnitsSelect.tsx deleted file mode 100644 index da703dbe1f..0000000000 --- a/app/src/features/surveys/animals/animal-form/components/ecological-units/components/EcologicalUnitsSelect.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { mdiClose } from '@mdi/js'; -import { Icon } from '@mdi/react'; -import Card from '@mui/material/Card'; -import grey from '@mui/material/colors/grey'; -import IconButton from '@mui/material/IconButton'; -import Stack from '@mui/material/Stack'; -import AutocompleteField from 'components/fields/AutocompleteField'; -import { FieldArrayRenderProps, useFormikContext } from 'formik'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import useDataLoader from 'hooks/useDataLoader'; -import { ICollectionCategory, ICreateEditAnimalRequest } from 'interfaces/useCritterApi.interface'; -import { useEffect, useMemo, useState } from 'react'; -import { EcologicalUnitsOptionSelect } from './EcologicalUnitsOptionSelect'; - -interface IEcologicalUnitsSelect { - // The collection units (categories) available to select from - ecologicalUnits: ICollectionCategory[]; - // Formik field array helpers - arrayHelpers: FieldArrayRenderProps; - // The index of the field array for these controls - index: number; -} - -/** - * Returns a component for selecting ecological (ie. collection) units for a given species. - * - * @param {IEcologicalUnitsSelect} props - * @return {*} - */ -export const EcologicalUnitsSelect = (props: IEcologicalUnitsSelect) => { - const { index, ecologicalUnits } = props; - - const { values, setFieldValue } = useFormikContext(); - - const critterbaseApi = useCritterbaseApi(); - - // Get the collection category ID for the selected ecological unit - const selectedEcologicalUnitId: string | undefined = values.ecological_units[index]?.collection_category_id; - - const ecologicalUnitOptionDataLoader = useDataLoader((collection_category_id: string) => - critterbaseApi.xref.getCollectionUnits(collection_category_id) - ); - - useEffect(() => { - // If a collection category is already selected, load the collection units for that category - if (!selectedEcologicalUnitId) { - return; - } - - ecologicalUnitOptionDataLoader.load(selectedEcologicalUnitId); - }, [ecologicalUnitOptionDataLoader, selectedEcologicalUnitId]); - - // Set the label for the ecological unit options autocomplete field - const [ecologicalUnitOptionLabel, setEcologicalUnitOptionLabel] = useState( - ecologicalUnits.find((ecologicalUnit) => ecologicalUnit.collection_category_id === selectedEcologicalUnitId) - ?.category_name ?? '' - ); - - // Filter out the categories that are already selected so they can't be selected again - const filteredCategories = useMemo( - () => - ecologicalUnits - .filter( - (ecologicalUnit) => - !values.ecological_units.some( - (existing) => - existing.collection_category_id === ecologicalUnit.collection_category_id && - existing.collection_category_id !== selectedEcologicalUnitId - ) - ) - .map((option) => { - return { - value: option.collection_category_id, - label: option.category_name - }; - }) ?? [], - [ecologicalUnits, selectedEcologicalUnitId, values.ecological_units] - ); - - // Map the collection unit options to the format required by the AutocompleteField - const ecologicalUnitOptions = useMemo( - () => - ecologicalUnitOptionDataLoader.data?.map((option) => ({ - value: option.collection_unit_id, - label: option.unit_name - })) ?? [], - [ecologicalUnitOptionDataLoader.data] - ); - - return ( - - { - if (option?.value) { - setFieldValue(`ecological_units.[${index}].collection_category_id`, option.value); - setEcologicalUnitOptionLabel(option.label); - } - }} - required - sx={{ - flex: '1 1 auto' - }} - /> - - props.arrayHelpers.remove(index)} - sx={{ mt: 1.125 }}> - - - - ); -}; diff --git a/app/src/features/surveys/animals/animal-form/components/general-information/AnimalGeneralInformationForm.tsx b/app/src/features/surveys/animals/animal-form/components/general-information/AnimalGeneralInformationForm.tsx index d89ec8cb5d..6bf20c9e5f 100644 --- a/app/src/features/surveys/animals/animal-form/components/general-information/AnimalGeneralInformationForm.tsx +++ b/app/src/features/surveys/animals/animal-form/components/general-information/AnimalGeneralInformationForm.tsx @@ -2,10 +2,10 @@ import Collapse from '@mui/material/Collapse'; import Grid from '@mui/material/Grid'; import Box from '@mui/system/Box'; import CustomTextField from 'components/fields/CustomTextField'; -import SelectedSpecies from 'components/species/components/SelectedSpecies'; import SpeciesAutocompleteField from 'components/species/components/SpeciesAutocompleteField'; import { useFormikContext } from 'formik'; import { ICreateEditAnimalRequest } from 'interfaces/useCritterApi.interface'; +import SelectedAnimalSpecies from './components/SelectedAnimalSpecies'; export interface IAnimalGeneralInformationFormProps { isEdit?: boolean; @@ -41,7 +41,7 @@ export const AnimalGeneralInformationForm = (props: IAnimalGeneralInformationFor /> {values.species && ( - setFieldValue('species', null)} diff --git a/app/src/features/surveys/animals/animal-form/components/general-information/components/SelectedAnimalSpecies.tsx b/app/src/features/surveys/animals/animal-form/components/general-information/components/SelectedAnimalSpecies.tsx new file mode 100644 index 0000000000..122d0f7b15 --- /dev/null +++ b/app/src/features/surveys/animals/animal-form/components/general-information/components/SelectedAnimalSpecies.tsx @@ -0,0 +1,38 @@ +import Collapse from '@mui/material/Collapse'; +import grey from '@mui/material/colors/grey'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import SpeciesSelectedCard from 'components/species/components/SpeciesSelectedCard'; +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { TransitionGroup } from 'react-transition-group'; + +export interface ISelectedAnimalSpeciesProps { + selectedSpecies: IPartialTaxonomy[]; + handleRemoveSpecies?: (species_id: number) => void; +} + +/** + * Returns a stack of selected species cards. + * + * @param props {ISelectedAnimalSpeciesProps} + * @returns + */ +const SelectedAnimalSpecies = (props: ISelectedAnimalSpeciesProps) => { + const { selectedSpecies, handleRemoveSpecies } = props; + + return ( + + {selectedSpecies.map((species, speciesIndex) => { + return ( + + + + + + ); + })} + + ); +}; + +export default SelectedAnimalSpecies; diff --git a/app/src/features/surveys/animals/animal-form/create/CreateAnimalPage.tsx b/app/src/features/surveys/animals/animal-form/create/CreateAnimalPage.tsx index 3f8ed2659d..b0e84cd94f 100644 --- a/app/src/features/surveys/animals/animal-form/create/CreateAnimalPage.tsx +++ b/app/src/features/surveys/animals/animal-form/create/CreateAnimalPage.tsx @@ -25,6 +25,7 @@ import { Link as RouterLink } from 'react-router-dom'; export const defaultAnimalDataFormValues: ICreateEditAnimalRequest = { nickname: '', species: null, + sex: AnimalSex.UNKNOWN, ecological_units: [], wildlife_health_id: '', critter_comment: '' @@ -91,7 +92,7 @@ export const CreateAnimalPage = () => { itis_tsn: values.species.tsn, wlh_id: undefined, animal_id: values.nickname, - sex: AnimalSex.UNKNOWN, + sex: values.sex, critter_comment: values.critter_comment }); @@ -118,7 +119,7 @@ export const CreateAnimalPage = () => { animalPageContext.setSelectedAnimal({ critterbase_critter_id: response.critterbase_critter_id, - survey_critter_id: response.survey_critter_id + critter_id: response.critter_id }); // Refresh the context, so the next page loads with the latest data diff --git a/app/src/features/surveys/animals/animal-form/edit/EditAnimalPage.tsx b/app/src/features/surveys/animals/animal-form/edit/EditAnimalPage.tsx index a38fb4d213..2a33aa80f0 100644 --- a/app/src/features/surveys/animals/animal-form/edit/EditAnimalPage.tsx +++ b/app/src/features/surveys/animals/animal-form/edit/EditAnimalPage.tsx @@ -44,7 +44,7 @@ export const EditAnimalPage = () => { const taxonomyContext = useTaxonomyContext(); const urlParams: Record = useParams(); - const surveyCritterId: number | undefined = Number(urlParams['survey_critter_id']); + const surveyCritterId: number | undefined = Number(urlParams['critter_id']); const { locationChangeInterceptor } = useUnsavedChangesDialog(); @@ -70,7 +70,7 @@ export const EditAnimalPage = () => { }, [critter?.itis_tsn, taxonomyContext]); // Loading spinner if the data later hasn't updated to the selected animal yet - if (!critter || animalPageContext.selectedAnimal?.critterbase_critter_id !== critter.critter_id) { + if (!critter || animalPageContext.selectedAnimal?.critterbase_critter_id !== critter.critterbase_critter_id) { return ; } @@ -107,11 +107,11 @@ export const EditAnimalPage = () => { } const response = await critterbaseApi.critters.updateCritter({ - critter_id: critter.critter_id, + critter_id: critter.critterbase_critter_id, itis_tsn: values.species.tsn, wlh_id: values.wildlife_health_id, animal_id: values.nickname, - sex: AnimalSex.UNKNOWN, + sex: values.sex, critter_comment: values.critter_comment }); @@ -130,13 +130,13 @@ export const EditAnimalPage = () => { .filter((unit) => unit.collection_category_id !== null && unit.collection_unit_id !== null) .map((unit) => ({ critter_collection_unit_id: unit.critter_collection_unit_id, - critter_id: critter.critter_id, + critter_id: critter.critterbase_critter_id, collection_category_id: unit.collection_category_id as string, collection_unit_id: unit.collection_unit_id as string })), ...collectionsForDelete.map((collection) => ({ ...collection, - critter_id: critter.critter_id, + critter_id: critter.critterbase_critter_id, _delete: true })) ] @@ -150,8 +150,8 @@ export const EditAnimalPage = () => { } // Refresh the context, so the next page loads with the latest data - surveyContext.critterDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); - animalPageContext.critterDataLoader.refresh(critter.critter_id); + surveyContext.critterDataLoader.refresh(projectId, surveyId); + animalPageContext.critterDataLoader.refresh(projectId, surveyId, critter.critter_id); history.push(`/admin/projects/${projectId}/surveys/${surveyId}/animals`, SKIP_CONFIRMATION_DIALOG); } catch (error) { @@ -216,8 +216,9 @@ export const EditAnimalPage = () => { { tsn: critter.itis_tsn, scientificName: critter.itis_scientific_name }, - ecological_units: critter.collection_units.map((unit) => ({ ...unit, critter_id: critter.critter_id })), + ecological_units: critter.collection_units.map((unit) => ({ + ...unit, + critter_id: critter.critterbase_critter_id + })), wildlife_health_id: critter.wlh_id, critter_comment: critter.critter_comment }} diff --git a/app/src/features/surveys/animals/components/ScientificNameTypography.tsx b/app/src/features/surveys/animals/components/ScientificNameTypography.tsx index 405e137848..f4d0f9d851 100644 --- a/app/src/features/surveys/animals/components/ScientificNameTypography.tsx +++ b/app/src/features/surveys/animals/components/ScientificNameTypography.tsx @@ -16,7 +16,7 @@ export const ScientificNameTypography = (props: IScientificNameTypographyProps) if (terms.length > 1) { return ( - + {props.name} ); diff --git a/app/src/features/surveys/animals/list/AnimalListContainer.tsx b/app/src/features/surveys/animals/list/AnimalListContainer.tsx index 1d1accf6a1..a65ae523c8 100644 --- a/app/src/features/surveys/animals/list/AnimalListContainer.tsx +++ b/app/src/features/surveys/animals/list/AnimalListContainer.tsx @@ -113,7 +113,7 @@ export const AnimalListContainer = () => { dialogContext.setYesNoDialog({ open: false }); // If the selected animal is the deleted animal, unset the selected animal - if (checkboxSelectedIds.some((id) => id == selectedAnimal?.survey_critter_id)) { + if (checkboxSelectedIds.some((id) => id == selectedAnimal?.critter_id)) { setSelectedAnimal(); } @@ -164,11 +164,11 @@ export const AnimalListContainer = () => { }, open: true, onYes: () => { - if (selectedCritterMenu?.survey_critter_id) { - handleDeleteCritter(selectedCritterMenu?.survey_critter_id); + if (selectedCritterMenu?.critter_id) { + handleDeleteCritter(selectedCritterMenu?.critter_id); } // If the selected animal is the deleted animal, unset the selected animal - if (selectedCritterMenu?.survey_critter_id == selectedAnimal?.survey_critter_id) { + if (selectedCritterMenu?.critter_id == selectedAnimal?.critter_id) { setSelectedAnimal(); } } @@ -234,7 +234,7 @@ export const AnimalListContainer = () => { } }}> { setSelectedAnimal(selectedCritterMenu); }}> @@ -323,7 +323,7 @@ export const AnimalListContainer = () => { return; } - const critterIds = critters.map((critter) => critter.survey_critter_id); + const critterIds = critters.map((critter) => critter.critter_id); setCheckboxSelectedIds(critterIds); }} inputProps={{ 'aria-label': 'controlled' }} @@ -355,7 +355,7 @@ export const AnimalListContainer = () => { )} {critters.map((critter) => ( { }}> { edge="end" onClick={(event: React.MouseEvent) => handleCritterMenuClick(event, { - critterbase_critter_id: critter.critter_id, - survey_critter_id: critter.survey_critter_id + critterbase_critter_id: critter.critterbase_critter_id, + critter_id: critter.critter_id }) } aria-label="animal-settings"> diff --git a/app/src/features/surveys/animals/list/components/CritterListItem.tsx b/app/src/features/surveys/animals/list/components/CritterListItem.tsx index 459e0f2681..297c4c547b 100644 --- a/app/src/features/surveys/animals/list/components/CritterListItem.tsx +++ b/app/src/features/surveys/animals/list/components/CritterListItem.tsx @@ -5,12 +5,12 @@ import IconButton from '@mui/material/IconButton'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import { useAnimalPageContext, useSurveyContext } from 'hooks/useContext'; -import { ISimpleCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; +import { ICritterSimpleResponse } from 'interfaces/useCritterApi.interface'; import { useEffect } from 'react'; import { ScientificNameTypography } from '../../components/ScientificNameTypography'; interface ICritterListItemProps { - critter: ISimpleCritterWithInternalId; + critter: ICritterSimpleResponse; isChecked: boolean; handleCheckboxChange: (surveyCritterId: number) => void; } @@ -45,10 +45,10 @@ export const CritterListItem = (props: ICritterListItemProps) => { }}> { - if (critter.survey_critter_id !== selectedAnimal?.survey_critter_id) + if (critter.critter_id !== selectedAnimal?.critter_id) setSelectedAnimal({ - survey_critter_id: critter.survey_critter_id, - critterbase_critter_id: critter.critter_id + critter_id: critter.critter_id, + critterbase_critter_id: critter.critterbase_critter_id }); }} sx={{ @@ -63,7 +63,7 @@ export const CritterListItem = (props: ICritterListItemProps) => { '& .MuiTypography-root': { color: 'text.primary' }, - bgcolor: selectedAnimal?.survey_critter_id === critter.survey_critter_id ? grey[100] : undefined + bgcolor: selectedAnimal?.critter_id === critter.critter_id ? grey[100] : undefined }}> { checked={isChecked} onClick={(event) => { event.stopPropagation(); - handleCheckboxChange(critter.survey_critter_id); + handleCheckboxChange(critter.critter_id); }} inputProps={{ 'aria-label': 'controlled' }} /> diff --git a/app/src/features/surveys/animals/profile/captures/AnimalCaptureContainer.tsx b/app/src/features/surveys/animals/profile/captures/AnimalCaptureContainer.tsx index aed46f7446..5e9f2d0c0a 100644 --- a/app/src/features/surveys/animals/profile/captures/AnimalCaptureContainer.tsx +++ b/app/src/features/surveys/animals/profile/captures/AnimalCaptureContainer.tsx @@ -72,7 +72,7 @@ export const AnimalCaptureContainer = () => { } })) || []; - const handleDelete = async (selectedCapture: string, critterbase_critter_id: string) => { + const handleDelete = async (selectedCapture: string, critter_id: number) => { // Delete markings and measurements associated with the capture to avoid foreign key constraint error await critterbaseApi.critters.bulkUpdate({ markings: data?.markings @@ -102,7 +102,7 @@ export const AnimalCaptureContainer = () => { await critterbaseApi.capture.deleteCapture(selectedCapture); // Refresh capture container - animalPageContext.critterDataLoader.refresh(critterbase_critter_id); + animalPageContext.critterDataLoader.refresh(projectId, surveyId, critter_id); }; const capturesWithLocation = captures.filter((capture) => capture.capture_location); @@ -113,7 +113,7 @@ export const AnimalCaptureContainer = () => { capturesCount={captures.length} onAddAnimalCapture={() => { history.push( - `/admin/projects/${projectId}/surveys/${surveyId}/animals/${selectedAnimal.survey_critter_id}/capture/create` + `/admin/projects/${projectId}/surveys/${surveyId}/animals/${selectedAnimal.critter_id}/capture/create` ); }} /> diff --git a/app/src/features/surveys/animals/profile/captures/capture-form/components/location/CaptureLocationMapControl.tsx b/app/src/features/surveys/animals/profile/captures/capture-form/components/location/CaptureLocationMapControl.tsx index fb775a470a..6520df756d 100644 --- a/app/src/features/surveys/animals/profile/captures/capture-form/components/location/CaptureLocationMapControl.tsx +++ b/app/src/features/surveys/animals/profile/captures/capture-form/components/location/CaptureLocationMapControl.tsx @@ -29,6 +29,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { FeatureGroup, LayersControl, MapContainer as LeafletMapContainer } from 'react-leaflet'; import { calculateUpdatedMapBounds } from 'utils/mapBoundaryUploadHelpers'; import { getCoordinatesFromGeoJson, isGeoJsonPointFeature, isValidCoordinates } from 'utils/spatial-utils'; +import { v4 as uuid } from 'uuid'; export interface ICaptureLocationMapControlProps { name: string; @@ -277,7 +278,15 @@ export const CaptureLocationMapControl = diff --git a/app/src/features/surveys/animals/profile/captures/capture-form/create/CreateCapturePage.tsx b/app/src/features/surveys/animals/profile/captures/capture-form/create/CreateCapturePage.tsx index 46c26a7d6c..903c9154ac 100644 --- a/app/src/features/surveys/animals/profile/captures/capture-form/create/CreateCapturePage.tsx +++ b/app/src/features/surveys/animals/profile/captures/capture-form/create/CreateCapturePage.tsx @@ -58,7 +58,7 @@ export const CreateCapturePage = () => { const animalPageContext = useAnimalPageContext(); const urlParams: Record = useParams(); - const surveyCritterId: number | undefined = Number(urlParams['survey_critter_id']); + const surveyCritterId: number | undefined = Number(urlParams['critter_id']); const { locationChangeInterceptor } = useUnsavedChangesDialog(); @@ -106,6 +106,7 @@ export const CreateCapturePage = () => { setIsSaving(true); try { + const surveyCritterId = animalPageContext.selectedAnimal?.critter_id; const critterbaseCritterId = animalPageContext.selectedAnimal?.critterbase_critter_id; if (!values || !critterbaseCritterId || values.capture.capture_location?.geometry.type !== 'Point') { @@ -190,7 +191,10 @@ export const CreateCapturePage = () => { } // Refresh page - animalPageContext.critterDataLoader.refresh(critterbaseCritterId); + + if (surveyCritterId) { + animalPageContext.critterDataLoader.refresh(projectId, surveyId, surveyCritterId); + } history.push(`/admin/projects/${projectId}/surveys/${surveyId}/animals/details`, SKIP_CONFIRMATION_DIALOG); } catch (error) { diff --git a/app/src/features/surveys/animals/profile/captures/capture-form/edit/EditCapturePage.tsx b/app/src/features/surveys/animals/profile/captures/capture-form/edit/EditCapturePage.tsx index bb78fc28a1..688e0dcf9a 100644 --- a/app/src/features/surveys/animals/profile/captures/capture-form/edit/EditCapturePage.tsx +++ b/app/src/features/surveys/animals/profile/captures/capture-form/edit/EditCapturePage.tsx @@ -40,7 +40,7 @@ export const EditCapturePage = () => { const urlParams: Record = useParams(); - const surveyCritterId: number | undefined = Number(urlParams['survey_critter_id']); + const surveyCritterId: number | undefined = Number(urlParams['critter_id']); const captureId: string | undefined = String(urlParams['capture_id']); const { locationChangeInterceptor } = useUnsavedChangesDialog(); @@ -164,7 +164,9 @@ export const EditCapturePage = () => { } // Refresh page - animalPageContext.critterDataLoader.refresh(critterbaseCritterId); + if (surveyCritterId) { + animalPageContext.critterDataLoader.refresh(projectId, surveyId, surveyCritterId); + } history.push(`/admin/projects/${projectId}/surveys/${surveyId}/animals/details`, SKIP_CONFIRMATION_DIALOG); } catch (error) { diff --git a/app/src/features/surveys/animals/profile/captures/capture-form/edit/utils.ts b/app/src/features/surveys/animals/profile/captures/capture-form/edit/utils.ts index 57ed035d9f..99fd7f27f3 100644 --- a/app/src/features/surveys/animals/profile/captures/capture-form/edit/utils.ts +++ b/app/src/features/surveys/animals/profile/captures/capture-form/edit/utils.ts @@ -101,7 +101,7 @@ export const formatCritterDetailsForBulkUpdate = ( !markings.some((incomingMarking) => incomingMarking.marking_id === existingmarkingsOnCapture.marking_id) ) // The remaining markings are the ones to delete from the critter for the current capture - .map((item) => ({ ...item, critter_id: critter.critter_id, _delete: true })); + .map((item) => ({ ...item, critter_id: critter.critterbase_critter_id, _delete: true })); // Find markings for create const markingsForCreate = markings @@ -110,7 +110,7 @@ export const formatCritterDetailsForBulkUpdate = ( .map((marking) => ({ ...marking, marking_id: marking.marking_id, - critter_id: critter.critter_id, + critter_id: critter.critterbase_critter_id, capture_id: captureId })); @@ -121,7 +121,7 @@ export const formatCritterDetailsForBulkUpdate = ( .map((marking) => ({ ...marking, marking_id: marking.marking_id, - critter_id: critter.critter_id, + critter_id: critter.critterbase_critter_id, capture_id: captureId })); @@ -129,7 +129,7 @@ export const formatCritterDetailsForBulkUpdate = ( const qualitativeMeasurementsForCreate = measurements .filter(isQualitativeMeasurementCreate) .map((measurement: IQualitativeMeasurementCreate) => ({ - critter_id: critter.critter_id, + critter_id: critter.critterbase_critter_id, capture_id: captureId, taxon_measurement_id: measurement.taxon_measurement_id, qualitative_option_id: measurement.qualitative_option_id, @@ -141,7 +141,7 @@ export const formatCritterDetailsForBulkUpdate = ( const quantitativeMeasurementsForCreate = measurements .filter(isQuantitativeMeasurementCreate) .map((measurement: IQuantitativeMeasurementCreate) => ({ - critter_id: critter.critter_id, + critter_id: critter.critterbase_critter_id, capture_id: captureId, taxon_measurement_id: measurement.taxon_measurement_id, value: measurement.value, @@ -153,7 +153,7 @@ export const formatCritterDetailsForBulkUpdate = ( const qualitativeMeasurementsForUpdate = measurements .filter(isQualitativeMeasurementUpdate) .map((measurement: IQualitativeMeasurementUpdate) => ({ - // critter_id: critter.critter_id, + // critter_id: critter.critterbase_critter_id, capture_id: captureId, measurement_qualitative_id: measurement.measurement_qualitative_id, taxon_measurement_id: measurement.taxon_measurement_id, @@ -166,7 +166,7 @@ export const formatCritterDetailsForBulkUpdate = ( const quantitativeMeasurementsForUpdate = measurements .filter(isQuantitativeMeasurementUpdate) .map((measurement: IQuantitativeMeasurementUpdate) => ({ - critter_id: critter.critter_id, + critter_id: critter.critterbase_critter_id, measurement_quantitative_id: measurement.measurement_quantitative_id, capture_id: captureId, taxon_measurement_id: measurement.taxon_measurement_id, diff --git a/app/src/features/surveys/animals/profile/captures/components/AnimalCaptureCardContainer.tsx b/app/src/features/surveys/animals/profile/captures/components/AnimalCaptureCardContainer.tsx index 5843757a69..1d679c018b 100644 --- a/app/src/features/surveys/animals/profile/captures/components/AnimalCaptureCardContainer.tsx +++ b/app/src/features/surveys/animals/profile/captures/components/AnimalCaptureCardContainer.tsx @@ -27,7 +27,7 @@ import { AnimalCaptureCardDetailsContainer } from './capture-card-details/Animal interface IAnimalCaptureCardContainer { captures: ICaptureWithSupplementaryData[]; selectedAnimal: ISurveyCritter; - handleDelete: (selectedCapture: string, critterbase_critter_id: string) => Promise; + handleDelete: (selectedCapture: string, critter_id: number) => Promise; } /** * Returns accordion cards for displaying animal capture details on the animal profile page @@ -77,7 +77,7 @@ export const AnimalCaptureCardContainer = (props: IAnimalCaptureCardContainer) = } }}> + to={`/admin/projects/${projectId}/surveys/${surveyId}/animals/${selectedAnimal.critterbase_critter_id}/capture/${selectedCapture}/edit`}> @@ -111,7 +111,7 @@ export const AnimalCaptureCardContainer = (props: IAnimalCaptureCardContainer) = open={Boolean(captureForDelete)} onYes={() => { setCaptureForDelete(false); - handleDelete(selectedCapture, selectedAnimal.critterbase_critter_id); + handleDelete(selectedCapture, selectedAnimal.critter_id); }} onClose={() => setCaptureForDelete(false)} onNo={() => setCaptureForDelete(false)} diff --git a/app/src/features/surveys/animals/profile/captures/components/AnimalCapturesMap.tsx b/app/src/features/surveys/animals/profile/captures/components/AnimalCapturesMap.tsx index 602774f9fc..df194fc722 100644 --- a/app/src/features/surveys/animals/profile/captures/components/AnimalCapturesMap.tsx +++ b/app/src/features/surveys/animals/profile/captures/components/AnimalCapturesMap.tsx @@ -20,7 +20,7 @@ export const AnimalCapturesMap = (props: IAnimalCapturesMapProps) => { const { captures, isLoading } = props; // Only include captures with valid locations - const captureMapFeatures: Feature[] = []; + const captureMapFeatures = []; for (const capture of captures) { if ( @@ -28,7 +28,8 @@ export const AnimalCapturesMap = (props: IAnimalCapturesMapProps) => { isDefined(capture.capture_location.latitude) && isDefined(capture.capture_location.longitude) ) { - const feature: Feature = { + const feature = { + id: capture.capture_id, type: 'Feature', geometry: { type: 'Point', @@ -45,15 +46,16 @@ export const AnimalCapturesMap = (props: IAnimalCapturesMapProps) => { popupRecordTitle: 'Capture Location', features: [ { - key: `${feature.geometry}-${index}`, - geoJSON: feature + id: feature.id, + key: `capture-location-${feature.geometry}-${index}`, + geoJSON: feature as Feature } ] })); return ( - + ); }; diff --git a/app/src/features/surveys/animals/profile/details/components/AnimalProfileHeader.tsx b/app/src/features/surveys/animals/profile/details/components/AnimalProfileHeader.tsx index 4bc257ae98..91b87833b3 100644 --- a/app/src/features/surveys/animals/profile/details/components/AnimalProfileHeader.tsx +++ b/app/src/features/surveys/animals/profile/details/components/AnimalProfileHeader.tsx @@ -71,15 +71,15 @@ export const AnimalProfileHeader = (props: IAnimalProfileHeaderProps) => { Unique ID:  - {critter.critter_id} + {critter.critterbase_critter_id} { - if (!critter.critter_id) { + if (!critter.critterbase_critter_id) { return; } - copyToClipboard(critter.critter_id, () => + copyToClipboard(critter.critterbase_critter_id, () => setMessageSnackbar('Unique ID copied to clipboard', dialogContext) ).catch((error) => { console.error('Could not copy text: ', error); diff --git a/app/src/features/surveys/animals/profile/mortality/AnimalMortalityContainer.tsx b/app/src/features/surveys/animals/profile/mortality/AnimalMortalityContainer.tsx index 8a84c19faf..ac70ae4e8d 100644 --- a/app/src/features/surveys/animals/profile/mortality/AnimalMortalityContainer.tsx +++ b/app/src/features/surveys/animals/profile/mortality/AnimalMortalityContainer.tsx @@ -66,7 +66,7 @@ const AnimalMortalityContainer = () => { } })) || []; - const handleDelete = async (selectedMortality: string, critterbase_critter_id: string) => { + const handleDelete = async (selectedMortality: string, critterId: number) => { // Delete markings and measurements associated with the mortality to avoid foreign key constraint error await critterbaseApi.critters.bulkUpdate({ markings: data?.markings @@ -96,7 +96,9 @@ const AnimalMortalityContainer = () => { await critterbaseApi.mortality.deleteMortality(selectedMortality); // Refresh mortality container - animalPageContext.critterDataLoader.refresh(critterbase_critter_id); + if (critterId) { + animalPageContext.critterDataLoader.refresh(projectId, surveyId, critterId); + } }; return ( @@ -105,7 +107,7 @@ const AnimalMortalityContainer = () => { mortalityCount={mortality.length} onAddAnimalMortality={() => { history.push( - `/admin/projects/${projectId}/surveys/${surveyId}/animals/${selectedAnimal.survey_critter_id}/mortality/create` + `/admin/projects/${projectId}/surveys/${surveyId}/animals/${selectedAnimal.critter_id}/mortality/create` ); }} /> diff --git a/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityCardContainer.tsx b/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityCardContainer.tsx index d011db6e25..a5a8d1e6ad 100644 --- a/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityCardContainer.tsx +++ b/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityCardContainer.tsx @@ -26,7 +26,7 @@ import { getFormattedDate } from 'utils/Utils'; interface IAnimalMortalityCardContainer { mortality: IMortalityWithSupplementaryData[]; selectedAnimal: ISurveyCritter; - handleDelete: (selectedMortality: string, critterbase_critter_id: string) => Promise; + handleDelete: (selectedMortality: string, critterId: number) => Promise; } /** * Returns accordion cards for displaying animal mortality details on the animal profile page @@ -76,7 +76,7 @@ export const AnimalMortalityCardContainer = (props: IAnimalMortalityCardContaine } }}> + to={`/admin/projects/${projectId}/surveys/${surveyId}/animals/${selectedAnimal.critterbase_critter_id}/mortality/${selectedMortality}/edit`}> @@ -108,7 +108,7 @@ export const AnimalMortalityCardContainer = (props: IAnimalMortalityCardContaine open={Boolean(mortalityForDelete)} onYes={() => { setMortalityForDelete(false); - handleDelete(selectedMortality, selectedAnimal.critterbase_critter_id); + handleDelete(selectedMortality, selectedAnimal.critter_id); }} onClose={() => setMortalityForDelete(false)} onNo={() => setMortalityForDelete(false)} diff --git a/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityMap.tsx b/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityMap.tsx index 04deba620f..902279e06c 100644 --- a/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityMap.tsx +++ b/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityMap.tsx @@ -22,28 +22,30 @@ export const AnimalMortalityMap = (props: IAnimalMortalityMapProps) => { const mortalityMapFeatures = mortality .filter((mortality) => isDefined(mortality.location?.latitude) && isDefined(mortality.location?.longitude)) .map((mortality) => ({ + id: mortality.mortality_id, type: 'Feature', geometry: { type: 'Point', coordinates: [mortality.location?.longitude, mortality.location?.latitude] }, properties: { mortalityId: mortality.mortality_id, date: mortality.mortality_timestamp } - })) as Feature[]; + })); const staticLayers: IStaticLayer[] = mortalityMapFeatures.map((feature, index) => ({ layerName: 'Mortality', popupRecordTitle: 'Capture Location', features: [ { - key: `${feature.geometry}-${index}`, - geoJSON: feature + id: feature.id, + key: `mortality-location-${feature.geometry}-${index}`, + geoJSON: feature as Feature } ] })); return ( - + ); }; diff --git a/app/src/features/surveys/animals/profile/mortality/mortality-form/components/location/MortalityLocationMapControl.tsx b/app/src/features/surveys/animals/profile/mortality/mortality-form/components/location/MortalityLocationMapControl.tsx index c62570a36d..cc8e56b281 100644 --- a/app/src/features/surveys/animals/profile/mortality/mortality-form/components/location/MortalityLocationMapControl.tsx +++ b/app/src/features/surveys/animals/profile/mortality/mortality-form/components/location/MortalityLocationMapControl.tsx @@ -29,6 +29,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { FeatureGroup, LayersControl, MapContainer as LeafletMapContainer } from 'react-leaflet'; import { calculateUpdatedMapBounds } from 'utils/mapBoundaryUploadHelpers'; import { getCoordinatesFromGeoJson, isGeoJsonPointFeature, isValidCoordinates } from 'utils/spatial-utils'; +import { v4 as uuid } from 'uuid'; export interface IMortalityLocationMapControlProps { name: string; @@ -274,7 +275,15 @@ export const MortalityLocationMapControl = diff --git a/app/src/features/surveys/animals/profile/mortality/mortality-form/create/CreateMortalityPage.tsx b/app/src/features/surveys/animals/profile/mortality/mortality-form/create/CreateMortalityPage.tsx index 1891e96882..c854ac04f4 100644 --- a/app/src/features/surveys/animals/profile/mortality/mortality-form/create/CreateMortalityPage.tsx +++ b/app/src/features/surveys/animals/profile/mortality/mortality-form/create/CreateMortalityPage.tsx @@ -62,7 +62,7 @@ export const CreateMortalityPage = () => { const animalPageContext = useAnimalPageContext(); const urlParams: Record = useParams(); - const surveyCritterId: number | undefined = Number(urlParams['survey_critter_id']); + const surveyCritterId: number | undefined = Number(urlParams['critter_id']); const { locationChangeInterceptor } = useUnsavedChangesDialog(); @@ -182,7 +182,7 @@ export const CreateMortalityPage = () => { } // Refresh page - animalPageContext.critterDataLoader.refresh(critterbaseCritterId); + animalPageContext.critterDataLoader.refresh(projectId, surveyId, surveyCritterId); history.push(`/admin/projects/${projectId}/surveys/${surveyId}/animals/details`, SKIP_CONFIRMATION_DIALOG); } catch (error) { diff --git a/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/EditMortalityPage.tsx b/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/EditMortalityPage.tsx index bc4b6e567d..737235ba72 100644 --- a/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/EditMortalityPage.tsx +++ b/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/EditMortalityPage.tsx @@ -41,7 +41,7 @@ export const EditMortalityPage = () => { const urlParams: Record = useParams(); - const surveyCritterId: number | undefined = Number(urlParams['survey_critter_id']); + const surveyCritterId: number | undefined = Number(urlParams['critter_id']); const mortalityId: string | undefined = String(urlParams['mortality_id']); const { locationChangeInterceptor } = useUnsavedChangesDialog(); @@ -171,7 +171,7 @@ export const EditMortalityPage = () => { } // Refresh page - animalPageContext.critterDataLoader.refresh(critterbaseCritterId); + if (surveyCritterId) animalPageContext.critterDataLoader.refresh(projectId, surveyId, surveyCritterId); history.push(`/admin/projects/${projectId}/surveys/${surveyId}/animals/details`, SKIP_CONFIRMATION_DIALOG); } catch (error) { diff --git a/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/utils.ts b/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/utils.ts index ab16e4174d..2c43dcbc7e 100644 --- a/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/utils.ts +++ b/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/utils.ts @@ -105,7 +105,7 @@ export const formatCritterDetailsForBulkUpdate = ( !markings.some((incomingMarking) => incomingMarking.marking_id === existingmarkingsOnMortality.marking_id) ) // The remaining markings are the ones to delete from the critter for the current mortality - .map((item) => ({ ...item, critter_id: critter.critter_id, _delete: true })); + .map((item) => ({ ...item, critter_id: critter.critterbase_critter_id, _delete: true })); // Find markings for create const markingsForCreate = markings @@ -114,7 +114,7 @@ export const formatCritterDetailsForBulkUpdate = ( .map((marking) => ({ ...marking, marking_id: marking.marking_id, - critter_id: critter.critter_id, + critter_id: critter.critterbase_critter_id, mortality_id: mortalityId })); @@ -125,13 +125,13 @@ export const formatCritterDetailsForBulkUpdate = ( .map((marking) => ({ ...marking, marking_id: marking.marking_id, - critter_id: critter.critter_id, + critter_id: critter.critterbase_critter_id, mortality_id: mortalityId })); // Find qualitative measurements for create const qualitativeMeasurementsForCreate = measurements.filter(isQualitativeMeasurementCreate).map((measurement) => ({ - critter_id: critter.critter_id, + critter_id: critter.critterbase_critter_id, mortality_id: mortalityId, taxon_measurement_id: measurement.taxon_measurement_id, qualitative_option_id: measurement.qualitative_option_id, @@ -141,7 +141,7 @@ export const formatCritterDetailsForBulkUpdate = ( // Find quantitative measurements for create const quantitativeMeasurementsForCreate = measurements.filter(isQuantitativeMeasurementCreate).map((measurement) => ({ - critter_id: critter.critter_id, + critter_id: critter.critterbase_critter_id, mortality_id: mortalityId, taxon_measurement_id: measurement.taxon_measurement_id, value: measurement.value, @@ -151,7 +151,7 @@ export const formatCritterDetailsForBulkUpdate = ( // Find qualitative measurements for update const qualitativeMeasurementsForUpdate = measurements.filter(isQualitativeMeasurementUpdate).map((measurement) => ({ - critter_id: critter.critter_id, + critter_id: critter.critterbase_critter_id, mortality_id: mortalityId, measurement_qualitative_id: measurement.measurement_qualitative_id, taxon_measurement_id: measurement.taxon_measurement_id, @@ -162,7 +162,7 @@ export const formatCritterDetailsForBulkUpdate = ( // Find quantitative measurements for update const quantitativeMeasurementsForUpdate = measurements.filter(isQuantitativeMeasurementUpdate).map((measurement) => ({ - critter_id: critter.critter_id, + critter_id: critter.critterbase_critter_id, mortality_id: mortalityId, measurement_quantitative_id: measurement.measurement_quantitative_id, taxon_measurement_id: measurement.taxon_measurement_id, diff --git a/app/src/features/surveys/components/SurveyProgressChip.tsx b/app/src/features/surveys/components/SurveyProgressChip.tsx index f39a5dea6b..d68e0e940a 100644 --- a/app/src/features/surveys/components/SurveyProgressChip.tsx +++ b/app/src/features/surveys/components/SurveyProgressChip.tsx @@ -2,6 +2,7 @@ import { ChipProps } from '@mui/material'; import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { getSurveyProgressColour, SurveyProgressKeys } from 'constants/colours'; import { useCodesContext } from 'hooks/useContext'; +import { useEffect } from 'react'; import { getCodesName } from 'utils/Utils'; interface ISurveyProgressChipProps extends ChipProps { @@ -17,6 +18,10 @@ interface ISurveyProgressChipProps extends ChipProps { export const SurveyProgressChip = (props: ISurveyProgressChipProps) => { const codesContext = useCodesContext(); + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext.codesDataLoader]); + const codeName = getCodesName(codesContext.codesDataLoader.data, 'survey_progress', props.progress_id) ?? ''; return ( diff --git a/app/src/features/surveys/components/agreements/ProprietaryDataForm.tsx b/app/src/features/surveys/components/agreements/ProprietaryDataForm.tsx index 933fe8af56..72ec4ad53f 100644 --- a/app/src/features/surveys/components/agreements/ProprietaryDataForm.tsx +++ b/app/src/features/surveys/components/agreements/ProprietaryDataForm.tsx @@ -1,6 +1,5 @@ import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; import FormControl from '@mui/material/FormControl'; import FormControlLabel from '@mui/material/FormControlLabel'; import FormHelperText from '@mui/material/FormHelperText'; @@ -17,25 +16,6 @@ import React, { useState } from 'react'; import { StringBoolean } from 'types/misc'; import yup from 'utils/YupSchema'; -const useStyles = () => { - return { - alignCenter: { - display: 'flex', - alignItems: 'center' - }, - learnMoreBtn: { - textDecoration: 'underline', - lineHeight: 'auto', - '&:hover': { - textDecoration: 'underline' - } - }, - dialogText: { - maxWidth: '72ch' - } - }; -}; - export interface IProprietaryDataForm { proprietor: { survey_data_proprietary: StringBoolean; @@ -95,7 +75,6 @@ export interface IProprietaryDataFormProps { */ const ProprietaryDataForm: React.FC = (props) => { const [openDialog, setOpenDialog] = useState(false); - const classes = useStyles(); const { values, touched, errors, handleChange, setFieldValue, setFieldTouched, setFieldError } = useFormikContext(); @@ -122,16 +101,6 @@ const ProprietaryDataForm: React.FC = (props) => { error={ touched.proprietor?.survey_data_proprietary && Boolean(errors.proprietor?.survey_data_proprietary) }> - - Is the data captured in this survey proprietary? - - = (props) => { } handleChange(event); }}> - } label="No" /> - } label="Yes" /> + } label="No" /> + } label="Yes" /> {errors.proprietor?.survey_data_proprietary} diff --git a/app/src/features/surveys/components/funding/SurveyFundingSourceForm.tsx b/app/src/features/surveys/components/funding/SurveyFundingSourceForm.tsx index 7909568607..a05fbd82df 100644 --- a/app/src/features/surveys/components/funding/SurveyFundingSourceForm.tsx +++ b/app/src/features/surveys/components/funding/SurveyFundingSourceForm.tsx @@ -5,15 +5,21 @@ import Button from '@mui/material/Button'; import Card from '@mui/material/Card'; import Collapse from '@mui/material/Collapse'; import grey from '@mui/material/colors/grey'; +import FormControlLabel from '@mui/material/FormControlLabel'; import IconButton from '@mui/material/IconButton'; +import Radio from '@mui/material/Radio'; +import RadioGroup from '@mui/material/RadioGroup'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; +import AlertBar from 'components/alert/AlertBar'; import AutocompleteField from 'components/fields/AutocompleteField'; import DollarAmountField from 'components/fields/DollarAmountField'; import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader from 'hooks/useDataLoader'; import { IEditSurveyRequest } from 'interfaces/useSurveyApi.interface'; +import get from 'lodash-es/get'; +import { useEffect } from 'react'; import { TransitionGroup } from 'react-transition-group'; import yup from 'utils/YupSchema'; @@ -21,15 +27,16 @@ export interface ISurveyFundingSource { funding_source_id: number; amount: number; revision_count: number; - survey_funding_source_id?: number; + survey_funding_source_id?: number | null; survey_id: number; funding_source_name?: string; - start_date?: string; - end_date?: string; - description?: string; + start_date?: string | null; + end_date?: string | null; + description?: string | null; } export interface ISurveyFundingSourceForm { + funding_used: boolean | null; funding_sources: ISurveyFundingSource[]; } @@ -42,10 +49,15 @@ const SurveyFundingSourceInitialValues: ISurveyFundingSource = { }; export const SurveyFundingSourceFormInitialValues: ISurveyFundingSourceForm = { + funding_used: null, funding_sources: [] }; export const SurveyFundingSourceFormYupSchema = yup.object().shape({ + funding_used: yup + .boolean() + .nullable() + .required('You must indicate whether a funding source requires this survey to be submitted'), funding_sources: yup.array( yup.object().shape({ funding_source_id: yup @@ -82,7 +94,16 @@ export const SurveyFundingSourceFormYupSchema = yup.object().shape({ */ const SurveyFundingSourceForm = () => { const formikProps = useFormikContext(); - const { values, handleChange, handleSubmit, errors } = formikProps; + const { values, handleChange, handleSubmit, errors, setFieldValue, submitCount, setFieldError } = formikProps; + + // Determine value of funding_used based on whether funding_sources exist + useEffect(() => { + if (values.funding_sources.length > 0) { + setFieldValue('funding_used', values.funding_used); + } else if (!values.funding_sources.length) { + setFieldValue('funding_used', values.funding_used); + } + }, [setFieldValue, values.funding_sources, values.funding_used]); const biohubApi = useBiohubApi(); const fundingSourcesDataLoader = useDataLoader(() => biohubApi.funding.getAllFundingSources()); @@ -90,12 +111,52 @@ const SurveyFundingSourceForm = () => { const fundingSources = fundingSourcesDataLoader.data ?? []; + // Determine the radio button value + const getFundingUsedValue = () => { + if (values.funding_used === true) { + return 'true'; + } + if (values.funding_used === false) { + return 'false'; + } + return null; + }; + return ( ( + {get(errors, 'funding_used') && submitCount > 0 && ( + + )} + { + const value = event.target.value === 'true' ? true : false; + setFieldValue('funding_used', value); + if (value) { + arrayHelpers.push(SurveyFundingSourceInitialValues); + } else { + setFieldValue('funding_sources', []); + } + setFieldError('funding_used', undefined); + }}> + } label="Yes" /> + } label="No" /> + + { gap={2} sx={{ width: '100%', + mt: 1, p: 2, backgroundColor: grey[100] }}> @@ -162,19 +224,21 @@ const SurveyFundingSourceForm = () => { {errors.funding_sources} )} - + {values.funding_used && ( + + )} )} /> diff --git a/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx b/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx index 0dedfb5b46..5156d7680a 100644 --- a/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx +++ b/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx @@ -1,18 +1,11 @@ -import Box from '@mui/material/Box'; import Grid from '@mui/material/Grid'; -import Typography from '@mui/material/Typography'; +import AutocompleteField from 'components/fields/AutocompleteField'; import CustomTextField from 'components/fields/CustomTextField'; -import { IMultiAutocompleteFieldOption } from 'components/fields/MultiAutocompleteField'; -import MultiAutocompleteFieldVariableSize from 'components/fields/MultiAutocompleteFieldVariableSize'; -import SelectWithSubtextField, { ISelectWithSubtextFieldOption } from 'components/fields/SelectWithSubtext'; +import { ISelectWithSubtextFieldOption } from 'components/fields/SelectWithSubtext'; import StartEndDateFields from 'components/fields/StartEndDateFields'; -import AncillarySpeciesComponent from 'components/species/AncillarySpeciesComponent'; -import FocalSpeciesComponent from 'components/species/FocalSpeciesComponent'; -import { useFormikContext } from 'formik'; -import { ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; import React from 'react'; import yup from 'utils/YupSchema'; -import SurveyPermitForm, { SurveyPermitFormYupSchema } from '../../SurveyPermitForm'; +import { SurveyPermitFormYupSchema } from '../permit/SurveyPermitForm'; export const AddPermitFormInitialValues = { permits: [ @@ -37,14 +30,10 @@ export interface IGeneralInformationForm { survey_name: string; start_date: string; end_date: string; - progress_id: number | null; + progress_id: number; survey_types: number[]; revision_count: number; }; - species: { - focal_species: ITaxonomy[]; - ancillary_species: ITaxonomy[]; - }; permit: { permits: { permit_id?: number; @@ -59,14 +48,10 @@ export const GeneralInformationInitialValues: IGeneralInformationForm = { survey_name: '', start_date: '', end_date: '', - progress_id: null, + progress_id: null as unknown as number, survey_types: [], revision_count: 0 }, - species: { - focal_species: [], - ancillary_species: [] - }, permit: { permits: [] } @@ -80,26 +65,21 @@ export const GeneralInformationYupSchema = () => { survey_name: yup.string().required('Survey Name is Required'), start_date: yup.string().isValidDateString().required('Start Date is Required'), end_date: yup.string().nullable().isValidDateString(), - survey_types: yup - .array(yup.number()) - .min(1, 'One or more Types are required') - .required('One or more Types are required'), progress_id: yup .number() .min(1, 'Survey Progress is Required') .required('Survey Progress is Required') - .nullable() - }), - species: yup.object().shape({ - focal_species: yup.array().min(1, 'You must specify a focal species').required('Required'), - ancillary_species: yup.array().isUniqueFocalAncillarySpecies('Focal and Ancillary species must be unique') + .nullable(), + survey_types: yup + .array(yup.number()) + .min(1, 'One or more data types are required') + .required('One or more data types are required') }) }) .concat(SurveyPermitFormYupSchema); }; export interface IGeneralInformationFormProps { - type: IMultiAutocompleteFieldOption[]; progress: ISelectWithSubtextFieldOption[]; } @@ -109,72 +89,36 @@ export interface IGeneralInformationFormProps { * @return {*} */ const GeneralInformationForm: React.FC = (props) => { - const formikProps = useFormikContext(); - return ( - <> - - - - - - - - - - - - - + + + - - - - Species - - - - - - - - - - - - - - Permits - - - - - - + + + + + + + ); }; diff --git a/app/src/features/surveys/components/locations/SurveyAreaMapControl.tsx b/app/src/features/surveys/components/locations/SurveyAreaMapControl.tsx index a5ce02fc17..b7ce0bb06a 100644 --- a/app/src/features/surveys/components/locations/SurveyAreaMapControl.tsx +++ b/app/src/features/surveys/components/locations/SurveyAreaMapControl.tsx @@ -211,7 +211,11 @@ export const SurveyAreaMapControl = (props: ISurveyAreMapControlProps) => { // Map geojson features into layer objects for leaflet return { layerName: location.name, - features: location.geojson.map((geo) => ({ geoJSON: geo, key: location.uuid ?? v4() })) + features: location.geojson.map((geo) => ({ + id: location.uuid ?? v4(), + key: `study-area-${location.uuid ?? v4()}`, + geoJSON: geo + })) }; })} /> diff --git a/app/src/features/surveys/components/methodology/PurposeAndMethodologyForm.tsx b/app/src/features/surveys/components/methodology/PurposeAndMethodologyForm.tsx index d322138e0f..17119f47d5 100644 --- a/app/src/features/surveys/components/methodology/PurposeAndMethodologyForm.tsx +++ b/app/src/features/surveys/components/methodology/PurposeAndMethodologyForm.tsx @@ -1,6 +1,5 @@ import Box from '@mui/material/Box'; import Grid from '@mui/material/Grid'; -import Typography from '@mui/material/Typography'; import CustomTextField from 'components/fields/CustomTextField'; import MultiAutocompleteField from 'components/fields/MultiAutocompleteField'; import MultiAutocompleteFieldVariableSize, { @@ -14,7 +13,6 @@ export interface IPurposeAndMethodologyForm { purpose_and_methodology: { intended_outcome_ids: number[]; additional_details: string; - vantage_code_ids: number[]; revision_count: number; }; } @@ -23,7 +21,6 @@ export const PurposeAndMethodologyInitialValues: IPurposeAndMethodologyForm = { purpose_and_methodology: { intended_outcome_ids: [], additional_details: '', - vantage_code_ids: [], revision_count: 0 } }; @@ -31,14 +28,13 @@ export const PurposeAndMethodologyInitialValues: IPurposeAndMethodologyForm = { export const PurposeAndMethodologyYupSchema = yup.object().shape({ purpose_and_methodology: yup.object().shape({ additional_details: yup.string(), - intended_outcome_ids: yup.array().min(1, 'One or more Ecological Variables are Required').required('Required'), - vantage_code_ids: yup.array().min(1, 'One or more Vantage Codes are Required').required('Required') + intended_outcome_ids: yup.array().min(1, 'One or more Ecological Variables are Required').required('Required') }) }); export interface IPurposeAndMethodologyFormProps { intended_outcomes: ISelectWithSubtextFieldOption[]; - vantage_codes: IMultiAutocompleteFieldOption[]; + type: IMultiAutocompleteFieldOption[]; } /** @@ -50,14 +46,19 @@ const PurposeAndMethodologyForm: React.FC = (pr return ( - - Purpose of Survey - + + + @@ -65,23 +66,8 @@ const PurposeAndMethodologyForm: React.FC = (pr - - - - - - Survey Methodology - - - - diff --git a/app/src/features/surveys/components/participants/SurveyUserForm.tsx b/app/src/features/surveys/components/participants/SurveyUserForm.tsx index c553276f8a..dc02dcd567 100644 --- a/app/src/features/surveys/components/participants/SurveyUserForm.tsx +++ b/app/src/features/surveys/components/participants/SurveyUserForm.tsx @@ -114,23 +114,12 @@ const SurveyUserForm = (props: ISurveyUserFormProps) => { return ( - - Add Participants - - Add particpants to this survey and assign each a role. - - {errors?.['participants'] && values.participants.length > 0 && ( )} - + { borderTop: '1px solid' + grey[300] } }} - key={renderOption.system_user_id} - {...renderProps}> + {...renderProps} + key={renderOption.system_user_id}> { it('deletes existing permits when delete icon is clicked', async () => { const existingFormValues: ISurveyPermitForm = { + permit_used: true, permit: { permits: [ { diff --git a/app/src/features/surveys/SurveyPermitForm.tsx b/app/src/features/surveys/components/permit/SurveyPermitForm.tsx similarity index 56% rename from app/src/features/surveys/SurveyPermitForm.tsx rename to app/src/features/surveys/components/permit/SurveyPermitForm.tsx index fbc90d84a5..b7a5e1a085 100644 --- a/app/src/features/surveys/SurveyPermitForm.tsx +++ b/app/src/features/surveys/components/permit/SurveyPermitForm.tsx @@ -6,68 +6,86 @@ import Card from '@mui/material/Card'; import Collapse from '@mui/material/Collapse'; import grey from '@mui/material/colors/grey'; import FormControl from '@mui/material/FormControl'; +import FormControlLabel from '@mui/material/FormControlLabel'; import FormHelperText from '@mui/material/FormHelperText'; import IconButton from '@mui/material/IconButton'; import InputLabel from '@mui/material/InputLabel'; import MenuItem from '@mui/material/MenuItem'; +import Radio from '@mui/material/Radio'; +import RadioGroup from '@mui/material/RadioGroup'; import Select from '@mui/material/Select'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; +import AlertBar from 'components/alert/AlertBar'; import CustomTextField from 'components/fields/CustomTextField'; import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; -import React from 'react'; +import { get } from 'lodash-es'; +import React, { useEffect } from 'react'; import { TransitionGroup } from 'react-transition-group'; import yup from 'utils/YupSchema'; -export interface ISurveyPermitFormArrayItem { - permit_id: number; +export interface ISurveyPermit { + permit_id?: number; permit_number: string; permit_type: string; } export interface ISurveyPermitForm { + permit_used: boolean | null; permit: { - permits: ISurveyPermitFormArrayItem[]; + permits: ISurveyPermit[]; }; } -export const SurveyPermitFormArrayItemInitialValues: ISurveyPermitFormArrayItem = { +export const SurveyPermitFormArrayItemInitialValues: ISurveyPermit = { permit_id: null as unknown as number, permit_number: '', permit_type: '' }; export const SurveyPermitFormInitialValues: ISurveyPermitForm = { + permit_used: null, permit: { permits: [] } }; export const SurveyPermitFormYupSchema = yup.object().shape({ + permit_used: yup.boolean().nullable().required('You must indicate whether a permit was used'), permit: yup.object().shape({ - permits: yup.array().of( - yup.object().shape({ - permit_id: yup.number().nullable(true), - permit_number: yup - .string() - .max(100, 'Cannot exceed 100 characters') - .required('Permit Number is Required') - .test('is-unique-permit-number', 'Permit numbers must be unique', function (permitNumber) { - const formValues = this.options.context; - - if (!formValues?.permit?.permits?.length) { - return true; - } - - return ( - formValues.permit.permits.filter( - (permit: ISurveyPermitFormArrayItem) => permit.permit_number === permitNumber - ).length <= 1 - ); - }), - permit_type: yup.string().required('Permit Type is Required') + permits: yup + .array() + .of( + yup.object().shape({ + permit_id: yup.number().nullable(true), + permit_number: yup + .string() + .max(100, 'Cannot exceed 100 characters') + .required('Permit Number is Required') + .test('is-unique-permit-number', 'Permit numbers must be unique', function (permitNumber) { + const formValues = this.options.context; + + if (!formValues?.permit?.permits?.length) { + return true; + } + + return ( + formValues.permit.permits.filter((permit: ISurveyPermit) => permit.permit_number === permitNumber) + .length <= 1 + ); + }), + permit_type: yup.string().required('Permit Type is Required') + }) + ) + .test('is-permit-used', 'You must add at least one permit', function (permits) { + const formValues = this.options.context; + + if (!formValues?.permit_used) { + return true; + } + + return !!permits?.length; }) - ) }) }); @@ -77,37 +95,70 @@ export const SurveyPermitFormYupSchema = yup.object().shape({ * @return {*} */ const SurveyPermitForm: React.FC = () => { - const { values, handleChange, getFieldMeta, errors } = useFormikContext(); + const { values, handleChange, getFieldMeta, errors, setFieldValue, submitCount } = + useFormikContext(); + + useEffect(() => { + setFieldValue('permit_used', values.permit_used); + }, [setFieldValue, values.permit_used]); + + const getPermitUsedValue = () => { + if (values.permit_used === true) { + return 'true'; + } + if (values.permit_used === false) { + return 'false'; + } + return null; + }; return ( ( - 0 && ( + + )} + { + const permitsUsed = event.target.value === 'true' ? true : false; + setFieldValue('permit_used', permitsUsed); + if (permitsUsed) { + setFieldValue('permit.permits', [SurveyPermitFormArrayItemInitialValues]); + } else if (!permitsUsed) { + setFieldValue('permit.permits', []); } }}> - {values.permit.permits?.map((permit: ISurveyPermitFormArrayItem, index) => { + } label="Yes" /> + } label="No" /> + + + + {values.permit.permits.map((permit: ISurveyPermit, index) => { const permitNumberMeta = getFieldMeta(`permit.permits.[${index}].permit_number`); const permitTypeMeta = getFieldMeta(`permit.permits.[${index}].permit_type`); return ( - + { {errors.permit.permits} )} - - + {values.permit_used && ( + + )} )} /> diff --git a/app/src/features/surveys/components/sampling-strategy/SamplingStrategyForm.tsx b/app/src/features/surveys/components/sampling-strategy/SamplingStrategyForm.tsx index d278a50c1e..d60bab9f44 100644 --- a/app/src/features/surveys/components/sampling-strategy/SamplingStrategyForm.tsx +++ b/app/src/features/surveys/components/sampling-strategy/SamplingStrategyForm.tsx @@ -11,10 +11,7 @@ const SamplingStrategyForm = () => { return ( <> - - Site Selection Strategies - - + Add Stratum @@ -35,7 +32,7 @@ const SamplingStrategyForm = () => { sx={{ mb: 0 }}> - Add Blocks (Optional) + Add Blocks (optional) { /> { )} - {values.site_selection.stratums.map((stratum: IGetSurveyStratum, index: number) => { + {values.site_selection.stratums.map((stratum, index: number) => { const key = `${stratum.name}-${index}`; return ( diff --git a/app/src/features/surveys/components/species/SpeciesForm.tsx b/app/src/features/surveys/components/species/SpeciesForm.tsx new file mode 100644 index 0000000000..a4fab7b867 --- /dev/null +++ b/app/src/features/surveys/components/species/SpeciesForm.tsx @@ -0,0 +1,87 @@ +import Box from '@mui/material/Box'; +import Grid from '@mui/material/Grid'; +import { FocalSpeciesForm } from 'features/surveys/components/species/components/FocalSpeciesForm'; +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import yup from 'utils/YupSchema'; + +export type IEcologicalUnit = { + critterbase_collection_category_id: string | null; + critterbase_collection_unit_id: string | null; +}; + +export const EcologicalUnitInitialValues: IEcologicalUnit = { + critterbase_collection_category_id: null, + critterbase_collection_unit_id: null +}; + +export interface ITaxonomyWithEcologicalUnits extends IPartialTaxonomy { + ecological_units: IEcologicalUnit[]; +} + +export interface ISpeciesForm { + species: { + focal_species: ITaxonomyWithEcologicalUnits[]; + }; +} + +export const SpeciesInitialValues: ISpeciesForm = { + species: { + focal_species: [] + } +}; + +export const SpeciesYupSchema = yup.object().shape({ + species: yup.object().shape({ + focal_species: yup + .array() + .of( + yup.object().shape({ + ecological_units: yup.array().of( + yup.object().shape({ + critterbase_collection_category_id: yup.string().nullable().required('Ecological unit is required'), + critterbase_collection_unit_id: yup.string().nullable().required('Ecological unit is required') + }) + ) + }) + ) + .min(1, 'You must specify a focal species') + .required('Required') + .test('is-unique-ecological-unit', 'Ecological units must be unique', function () { + const focalSpecies = (this.options.context?.species.focal_species ?? []) as ITaxonomyWithEcologicalUnits[]; + + const seenCollectionUnitIts = new Set(); + + for (const focalSpeciesItem of focalSpecies) { + for (const ecologicalUnit of focalSpeciesItem.ecological_units) { + if (seenCollectionUnitIts.has(ecologicalUnit.critterbase_collection_category_id)) { + // Duplicate ecological collection category id found, return false + return false; + } + seenCollectionUnitIts.add(ecologicalUnit.critterbase_collection_category_id); + } + } + + // Valid, return true + return true; + }) + }) +}); + +/** + * Create survey - species information fields + * + * @return {*} + */ +const SpeciesForm = () => { + return ( + + + + + + + + ); +}; + +export default SpeciesForm; diff --git a/app/src/features/surveys/components/species/components/FocalSpeciesAlert.tsx b/app/src/features/surveys/components/species/components/FocalSpeciesAlert.tsx new file mode 100644 index 0000000000..13fd339187 --- /dev/null +++ b/app/src/features/surveys/components/species/components/FocalSpeciesAlert.tsx @@ -0,0 +1,25 @@ +import AlertBar from 'components/alert/AlertBar'; +import { useFormikContext } from 'formik'; +import { ICreateSurveyRequest, IEditSurveyRequest } from 'interfaces/useSurveyApi.interface'; +import get from 'lodash-es/get'; + +/** + * Renders an alert if formik has an error for the 'species.focal_species' field. + * + * @return {*} + */ +export const FocalSpeciesAlert = () => { + const { errors } = useFormikContext(); + + const errorText = get(errors, 'species.focal_species'); + + if (!errorText) { + return null; + } + + if (typeof errorText !== 'string') { + return null; + } + + return ; +}; diff --git a/app/src/features/surveys/components/species/components/FocalSpeciesEcologicalUnitsForm.tsx b/app/src/features/surveys/components/species/components/FocalSpeciesEcologicalUnitsForm.tsx new file mode 100644 index 0000000000..d877370212 --- /dev/null +++ b/app/src/features/surveys/components/species/components/FocalSpeciesEcologicalUnitsForm.tsx @@ -0,0 +1,94 @@ +import { mdiPlus } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Stack from '@mui/material/Stack'; +import { EcologicalUnitsSelect } from 'components/species/ecological-units/EcologicalUnitsSelect'; +import { initialEcologicalUnitValues } from 'features/surveys/animals/animal-form/components/ecological-units/EcologicalUnitsForm'; +import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { ICreateSurveyRequest, IEditSurveyRequest } from 'interfaces/useSurveyApi.interface'; +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { useEffect } from 'react'; +import { isDefined } from 'utils/Utils'; +import { v4 } from 'uuid'; + +export interface ISelectedSpeciesProps { + /** + * The species to display. + * + * @type {IPartialTaxonomy} + * @memberof ISelectedSpeciesProps + */ + species: IPartialTaxonomy; + /** + * The index of the component in the list. + * + * @type {number} + * @memberof ISelectedSpeciesProps + */ + index: number; +} + +/** + * Renders form controls for selecting ecological units for a focal species. + * + * @param {ISelectedSpeciesProps} props + * @return {*} + */ +export const FocalSpeciesEcologicalUnitsForm = (props: ISelectedSpeciesProps) => { + const { index, species } = props; + + const { values } = useFormikContext(); + + const critterbaseApi = useCritterbaseApi(); + + const ecologicalUnitDataLoader = useDataLoader((tsn: number) => critterbaseApi.xref.getTsnCollectionCategories(tsn)); + + useEffect(() => { + ecologicalUnitDataLoader.load(species.tsn); + }, [ecologicalUnitDataLoader, species.tsn]); + + const ecologicalUnitsForSpecies = ecologicalUnitDataLoader.data ?? []; + + const selectedUnits = + values.species.focal_species.filter((item) => item.tsn === species.tsn).flatMap((item) => item.ecological_units) ?? + []; + + return ( + ( + + {selectedUnits.map((ecological_unit, ecologicalUnitIndex) => ( + unit.critterbase_collection_category_id) + .filter(isDefined)} + ecologicalUnits={ecologicalUnitsForSpecies} + arrayHelpers={arrayHelpers} + index={ecologicalUnitIndex} + /> + ))} + + + + + )} + /> + ); +}; diff --git a/app/src/features/surveys/components/species/components/FocalSpeciesForm.tsx b/app/src/features/surveys/components/species/components/FocalSpeciesForm.tsx new file mode 100644 index 0000000000..7b0368a847 --- /dev/null +++ b/app/src/features/surveys/components/species/components/FocalSpeciesForm.tsx @@ -0,0 +1,69 @@ +import Collapse from '@mui/material/Collapse'; +import { grey } from '@mui/material/colors'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import SpeciesAutocompleteField from 'components/species/components/SpeciesAutocompleteField'; +import SpeciesSelectedCard from 'components/species/components/SpeciesSelectedCard'; +import { FocalSpeciesAlert } from 'features/surveys/components/species/components/FocalSpeciesAlert'; +import { FocalSpeciesEcologicalUnitsForm } from 'features/surveys/components/species/components/FocalSpeciesEcologicalUnitsForm'; +import { ITaxonomyWithEcologicalUnits } from 'features/surveys/components/species/SpeciesForm'; +import { FieldArray, useFormikContext } from 'formik'; +import { ICreateSurveyRequest, IEditSurveyRequest } from 'interfaces/useSurveyApi.interface'; +import get from 'lodash-es/get'; +import { TransitionGroup } from 'react-transition-group'; + +/** + * Returns a form control for selecting focal species and ecological units for each focal species. + * + * @return {*} + */ +export const FocalSpeciesForm = () => { + const { values } = useFormikContext(); + + const selectedSpecies: ITaxonomyWithEcologicalUnits[] = get(values, 'species.focal_species') ?? []; + + return ( + { + return ( + + + + { + if (values.species.focal_species.some((focalSpecies) => focalSpecies.tsn === species.tsn)) { + // Species was already added, do not add again + return; + } + + arrayHelpers.push({ ...species, ecological_units: [] }); + }} + clearOnSelect={true} + /> + + + {selectedSpecies.map((species, index) => ( + + + { + arrayHelpers.remove(index); + }} + /> + + + + ))} + + + ); + }} + /> + ); +}; diff --git a/app/src/features/surveys/edit/EditSurveyForm.tsx b/app/src/features/surveys/edit/EditSurveyForm.tsx index 0335e9eec6..c228734833 100644 --- a/app/src/features/surveys/edit/EditSurveyForm.tsx +++ b/app/src/features/surveys/edit/EditSurveyForm.tsx @@ -1,4 +1,5 @@ import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; import Divider from '@mui/material/Divider'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; @@ -6,16 +7,18 @@ import FormikErrorSnackbar from 'components/alert/FormikErrorSnackbar'; import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; import { CodesContext } from 'contexts/codesContext'; import { ProjectContext } from 'contexts/projectContext'; +import SurveyPermitForm, { ISurveyPermitForm } from 'features/surveys/components/permit/SurveyPermitForm'; import SamplingStrategyForm from 'features/surveys/components/sampling-strategy/SamplingStrategyForm'; import SurveyPartnershipsForm, { SurveyPartnershipsFormYupSchema } from 'features/surveys/view/components/SurveyPartnershipsForm'; import { Formik, FormikProps } from 'formik'; -import { ICreateSurveyRequest, IEditSurveyRequest, SurveyUpdateObject } from 'interfaces/useSurveyApi.interface'; -import React, { useContext } from 'react'; +import { ICreateSurveyRequest, IUpdateSurveyRequest } from 'interfaces/useSurveyApi.interface'; +import React, { useContext, useEffect } from 'react'; import AgreementsForm, { AgreementsYupSchema } from '../components/agreements/AgreementsForm'; import ProprietaryDataForm, { ProprietaryDataYupSchema } from '../components/agreements/ProprietaryDataForm'; import SurveyFundingSourceForm, { + ISurveyFundingSourceForm, SurveyFundingSourceFormYupSchema } from '../components/funding/SurveyFundingSourceForm'; import GeneralInformationForm, { @@ -27,11 +30,16 @@ import PurposeAndMethodologyForm, { } from '../components/methodology/PurposeAndMethodologyForm'; import SurveyUserForm, { SurveyUserJobYupSchema } from '../components/participants/SurveyUserForm'; import { SurveySiteSelectionYupSchema } from '../components/sampling-strategy/SurveySiteSelectionForm'; - -export interface IEditSurveyForm { - initialSurveyData: SurveyUpdateObject | ICreateSurveyRequest; - handleSubmit: (formikData: IEditSurveyRequest) => void; - formikRef: React.RefObject>; +import SpeciesForm, { SpeciesYupSchema } from '../components/species/SpeciesForm'; + +export interface IEditSurveyForm< + T extends + | (IUpdateSurveyRequest & ISurveyPermitForm & ISurveyFundingSourceForm) + | (ICreateSurveyRequest & ISurveyPermitForm & ISurveyFundingSourceForm) +> { + initialSurveyData: T; + handleSubmit: (formikData: T) => void; + formikRef: React.RefObject>; } /** @@ -39,15 +47,25 @@ export interface IEditSurveyForm { * * @return {*} */ -const EditSurveyForm = (props: IEditSurveyForm) => { +const EditSurveyForm = < + T extends + | (IUpdateSurveyRequest & ISurveyPermitForm & ISurveyFundingSourceForm) + | (ICreateSurveyRequest & ISurveyPermitForm & ISurveyFundingSourceForm) +>( + props: IEditSurveyForm +) => { const projectContext = useContext(ProjectContext); const projectData = projectContext.projectDataLoader.data?.projectData; const codesContext = useContext(CodesContext); const codes = codesContext.codesDataLoader.data; + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext.codesDataLoader]); + if (!projectData || !codes) { - return <>; + return ; } const surveyEditYupSchemas = GeneralInformationYupSchema() @@ -58,12 +76,13 @@ const EditSurveyForm = (props: IEditSurveyForm) => { .concat(SurveyUserJobYupSchema) .concat(SurveyLocationYupSchema) .concat(SurveySiteSelectionYupSchema) - .concat(SurveyPartnershipsFormYupSchema); + .concat(SurveyPartnershipsFormYupSchema) + .concat(SpeciesYupSchema); return ( - innerRef={props.formikRef} - initialValues={props.initialSurveyData as IEditSurveyRequest} + initialValues={props.initialSurveyData} validationSchema={surveyEditYupSchemas} validateOnBlur={false} validateOnChange={false} @@ -72,13 +91,9 @@ const EditSurveyForm = (props: IEditSurveyForm) => { { - return { value: item.id, label: item.name }; - }) || [] - } progress={ codes?.survey_progress?.map((item) => { return { value: item.id, label: item.name, subText: item.description }; @@ -89,8 +104,43 @@ const EditSurveyForm = (props: IEditSurveyForm) => { + + + + + + + + Were any permits used in this survey? + + + } + /> + + + + + Do any funding agencies require this survey to be submitted? + + + } + /> + + + { return { value: item.id, label: item.name, subText: item.description }; }) || [] } - vantage_codes={ - codes.vantage_codes.map((item) => { + type={ + codes?.type?.map((item) => { return { value: item.id, label: item.name }; }) || [] } @@ -110,34 +160,23 @@ const EditSurveyForm = (props: IEditSurveyForm) => { } /> - - Add Funding Sources - - - - Additional Partnerships - - - - } + title="Partnerships" + summary="Enter any partners involved in the survey" + component={} /> } /> @@ -145,41 +184,39 @@ const EditSurveyForm = (props: IEditSurveyForm) => { - Define Survey Study Area - - - Import, draw or select a feature from an existing layer to define the study areas for this survey. - - - - - } + summary="Import, draw or select a feature from an existing layer to define the study areas for this survey" + component={} /> { - return { value: item.id, label: item.name, is_first_nation: item.is_first_nation }; - }) || [] - } - first_nations={ - codes.first_nations?.map((item) => { - return { value: item.id, label: item.name }; - }) || [] - } - /> + + Is any data in this survey proprietary? + { + return { value: item.id, label: item.name, is_first_nation: item.is_first_nation }; + }) || [] + } + first_nations={ + codes.first_nations?.map((item) => { + return { value: item.id, label: item.name }; + }) || [] + } + /> + }> - }> + }> diff --git a/app/src/features/surveys/edit/EditSurveyPage.tsx b/app/src/features/surveys/edit/EditSurveyPage.tsx index c0a7563eaa..96e384c016 100644 --- a/app/src/features/surveys/edit/EditSurveyPage.tsx +++ b/app/src/features/surveys/edit/EditSurveyPage.tsx @@ -59,15 +59,15 @@ const EditSurveyPage = () => { const surveyContext = useContext(SurveyContext); - const editSurveyDataLoader = useDataLoader((projectId: number, surveyId: number) => + const getSurveyForUpdateDataLoader = useDataLoader((projectId: number, surveyId: number) => biohubApi.survey.getSurveyForUpdate(projectId, surveyId) ); if (surveyId) { - editSurveyDataLoader.load(projectContext.projectId, surveyId); + getSurveyForUpdateDataLoader.load(projectContext.projectId, surveyId); } - const surveyData = editSurveyDataLoader.data?.surveyData; + const surveyData = getSurveyForUpdateDataLoader.data?.surveyData; const handleCancel = () => { history.push('details'); @@ -97,6 +97,7 @@ const EditSurveyPage = () => { setIsSaving(true); try { + // Remove the permit_used and funding_used properties const response = await biohubApi.survey.updateSurvey(projectContext.projectId, surveyId, { blocks: values.blocks, funding_sources: values.funding_sources, @@ -109,9 +110,10 @@ const EditSurveyPage = () => { })), participants: values.participants, partnerships: values.partnerships, - permit: values.permit, + permit: { + permits: values.permit.permits + }, proprietor: values.proprietor, - purpose_and_methodology: values.purpose_and_methodology, site_selection: { stratums: values.site_selection.stratums.map((stratum) => ({ survey_stratum_id: stratum.survey_stratum_id, @@ -121,7 +123,9 @@ const EditSurveyPage = () => { strategies: values.site_selection.strategies }, species: values.species, - survey_details: values.survey_details + survey_details: values.survey_details, + purpose_and_methodology: values.purpose_and_methodology, + agreements: values.agreements }); if (!response?.id) { @@ -194,7 +198,27 @@ const EditSurveyPage = () => { - + { it('renders correctly with an empty list of surveys', async () => { const mockCodesContext: ICodesContext = { codesDataLoader: { - data: codes + data: codes, + load: () => {} } as DataLoader }; const mockProjectContext: IProjectContext = { projectDataLoader: { data: getProjectForViewResponse } as DataLoader, - surveysListDataLoader: { data: [], refresh: jest.fn() } as unknown as DataLoader, + surveysListDataLoader: { data: [], isLoading: false, isReady: true, refresh: jest.fn() } as unknown as DataLoader< + any, + any, + any + >, artifactDataLoader: { data: null } as DataLoader, projectId: 1 }; @@ -62,7 +67,7 @@ describe('SurveysListPage', () => { const authState = getMockAuthState({ base: SystemAdminAuthState }); - const { getByText } = render( + const { getByTestId } = render( @@ -77,16 +82,15 @@ describe('SurveysListPage', () => { ); await waitFor(() => { - expect(getByText(/^Surveys/)).toBeInTheDocument(); - expect(getByText('Create Survey')).toBeInTheDocument(); - expect(getByText('No surveys found')).toBeInTheDocument(); + expect(getByTestId('survey-list-no-data-overlay')).toBeInTheDocument(); }); }); it('renders correctly with a populated list of surveys', async () => { const mockCodesContext: ICodesContext = { codesDataLoader: { - data: codes + data: codes, + load: () => {} } as DataLoader }; @@ -103,11 +107,12 @@ describe('SurveysListPage', () => { projectDataLoader: { data: getProjectForViewResponse } as DataLoader, - surveysListDataLoader: { data: getSurveyForListResponse, refresh: jest.fn() } as unknown as DataLoader< - any, - any, - any - >, + surveysListDataLoader: { + data: getSurveyForListResponse, + isLoading: false, + isReady: true, + refresh: jest.fn() + } as unknown as DataLoader, artifactDataLoader: { data: null } as DataLoader, projectId: 1 }; diff --git a/app/src/features/surveys/list/SurveysListPage.tsx b/app/src/features/surveys/list/SurveysListPage.tsx index fe3cc79810..55474b8ba4 100644 --- a/app/src/features/surveys/list/SurveysListPage.tsx +++ b/app/src/features/surveys/list/SurveysListPage.tsx @@ -1,13 +1,17 @@ -import { mdiPlus } from '@mdi/js'; +import { mdiArrowTopRight, mdiPlus } from '@mdi/js'; import Icon from '@mdi/react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; +import { grey } from '@mui/material/colors'; import Divider from '@mui/material/Divider'; import Link from '@mui/material/Link'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import { GridColDef, GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; import { ProjectRoleGuard } from 'components/security/Guards'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; @@ -53,7 +57,25 @@ const SurveysListPage = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [sortModel, paginationModel]); + const surveys = projectContext.surveysListDataLoader.data?.surveys ?? []; + const columns: GridColDef[] = [ + { + field: 'survey_id', + headerName: 'ID', + width: 70, + minWidth: 70, + renderHeader: () => ( + + ID + + ), + renderCell: (params) => ( + + {params.row.survey_id} + + ) + }, { field: 'name', headerName: 'Name', @@ -131,30 +153,47 @@ const SurveysListPage = () => { - + + + - row.survey_id} - pageSizeOptions={[...pageSizeOptions]} - paginationMode="server" - sortingMode="server" - sortModel={sortModel} - paginationModel={paginationModel} - onPaginationModelChange={setPaginationModel} - onSortModelChange={setSortModel} - rowSelection={false} - checkboxSelection={false} - disableRowSelectionOnClick - disableColumnSelector - disableColumnFilter - disableColumnMenu - sortingOrder={['asc', 'desc']} - /> + } + isLoadingFallbackDelay={100} + hasNoData={!surveys.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + row.survey_id} + pageSizeOptions={[...pageSizeOptions]} + paginationMode="server" + sortingMode="server" + sortModel={sortModel} + paginationModel={paginationModel} + onPaginationModelChange={setPaginationModel} + onSortModelChange={setSortModel} + rowSelection={false} + checkboxSelection={false} + disableRowSelectionOnClick + disableColumnSelector + disableColumnFilter + disableColumnMenu + sortingOrder={['asc', 'desc']} + /> + ); diff --git a/app/src/features/surveys/observations/observations-table/ObservationsTable.tsx b/app/src/features/surveys/observations/observations-table/ObservationsTable.tsx index e523621720..7c3afd2cbe 100644 --- a/app/src/features/surveys/observations/observations-table/ObservationsTable.tsx +++ b/app/src/features/surveys/observations/observations-table/ObservationsTable.tsx @@ -26,152 +26,150 @@ const ObservationsTable = (props: ISpeciesObservationTableProps) => { const observationsTableContext = useObservationsTableContext(); return ( - <> - observationsTableContext.onRowEditStart(params.id)} - onRowEditStop={(_params, event) => { - event.defaultMuiPrevented = true; - }} - // Row selection - checkboxSelection - disableRowSelectionOnClick - rowSelectionModel={observationsTableContext.rowSelectionModel} - onRowSelectionModelChange={observationsTableContext.onRowSelectionModelChange} - // Styling - localeText={{ - noRowsLabel: 'No Records' - }} - rowHeight={56} - getRowHeight={() => 'auto'} - getRowClassName={(params) => (has(observationsTableContext.validationModel, params.row.id) ? 'error' : '')} - // Loading - loading={observationsTableContext.isLoading} - slots={{ - loadingOverlay: SkeletonTable - }} - // Styles - sx={{ - border: 'none', - borderRadius: 0, - '&:after': { - content: '" "', - position: 'absolute', - top: 0, - right: 0, - width: 100, - height: 55 - }, - '& .pinnedColumn': { - position: 'sticky', - right: 0, - top: 0, - borderLeft: '1px solid' + grey[300] - }, - '& .MuiDataGrid-columnHeaders': { - position: 'relative', - background: grey[50] - }, - '& .MuiDataGrid-columnHeader:focus-within': { - outline: 'none', - background: grey[200] - }, - '& .MuiDataGrid-columnHeaderTitle': { - fontWeight: 700, - textTransform: 'uppercase', - color: 'text.secondary' + observationsTableContext.onRowEditStart(params.id)} + onRowEditStop={(_params, event) => { + event.defaultMuiPrevented = true; + }} + // Row selection + checkboxSelection + disableRowSelectionOnClick + rowSelectionModel={observationsTableContext.rowSelectionModel} + onRowSelectionModelChange={observationsTableContext.onRowSelectionModelChange} + // Styling + localeText={{ + noRowsLabel: 'No Records' + }} + rowHeight={56} + getRowHeight={() => 'auto'} + getRowClassName={(params) => (has(observationsTableContext.validationModel, params.row.id) ? 'error' : '')} + // Loading + loading={observationsTableContext.isLoading} + slots={{ + loadingOverlay: SkeletonTable + }} + // Styles + sx={{ + border: 'none', + borderRadius: 0, + '&:after': { + content: '" "', + position: 'absolute', + top: 0, + right: 0, + width: 100, + height: 55 + }, + '& .pinnedColumn': { + position: 'sticky', + right: 0, + top: 0, + borderLeft: '1px solid' + grey[300] + }, + '& .MuiDataGrid-columnHeaders': { + position: 'relative', + background: grey[50] + }, + '& .MuiDataGrid-columnHeader:focus-within': { + outline: 'none', + background: grey[200] + }, + '& .MuiDataGrid-columnHeaderTitle': { + fontWeight: 700, + textTransform: 'uppercase', + color: 'text.secondary' + }, + '& .MuiDataGrid-cell': { + py: 0.75, + background: '#fff', + '&.MuiDataGrid-cell--editing:focus-within': { + outline: 'none' }, + '&.MuiDataGrid-cell--editing': { + p: 0.5, + backgroundColor: cyan[100] + } + }, + '& .MuiDataGrid-row--editing': { + boxShadow: 'none', + backgroundColor: cyan[50], '& .MuiDataGrid-cell': { - py: 0.75, - background: '#fff', - '&.MuiDataGrid-cell--editing:focus-within': { - outline: 'none' - }, - '&.MuiDataGrid-cell--editing': { - p: 0.5, - backgroundColor: cyan[100] - } + backgroundColor: cyan[50] }, - '& .MuiDataGrid-row--editing': { - boxShadow: 'none', - backgroundColor: cyan[50], - '& .MuiDataGrid-cell': { - backgroundColor: cyan[50] - }, - '&.error': { - '& .MuiDataGrid-cell, .MuiDataGrid-cell--editing': { - backgroundColor: 'rgb(251, 237, 238)' - } - } - }, - '& .MuiDataGrid-editInputCell': { - border: '1px solid #ccc', - '&:hover': { - borderColor: 'primary.main' - }, - '&.Mui-focused': { - borderColor: 'primary.main', - outlineWidth: '2px', - outlineStyle: 'solid', - outlineColor: 'primary.main', - outlineOffset: '-2px' - } - }, - '& .MuiInputBase-root': { - height: '40px', - borderRadius: '4px', - background: '#fff', - fontSize: '0.875rem', - '&.MuiDataGrid-editInputCell': { - padding: 0 - } - }, - '& .MuiOutlinedInput-root': { - borderRadius: '4px', - background: '#fff', - border: 'none', - '&:hover': { - borderColor: 'primary.main' - }, - '&:hover > fieldset': { - border: '1px solid primary.main' + '&.error': { + '& .MuiDataGrid-cell, .MuiDataGrid-cell--editing': { + backgroundColor: 'rgb(251, 237, 238)' } + } + }, + '& .MuiDataGrid-editInputCell': { + border: '1px solid #ccc', + '&:hover': { + borderColor: 'primary.main' }, - '& .MuiOutlinedInput-notchedOutline': { - border: '1px solid ' + grey[300], - '&.Mui-focused': { - borderColor: 'primary.main' - } + '&.Mui-focused': { + borderColor: 'primary.main', + outlineWidth: '2px', + outlineStyle: 'solid', + outlineColor: 'primary.main', + outlineOffset: '-2px' + } + }, + '& .MuiInputBase-root': { + height: '40px', + borderRadius: '4px', + background: '#fff', + fontSize: '0.875rem', + '&.MuiDataGrid-editInputCell': { + padding: 0 + } + }, + '& .MuiOutlinedInput-root': { + borderRadius: '4px', + background: '#fff', + border: 'none', + '&:hover': { + borderColor: 'primary.main' }, - '& .MuiDataGrid-virtualScrollerContent, .MuiDataGrid-overlay': { - background: grey[100] + '&:hover > fieldset': { + border: '1px solid primary.main' + } + }, + '& .MuiOutlinedInput-notchedOutline': { + border: '1px solid ' + grey[300], + '&.Mui-focused': { + borderColor: 'primary.main' } - }} - /> - + }, + '& .MuiDataGrid-virtualScrollerContent, .MuiDataGrid-overlay': { + background: grey[100] + } + }} + /> ); }; diff --git a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx index c7ac13bbde..24e300a0b7 100644 --- a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx +++ b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx @@ -39,7 +39,7 @@ import { IGetSampleMethodDetails, IGetSamplePeriodRecord } from 'interfaces/useSamplingSiteApi.interface'; -import { useContext } from 'react'; +import { useContext, useMemo } from 'react'; import { getCodesName } from 'utils/Utils'; import { ConfigureColumnsButton } from './configure-columns/ConfigureColumnsButton'; import ExportHeadersButton from './export-button/ExportHeadersButton'; @@ -57,12 +57,19 @@ const ObservationsTableContainer = () => { const observationsTableContext = useObservationsTableContext(); // Collect sample sites - const surveySampleSites: IGetSampleLocationDetails[] = surveyContext.sampleSiteDataLoader.data?.sampleSites ?? []; - const sampleSiteOptions: ISampleSiteOption[] = - surveySampleSites.map((site) => ({ - survey_sample_site_id: site.survey_sample_site_id, - sample_site_name: site.name - })) ?? []; + const surveySampleSites: IGetSampleLocationDetails[] = useMemo( + () => surveyContext.sampleSiteDataLoader.data?.sampleSites ?? [], + [surveyContext.sampleSiteDataLoader.data?.sampleSites] + ); + + const sampleSiteOptions: ISampleSiteOption[] = useMemo( + () => + surveySampleSites.map((site) => ({ + survey_sample_site_id: site.survey_sample_site_id, + sample_site_name: site.name + })) ?? [], + [surveySampleSites] + ); // Collect sample methods const surveySampleMethods: IGetSampleMethodDetails[] = surveySampleSites @@ -91,22 +98,55 @@ const ObservationsTableContainer = () => { })); // The column definitions of the columns to render in the observations table - const columns: GridColDef[] = [ - // Add standard observation columns to the table - TaxonomyColDef({ hasError: observationsTableContext.hasError }), - SampleSiteColDef({ sampleSiteOptions, hasError: observationsTableContext.hasError }), - SampleMethodColDef({ sampleMethodOptions, hasError: observationsTableContext.hasError }), - SamplePeriodColDef({ samplePeriodOptions, hasError: observationsTableContext.hasError }), - ObservationCountColDef({ sampleMethodOptions, hasError: observationsTableContext.hasError }), - GenericDateColDef({ field: 'observation_date', headerName: 'Date', hasError: observationsTableContext.hasError }), - GenericTimeColDef({ field: 'observation_time', headerName: 'Time', hasError: observationsTableContext.hasError }), - GenericLatitudeColDef({ field: 'latitude', headerName: 'Lat', hasError: observationsTableContext.hasError }), - GenericLongitudeColDef({ field: 'longitude', headerName: 'Long', hasError: observationsTableContext.hasError }), - // Add measurement columns to the table - ...getMeasurementColumnDefinitions(observationsTableContext.measurementColumns, observationsTableContext.hasError), - // Add environment columns to the table - ...getEnvironmentColumnDefinitions(observationsTableContext.environmentColumns, observationsTableContext.hasError) - ]; + const columns: GridColDef[] = useMemo( + () => [ + // Add standard observation columns to the table + TaxonomyColDef({ hasError: observationsTableContext.hasError }), + SampleSiteColDef({ sampleSiteOptions, hasError: observationsTableContext.hasError }), + SampleMethodColDef({ sampleMethodOptions, hasError: observationsTableContext.hasError }), + SamplePeriodColDef({ samplePeriodOptions, hasError: observationsTableContext.hasError }), + ObservationCountColDef({ sampleMethodOptions, hasError: observationsTableContext.hasError }), + GenericDateColDef({ + field: 'observation_date', + headerName: 'Date', + description: 'The date when the observation was made', + hasError: observationsTableContext.hasError + }), + GenericTimeColDef({ + field: 'observation_time', + headerName: 'Time', + description: 'The time when the observation was made', + hasError: observationsTableContext.hasError + }), + GenericLatitudeColDef({ + field: 'latitude', + headerName: 'Latitude', + description: 'The latitude where the observation was made', + hasError: observationsTableContext.hasError + }), + GenericLongitudeColDef({ + field: 'longitude', + headerName: 'Longitude', + description: 'The longitude where the observation was made', + hasError: observationsTableContext.hasError + }), + // Add measurement columns to the table + ...getMeasurementColumnDefinitions( + observationsTableContext.measurementColumns, + observationsTableContext.hasError + ), + // Add environment columns to the table + ...getEnvironmentColumnDefinitions(observationsTableContext.environmentColumns, observationsTableContext.hasError) + ], + [ + observationsTableContext.environmentColumns, + observationsTableContext.hasError, + observationsTableContext.measurementColumns, + sampleMethodOptions, + samplePeriodOptions, + sampleSiteOptions + ] + ); return ( diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsDialog.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsDialog.tsx index 55a02aab10..4d942a67bb 100644 --- a/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsDialog.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsDialog.tsx @@ -125,7 +125,7 @@ export const ConfigureColumnsDialog = (props: IConfigureColumnsDialogProps) => { return ( { measurements. - + { /> - - Close + + Save & Close diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsPage.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsPage.tsx index 2d5f97e20c..3dd4626823 100644 --- a/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsPage.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsPage.tsx @@ -127,7 +127,7 @@ export const ConfigureColumnsPage = (props: IConfigureColumnsPageProps) => { const [activeView, setActiveView] = useState(ConfigureColumnsViewEnum.GENERAL); return ( - + { startIcon={} disabled={disabled} value={ConfigureColumnsViewEnum.MEASUREMENTS}> - Measurements + Species Attributes { - + {activeView === ConfigureColumnsViewEnum.GENERAL && ( + - Configure Environment Columns + Add Environmental Variables onAddEnvironmentColumns(environmentColumn)} /> - - {hasEnvironmentColumns ? ( - <> - - Selected environments - - - {environmentColumns.qualitative_environments.map((environment) => ( - - - - - onRemoveEnvironmentColumns({ - qualitative_environments: [environment.environment_qualitative_id], - quantitative_environments: [] - }) - } - data-testid="configure-environment-qualitative-column-remove-button"> - - - + {hasEnvironmentColumns ? ( + <> + + Selected environments + + + {environmentColumns.qualitative_environments.map((environment) => ( + + + {environment.options.map((option) => ( + + ))} + + } + /> + + + onRemoveEnvironmentColumns({ + qualitative_environments: [environment.environment_qualitative_id], + quantitative_environments: [] + }) + } + data-testid="configure-environment-qualitative-column-remove-button"> + + - ))} - {environmentColumns.quantitative_environments.map((environment) => ( - - - - - onRemoveEnvironmentColumns({ - qualitative_environments: [], - quantitative_environments: [environment.environment_quantitative_id] - }) - } - data-testid="configure-environment-quantitative-column-remove-button"> - - - + + ))} + {environmentColumns.quantitative_environments.map((environment) => ( + + : undefined + } + /> + + + onRemoveEnvironmentColumns({ + qualitative_environments: [], + quantitative_environments: [environment.environment_quantitative_id] + }) + } + data-testid="configure-environment-quantitative-column-remove-button"> + + - ))} - - - ) : ( - - No environmental variables selected - - )} - - + + ))} + + + ) : ( + + )} + ); }; diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/general/ConfigureGeneralColumns.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/general/ConfigureGeneralColumns.tsx index 3ca4654a46..39a1e4b84f 100644 --- a/app/src/features/surveys/observations/observations-table/configure-columns/components/general/ConfigureGeneralColumns.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/general/ConfigureGeneralColumns.tsx @@ -97,18 +97,10 @@ export const ConfigureGeneralColumns = (props: IConfigureGeneralColumnsProps) => } = props; return ( - - - Select Columns to Show - - + // display="flex" and flexDirection="column" is necessary for the scrollbars to be correctly positioned + + + Select Columns to Show checked={hiddenFields.length === 0} onClick={() => onToggleShowHideAll()} disabled={disabled} + sx={{ m: 0, p: 0 }} /> } label={ - + Show/Hide all } @@ -130,12 +128,14 @@ export const ConfigureGeneralColumns = (props: IConfigureGeneralColumnsProps) => component={Stack} gap={0.5} sx={{ + my: 2, p: 0.5, - maxHeight: { sm: 300, md: 500 }, + maxHeight: '100%', overflowY: 'auto' }} disablePadding> {hideableColumns.map((column) => { + const isSelected = !hiddenFields.includes(column.field); return ( dense onClick={() => onToggleColumnVisibility(column.field)} disabled={disabled} - sx={{ background: grey[50], borderRadius: '5px' }}> + sx={{ + background: isSelected ? grey[50] : '#fff', + borderRadius: '5px', + alignItems: 'flex-start', + '& .MuiListItemText-root': { my: 0.25 } + }}> - + - {column.headerName} + + {column.headerName} + + {column.description} + + ); diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/ConfigureMeasurementColumns.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/ConfigureMeasurementColumns.tsx index f36b2763e5..2b8e1b6dbb 100644 --- a/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/ConfigureMeasurementColumns.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/ConfigureMeasurementColumns.tsx @@ -1,8 +1,19 @@ -import { mdiTrashCanOutline } from '@mdi/js'; +import { mdiArrowTopRight, mdiTrashCanOutline } from '@mdi/js'; import Icon from '@mdi/react'; -import { Box, IconButton, Stack, Typography } from '@mui/material'; -import MeasurementStandardCard from 'features/standards/view/components/MeasurementStandardCard'; +import Box from '@mui/material/Box'; +import Checkbox from '@mui/material/Checkbox'; +import { blueGrey, grey } from '@mui/material/colors'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormGroup from '@mui/material/FormGroup'; +import IconButton from '@mui/material/IconButton'; +import List from '@mui/material/List'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; +import { AccordionStandardCard } from 'features/standards/view/components/AccordionStandardCard'; import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; +import { useState } from 'react'; import { MeasurementsSearch } from './search/MeasurementsSearch'; export interface IConfigureMeasurementColumnsProps { @@ -33,54 +44,98 @@ export interface IConfigureMeasurementColumnsProps { * @param {IConfigureMeasurementColumnsProps} props * @return {*} */ + export const ConfigureMeasurementColumns = (props: IConfigureMeasurementColumnsProps) => { const { measurementColumns, onAddMeasurementColumns, onRemoveMeasurementColumns } = props; + const [isFocalSpeciesMeasurementsOnly, setIsFocalSpeciesMeasurementsOnly] = useState(true); + return ( - <> + - Configure Measurement Columns + Add Species Attributes onAddMeasurementColumns([measurementColumn])} + focalOrObservedSpeciesOnly={isFocalSpeciesMeasurementsOnly} /> - - {measurementColumns.length ? ( - <> - - Selected measurements - - - {measurementColumns.map((measurement) => ( - - - - onRemoveMeasurementColumns([measurement.taxon_measurement_id])} - data-testid="configure-measurement-column-remove-button"> - - - - - ))} - - - ) : ( - - No measurements selected - - )} - - + + setIsFocalSpeciesMeasurementsOnly((prev) => !prev)} + /> + } + /> + + {measurementColumns.length ? ( + + {measurementColumns.map((measurement) => ( + + + {measurement.options.map((option) => ( + + ))} + + ) : undefined + } + ornament={ + 'unit' in measurement && measurement.unit ? ( + + ) : undefined + } + /> + + onRemoveMeasurementColumns([measurement.taxon_measurement_id])} + data-testid="configure-measurement-column-remove-button"> + + + + + ))} + + ) : ( + + )} + ); }; diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearch.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearch.tsx index 00540e15a4..d8c2d8762d 100644 --- a/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearch.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearch.tsx @@ -1,4 +1,8 @@ +import green from '@mui/material/colors/green'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { MeasurementsSearchAutocomplete } from 'features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearchAutocomplete'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useObservationsContext, useSurveyContext } from 'hooks/useContext'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; import useDataLoader from 'hooks/useDataLoader'; import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; @@ -17,6 +21,12 @@ export interface IMeasurementsSearchProps { * @memberof IMeasurementsSearchProps */ onAddMeasurementColumn: (measurementColumn: CBMeasurementType) => void; + /** + * Whether to only show measurements that focal or observed species can have + * + * @memberof IMeasurementsSearchProps + */ + focalOrObservedSpeciesOnly?: boolean; } /** @@ -25,20 +35,59 @@ export interface IMeasurementsSearchProps { * @param {IMeasurementsSearchProps} props * @return {*} */ -export const MeasurementsSearch = (props: IMeasurementsSearchProps) => { - const { selectedMeasurements, onAddMeasurementColumn } = props; +import React, { useEffect } from 'react'; + +export const MeasurementsSearch: React.FC = (props) => { + const { selectedMeasurements, onAddMeasurementColumn, focalOrObservedSpeciesOnly } = props; const critterbaseApi = useCritterbaseApi(); + const surveyContext = useSurveyContext(); + const observationsContext = useObservationsContext(); + const biohubApi = useBiohubApi(); + + const measurementsDataLoader = useDataLoader((searchTerm: string, tsns?: number[]) => + critterbaseApi.xref.getMeasurementTypeDefinitionsBySearchTerm(searchTerm, tsns) + ); + + const hierarchyDataLoader = useDataLoader((tsns: number[]) => biohubApi.taxonomy.getTaxonHierarchyByTSNs(tsns)); + + useEffect(() => { + if (!observationsContext.observedSpeciesDataLoader.data) { + observationsContext.observedSpeciesDataLoader.load(); + } + }, [observationsContext.observedSpeciesDataLoader]); + + const focalOrObservedSpecies: number[] = [ + ...(surveyContext.surveyDataLoader.data?.surveyData.species.focal_species.map((species) => species.tsn) ?? []), + ...(observationsContext.observedSpeciesDataLoader.data?.map((species) => species.tsn) ?? []) + ]; + + useEffect(() => { + if (focalOrObservedSpecies.length) { + hierarchyDataLoader.load(focalOrObservedSpecies); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hierarchyDataLoader]); + + const getOptions = async (inputValue: string): Promise => { + const response = focalOrObservedSpeciesOnly + ? await measurementsDataLoader.refresh(inputValue, focalOrObservedSpecies) + : await measurementsDataLoader.refresh(inputValue); + + return response ? [...response.qualitative, ...response.quantitative] : []; + }; - const measurementsDataLoader = useDataLoader(critterbaseApi.xref.getMeasurementTypeDefinitionsBySearchTerm); + const focalOrObservedSpeciesTsns = [ + ...focalOrObservedSpecies, + ...(hierarchyDataLoader.data?.flatMap((taxon) => taxon.hierarchy) ?? []) + ]; return ( { - const response = await measurementsDataLoader.refresh(inputValue); - return (response && [...response.qualitative, ...response.quantitative]) || []; - }} + ornament={} + applicableTsns={focalOrObservedSpeciesTsns} + getOptions={getOptions} onAddMeasurementColumn={onAddMeasurementColumn} /> ); diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearchAutocomplete.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearchAutocomplete.tsx index a531d1d57a..0d72aa8b43 100644 --- a/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearchAutocomplete.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearchAutocomplete.tsx @@ -2,10 +2,13 @@ import { mdiMagnify } from '@mdi/js'; import Icon from '@mdi/react'; import Autocomplete from '@mui/material/Autocomplete'; import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; import ListItem from '@mui/material/ListItem'; import Stack from '@mui/material/Stack'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; +import { ScientificNameTypography } from 'features/surveys/animals/components/ScientificNameTypography'; +import { useTaxonomyContext } from 'hooks/useContext'; import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; import { debounce } from 'lodash-es'; import { useMemo, useState } from 'react'; @@ -39,6 +42,20 @@ export interface IMeasurementsSearchAutocompleteProps { * @memberof IMeasurementsSearchAutocompleteProps */ speciesTsn?: number[]; + /** + * Measurements applied to any of these TSNs will have the ornament applied to them in the options list + * + * @type {number[]} + * @memberof IMeasurementsSearchAutocompleteProps + */ + applicableTsns?: number[]; + /** + * Ornament to display on the option card, typically indicating whether focal species can have the measurement + * + * @type {JSX.Element} + * @memberof IMeasurementSearchAutocompleteProps + */ + ornament?: JSX.Element; } /** @@ -48,10 +65,11 @@ export interface IMeasurementsSearchAutocompleteProps { * @return {*} */ export const MeasurementsSearchAutocomplete = (props: IMeasurementsSearchAutocompleteProps) => { - const { selectedOptions, getOptions, onAddMeasurementColumn } = props; + const { selectedOptions, getOptions, onAddMeasurementColumn, ornament, applicableTsns } = props; const [inputValue, setInputValue] = useState(''); const [options, setOptions] = useState([]); + const [isLoading, setIsLoading] = useState(false); const handleSearch = useMemo( () => @@ -62,14 +80,17 @@ export const MeasurementsSearchAutocomplete = (props: IMeasurementsSearchAutocom [getOptions] ); + const taxonomyContext = useTaxonomyContext(); + return ( option.measurement_name} @@ -103,8 +124,10 @@ export const MeasurementsSearchAutocomplete = (props: IMeasurementsSearchAutocom } setInputValue(value); + setIsLoading(true); handleSearch(value, (newOptions) => { setOptions(() => newOptions); + setIsLoading(false); }); }} value={null} // The selected value is not displayed in the input field or tracked by this component @@ -116,53 +139,49 @@ export const MeasurementsSearchAutocomplete = (props: IMeasurementsSearchAutocom } }} renderOption={(renderProps, renderOption) => { + const isApplicable = renderOption.itis_tsn && applicableTsns?.includes(renderOption.itis_tsn); + return ( - - - - {renderOption.itis_tsn} - - {/* - {renderOption.commonNames ? ( - <> - {renderOption.commonNames}  - - ({renderOption.scientificName}) - - - ) : ( - {renderOption.scientificName} - )} - */} - - - - {renderOption.measurement_name} - - + + - {renderOption.measurement_desc} - + name={ + renderOption.itis_tsn + ? taxonomyContext.getCachedSpeciesTaxonomyById(renderOption.itis_tsn)?.scientificName ?? '' + : '' + } + /> + {isApplicable && ornament} + + {renderOption.measurement_name} + + + {renderOption.measurement_desc} + ); @@ -180,6 +199,12 @@ export const MeasurementsSearchAutocomplete = (props: IMeasurementsSearchAutocom + ), + endAdornment: ( + <> + {inputValue && isLoading ? : null} + {params.InputProps.endAdornment} + ) }} data-testid="measurements-autocomplete-input" diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx index 06e7e9315c..fe13e339a2 100644 --- a/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx @@ -40,6 +40,7 @@ export const TaxonomyColDef = (props: { return { field: 'itis_tsn', headerName: 'Species', + description: 'The observed species, or if the species is unknown, a higher taxon', editable: true, hideable: true, flex: 1, @@ -67,6 +68,7 @@ export const SampleSiteColDef = (props: { return { field: 'survey_sample_site_id', + description: 'A sampling site where the observation was made', headerName: 'Site', editable: true, hideable: true, @@ -111,6 +113,7 @@ export const SampleMethodColDef = (props: { return { field: 'survey_sample_method_id', headerName: 'Method', + description: 'A method with which the observation was made', editable: true, hideable: true, flex: 1, @@ -158,6 +161,7 @@ export const SamplePeriodColDef = (props: { return { field: 'survey_sample_period_id', headerName: 'Period', + description: 'A sampling period in which the observation was made', editable: true, hideable: true, flex: 0, @@ -211,6 +215,7 @@ export const ObservationCountColDef = (props: { return { field: 'count', headerName: 'Count', + description: 'The number of individuals observed', editable: true, hideable: true, type: 'number', @@ -271,6 +276,7 @@ export const ObservationQuantitativeMeasurementColDef = (props: { return { field: measurement.taxon_measurement_id, headerName: measurement.measurement_name, + description: measurement.measurement_desc ?? '', editable: true, hideable: true, sortable: false, @@ -326,6 +332,7 @@ export const ObservationQualitativeMeasurementColDef = (props: { return { field: measurement.taxon_measurement_id, headerName: measurement.measurement_name, + description: measurement.measurement_desc ?? '', editable: true, hideable: true, sortable: false, @@ -355,6 +362,7 @@ export const ObservationQuantitativeEnvironmentColDef = (props: { return { field: String(environment.environment_quantitative_id), headerName: environment.name, + description: environment.description ?? '', editable: true, hideable: true, sortable: false, @@ -409,6 +417,7 @@ export const ObservationQualitativeEnvironmentColDef = (props: { return { field: String(environment.environment_qualitative_id), headerName: environment.name, + description: environment.description ?? '', editable: true, hideable: true, sortable: false, diff --git a/app/src/features/surveys/observations/observations-table/observation-row-validation/ObservationRowValidationUtils.ts b/app/src/features/surveys/observations/observations-table/observation-row-validation/ObservationRowValidationUtils.ts index 949b4d779a..814986e8df 100644 --- a/app/src/features/surveys/observations/observations-table/observation-row-validation/ObservationRowValidationUtils.ts +++ b/app/src/features/surveys/observations/observations-table/observation-row-validation/ObservationRowValidationUtils.ts @@ -34,7 +34,7 @@ export const validateObservationTableRowMeasurements = async ( return []; } - const taxonMeasurements = await getTsnMeasurementTypeDefinitionMap(Number(row.itis_tsn)); + const taxonMeasurements = await getTsnMeasurementTypeDefinitionMap(row.itis_tsn); if (!taxonMeasurements) { // This taxon has no valid measurements, return an error diff --git a/app/src/features/surveys/observations/sampling-sites/SamplingSiteListContainer.tsx b/app/src/features/surveys/observations/sampling-sites/SamplingSiteListContainer.tsx index 6212c09039..48a0ba96d3 100644 --- a/app/src/features/surveys/observations/sampling-sites/SamplingSiteListContainer.tsx +++ b/app/src/features/surveys/observations/sampling-sites/SamplingSiteListContainer.tsx @@ -19,7 +19,7 @@ import Typography from '@mui/material/Typography'; import { SkeletonList } from 'components/loading/SkeletonLoaders'; import { SamplingSiteListSite } from 'features/surveys/observations/sampling-sites/components/SamplingSiteListSite'; import { useBiohubApi } from 'hooks/useBioHubApi'; -import { useCodesContext, useDialogContext, useObservationsPageContext, useSurveyContext } from 'hooks/useContext'; +import { useDialogContext, useObservationsPageContext, useSurveyContext } from 'hooks/useContext'; import { useEffect, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; @@ -30,15 +30,10 @@ import { Link as RouterLink } from 'react-router-dom'; */ export const SamplingSiteListContainer = () => { const surveyContext = useSurveyContext(); - const codesContext = useCodesContext(); const dialogContext = useDialogContext(); const observationsPageContext = useObservationsPageContext(); const biohubApi = useBiohubApi(); - useEffect(() => { - codesContext.codesDataLoader.load(); - }, [codesContext.codesDataLoader]); - useEffect(() => { surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -295,7 +290,7 @@ export const SamplingSiteListContainer = () => { - {surveyContext.sampleSiteDataLoader.isLoading || codesContext.codesDataLoader.isLoading ? ( + {surveyContext.sampleSiteDataLoader.isLoading ? ( ) : ( diff --git a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListMethod.tsx b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListMethod.tsx index aed4ad87ba..39be47b8e5 100644 --- a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListMethod.tsx +++ b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListMethod.tsx @@ -3,9 +3,8 @@ import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; import ListItemText from '@mui/material/ListItemText'; import { SamplingSiteListPeriod } from 'features/surveys/observations/sampling-sites/components/SamplingSiteListPeriod'; -import { useCodesContext, useObservationsContext, useObservationsPageContext } from 'hooks/useContext'; +import { useObservationsContext, useObservationsPageContext } from 'hooks/useContext'; import { IGetSampleMethodDetails } from 'interfaces/useSamplingSiteApi.interface'; -import { useEffect } from 'react'; export interface ISamplingSiteListMethodProps { sampleMethod: IGetSampleMethodDetails; @@ -20,14 +19,9 @@ export interface ISamplingSiteListMethodProps { export const SamplingSiteListMethod = (props: ISamplingSiteListMethodProps) => { const { sampleMethod } = props; - const codesContext = useCodesContext(); const observationsPageContext = useObservationsPageContext(); const observationsContext = useObservationsContext(); - useEffect(() => { - codesContext.codesDataLoader.load(); - }, [codesContext.codesDataLoader]); - return ( { const staticLayers: IStaticLayer[] = [ { layerName: 'Sample Sites', - layerColors: { color: blue[500], fillColor: blue[500] }, + layerOptions: { color: blue[500], fillColor: blue[500] }, features: [ { - key: sampleSite.survey_sample_site_id, + id: sampleSite.survey_sample_site_id, + key: `sampling-site-${sampleSite.survey_sample_site_id}`, geoJSON: sampleSite.geojson } ] @@ -161,7 +162,7 @@ export const SamplingSiteListSite = (props: ISamplingSiteListSiteProps) => { })} - + diff --git a/app/src/features/surveys/sampling-information/SamplingRouter.tsx b/app/src/features/surveys/sampling-information/SamplingRouter.tsx index 0917b2df2c..dd35c9af94 100644 --- a/app/src/features/surveys/sampling-information/SamplingRouter.tsx +++ b/app/src/features/surveys/sampling-information/SamplingRouter.tsx @@ -4,8 +4,8 @@ import { DialogContextProvider } from 'contexts/dialogContext'; import { SamplingSiteManagePage } from 'features/surveys/sampling-information/manage/SamplingSiteManagePage'; import { CreateSamplingSitePage } from 'features/surveys/sampling-information/sites/create/CreateSamplingSitePage'; import { EditSamplingSitePage } from 'features/surveys/sampling-information/sites/edit/EditSamplingSitePage'; -import { CreateTechniquePage } from 'features/surveys/sampling-information/techniques/form/create/CreateTechniquePage'; -import { EditTechniquePage } from 'features/surveys/sampling-information/techniques/form/edit/EditTechniquePage'; +import { CreateTechniquePage } from 'features/surveys/sampling-information/techniques/create/CreateTechniquePage'; +import { EditTechniquePage } from 'features/surveys/sampling-information/techniques/edit/EditTechniquePage'; import { Switch } from 'react-router'; import RouteWithTitle from 'utils/RouteWithTitle'; import { getTitle } from 'utils/Utils'; diff --git a/app/src/features/surveys/sampling-information/manage/SamplingSiteManagePage.tsx b/app/src/features/surveys/sampling-information/manage/SamplingSiteManagePage.tsx index f38c90653e..7830445b02 100644 --- a/app/src/features/surveys/sampling-information/manage/SamplingSiteManagePage.tsx +++ b/app/src/features/surveys/sampling-information/manage/SamplingSiteManagePage.tsx @@ -2,9 +2,9 @@ import Container from '@mui/material/Container'; import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; import { SamplingSiteManageHeader } from 'features/surveys/sampling-information/manage/SamplingSiteManageHeader'; -import { SamplingSiteManageSiteList } from 'features/surveys/sampling-information/sites/manage/SamplingSiteManageSiteList'; import { SamplingTechniqueContainer } from 'features/surveys/sampling-information/techniques/SamplingTechniqueContainer'; import { useProjectContext, useSurveyContext } from 'hooks/useContext'; +import SamplingSiteContainer from '../sites/SamplingSiteContainer'; /** * Page for managing sampling information (sampling techniques and sites). @@ -29,7 +29,7 @@ export const SamplingSiteManagePage = () => { - + diff --git a/app/src/features/surveys/sampling-information/methods/components/SamplingMethodForm.tsx b/app/src/features/surveys/sampling-information/methods/components/SamplingMethodForm.tsx index 365e6b5a8f..2a9f7b03f2 100644 --- a/app/src/features/surveys/sampling-information/methods/components/SamplingMethodForm.tsx +++ b/app/src/features/surveys/sampling-information/methods/components/SamplingMethodForm.tsx @@ -3,7 +3,6 @@ import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import AutocompleteField, { IAutocompleteFieldOption } from 'components/fields/AutocompleteField'; import CustomTextField from 'components/fields/CustomTextField'; -import SelectWithSubtextField from 'components/fields/SelectWithSubtext'; import { CodesContext } from 'contexts/codesContext'; import { useFormikContext } from 'formik'; import { useSurveyContext } from 'hooks/useContext'; @@ -87,7 +86,7 @@ export const SamplingMethodForm = () => { Details - - {dayjs(item.start_date).format(DATE_FORMAT.MediumDateFormat)} –  - {dayjs(item.end_date).format(DATE_FORMAT.MediumDateFormat)} - + + + {dayjs(item.start_date).format(DATE_FORMAT.MediumDateFormat)}  + + {item.start_time} + + + – + + {dayjs(item.end_date).format(DATE_FORMAT.MediumDateFormat)}  + + {item.end_time} + + + } action={ } /> - - {/* */} - ))} diff --git a/app/src/features/surveys/sampling-information/periods/table/SamplingPeriodTable.tsx b/app/src/features/surveys/sampling-information/periods/table/SamplingPeriodTable.tsx new file mode 100644 index 0000000000..ea9bcec0f8 --- /dev/null +++ b/app/src/features/surveys/sampling-information/periods/table/SamplingPeriodTable.tsx @@ -0,0 +1,106 @@ +import Typography from '@mui/material/Typography'; +import { GridColDef } from '@mui/x-data-grid'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import dayjs from 'dayjs'; +import { useCodesContext } from 'hooks/useContext'; +import { getCodesName } from 'utils/Utils'; + +export interface ISamplingSitePeriodRowData { + id: number; + sample_site: string; + sample_method: string; + method_response_metric_id: number; + start_date: string; + end_date: string; + start_time: string | null; + end_time: string | null; +} + +interface ISamplingPeriodTableProps { + periods: ISamplingSitePeriodRowData[]; +} + +/** + * Renders a table of sampling periods. + * + * @param props {} + * @returns {*} + */ +export const SamplingPeriodTable = (props: ISamplingPeriodTableProps) => { + const { periods } = props; + + const codesContext = useCodesContext(); + + const columns: GridColDef[] = [ + { + field: 'sample_site', + headerName: 'Site', + flex: 1 + }, + { + field: 'sample_method', + headerName: 'Technique', + flex: 1 + }, + { + field: 'method_response_metric_id', + headerName: 'Response Metric', + flex: 1, + renderCell: (params) => ( + <> + {getCodesName( + codesContext.codesDataLoader.data, + 'method_response_metrics', + params.row.method_response_metric_id + )} + + ) + }, + { + field: 'start_date', + headerName: 'Start date', + flex: 1, + renderCell: (params) => ( + {dayjs(params.row.start_date).format(DATE_FORMAT.MediumDateFormat)} + ) + }, + { + field: 'start_time', + headerName: 'Start time', + flex: 1 + }, + { + field: 'end_date', + headerName: 'End date', + flex: 1, + renderCell: (params) => ( + {dayjs(params.row.end_date).format(DATE_FORMAT.MediumDateFormat)} + ) + }, + { + field: 'end_time', + headerName: 'End time', + flex: 1 + } + ]; + + return ( + 'auto'} + disableColumnMenu + rows={periods} + getRowId={(row: ISamplingSitePeriodRowData) => row.id} + columns={columns} + checkboxSelection={false} + disableRowSelectionOnClick + initialState={{ + pagination: { + paginationModel: { page: 1, pageSize: 10 } + } + }} + pageSizeOptions={[10, 25, 50]} + /> + ); +}; diff --git a/app/src/features/surveys/sampling-information/sites/SamplingSiteContainer.tsx b/app/src/features/surveys/sampling-information/sites/SamplingSiteContainer.tsx new file mode 100644 index 0000000000..ffa68724c9 --- /dev/null +++ b/app/src/features/surveys/sampling-information/sites/SamplingSiteContainer.tsx @@ -0,0 +1,265 @@ +import { mdiArrowTopRight, mdiDotsVertical, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; +import IconButton from '@mui/material/IconButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Paper from '@mui/material/Paper'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import { GridRowSelectionModel } from '@mui/x-data-grid'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonMap, SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; +import { + ISamplingSitePeriodRowData, + SamplingPeriodTable +} from 'features/surveys/sampling-information/periods/table/SamplingPeriodTable'; +import { SamplingSiteMapContainer } from 'features/surveys/sampling-information/sites/map/SamplingSiteMapContainer'; +import { SamplingSiteTable } from 'features/surveys/sampling-information/sites/table/SamplingSiteTable'; +import { + SamplingSiteManageTableView, + SamplingSiteTabs +} from 'features/surveys/sampling-information/sites/table/SamplingSiteTabs'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useDialogContext, useSurveyContext } from 'hooks/useContext'; +import { useEffect, useMemo, useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; + +/** + * Component for managing sampling sites, methods, and periods. + * Returns a map and data grids displaying sampling information. + * + * @returns {*} + */ +const SamplingSiteContainer = () => { + const surveyContext = useSurveyContext(); + const dialogContext = useDialogContext(); + const biohubApi = useBiohubApi(); + + // State for bulk actions + const [headerAnchorEl, setHeaderAnchorEl] = useState(null); + const [siteSelection, setSiteSelection] = useState([]); + + // Controls whether sites, methods, or periods are shown + const [activeView, setActiveView] = useState(SamplingSiteManageTableView.SITES); + + const sampleSites = useMemo( + () => surveyContext.sampleSiteDataLoader.data?.sampleSites ?? [], + [surveyContext.sampleSiteDataLoader.data?.sampleSites] + ); + const sampleSiteCount = surveyContext.sampleSiteDataLoader.data?.pagination.total ?? 0; + + const samplePeriods: ISamplingSitePeriodRowData[] = useMemo(() => { + const data: ISamplingSitePeriodRowData[] = []; + + for (const site of sampleSites) { + for (const method of site.sample_methods) { + for (const period of method.sample_periods) { + data.push({ + id: period.survey_sample_period_id, + sample_site: site.name, + sample_method: method.technique.name, + method_response_metric_id: method.method_response_metric_id, + start_date: period.start_date, + end_date: period.end_date, + start_time: period.start_time, + end_time: period.end_time + }); + } + } + } + + return data; + }, [sampleSites]); + + useEffect(() => { + surveyContext.sampleSiteDataLoader.load(surveyContext.projectId, surveyContext.surveyId); + }, [surveyContext.sampleSiteDataLoader, surveyContext.projectId, surveyContext.surveyId]); + + // Handler for bulk delete operation + const handleBulkDelete = async () => { + try { + await biohubApi.samplingSite.deleteSampleSites( + surveyContext.projectId, + surveyContext.surveyId, + siteSelection.map((site) => Number(site)) // Convert GridRowId to number[] + ); + dialogContext.setYesNoDialog({ open: false }); // Close confirmation dialog + setSiteSelection([]); // Clear selection + surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); // Refresh data + } catch (error) { + dialogContext.setYesNoDialog({ open: false }); // Close confirmation dialog on error + setSiteSelection([]); // Clear selection + // Show snackbar with error message + dialogContext.setSnackbar({ + snackbarMessage: ( + <> + + Error Deleting Items + + + {String(error)} + + + ), + open: true + }); + } + }; + + // Handler for clicking on header menu (bulk actions) + const handleHeaderMenuClick = (event: React.MouseEvent) => { + setHeaderAnchorEl(event.currentTarget); + }; + + // Handler for confirming bulk delete operation + const handlePromptConfirmBulkDelete = () => { + setHeaderAnchorEl(null); // Close header menu + dialogContext.setYesNoDialog({ + dialogTitle: 'Delete Sampling Sites?', + dialogContent: ( + + Are you sure you want to delete the selected sampling sites? + + ), + yesButtonLabel: 'Delete Sampling Sites', + noButtonLabel: 'Cancel', + yesButtonProps: { color: 'error' }, + onClose: () => dialogContext.setYesNoDialog({ open: false }), + onNo: () => dialogContext.setYesNoDialog({ open: false }), + open: true, + onYes: handleBulkDelete + }); + }; + + // Counts for the toggle button labels + const viewCounts = { + [SamplingSiteManageTableView.SITES]: sampleSiteCount, + [SamplingSiteManageTableView.PERIODS]: samplePeriods.length + }; + + return ( + <> + {/* Bulk action menu */} + setHeaderAnchorEl(null)} + anchorEl={headerAnchorEl} + anchorOrigin={{ vertical: 'top', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }}> + + + + + Delete + + + + + + + Sampling Sites ‌ + + ({sampleSiteCount}) + + + + + + + + + + + + + + + + } + isLoadingFallbackDelay={100}> + + + {/* Toggle buttons for changing between sites, methods, and periods */} + + + + + {/* Data tables */} + + {activeView === SamplingSiteManageTableView.SITES && ( + } + isLoadingFallbackDelay={100} + hasNoData={!viewCounts[SamplingSiteManageTableView.SITES]} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + + )} + + {activeView === SamplingSiteManageTableView.PERIODS && ( + } + isLoadingFallbackDelay={100} + hasNoData={!viewCounts[SamplingSiteManageTableView.PERIODS]} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + + )} + + + + + + ); +}; + +export default SamplingSiteContainer; diff --git a/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteEditMapControl.tsx b/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteEditMapControl.tsx index 2273cbe10d..9a5614b6ed 100644 --- a/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteEditMapControl.tsx +++ b/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteEditMapControl.tsx @@ -107,7 +107,11 @@ const SamplingSiteEditMapControl = (props: ISamplingSiteEditMapControlProps) => const staticLayers: IStaticLayer[] = [ { layerName: 'Sampling Sites', - features: samplingSiteGeoJsonFeatures.map((feature: Feature, index) => ({ geoJSON: feature, key: index })) + features: samplingSiteGeoJsonFeatures.map((feature: Feature, index) => ({ + id: feature.id || index, + key: `sampling-site-${feature.id || index}`, + geoJSON: feature + })) } ]; diff --git a/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl.tsx b/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl.tsx index 2422f0fd58..9085af7c82 100644 --- a/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl.tsx +++ b/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl.tsx @@ -216,8 +216,12 @@ const SamplingSiteMapControl = (props: ISamplingSiteMapControlProps) => { { layerName: 'Sampling Sites', features: samplingSiteGeoJsonFeatures - .filter((item) => item?.id) // Filter for only drawn features - .map((feature, index) => ({ geoJSON: feature, key: index })) + .filter((feature) => feature?.id) // Filter for only drawn features + .map((feature, index) => ({ + id: feature.id || index, + key: `sampling-site-${feature.id || index}`, + geoJSON: feature + })) } ]} /> diff --git a/app/src/features/surveys/sampling-information/sites/components/site-groupings/SamplingStratumForm.tsx b/app/src/features/surveys/sampling-information/sites/components/site-groupings/SamplingStratumForm.tsx index 967fc74b0d..459d5433e4 100644 --- a/app/src/features/surveys/sampling-information/sites/components/site-groupings/SamplingStratumForm.tsx +++ b/app/src/features/surveys/sampling-information/sites/components/site-groupings/SamplingStratumForm.tsx @@ -13,7 +13,7 @@ import { SurveyContext } from 'contexts/surveyContext'; import { BlockStratumCard } from 'features/surveys/sampling-information/sites/components/site-groupings/BlockStratumCard'; import { useFormikContext } from 'formik'; import { IGetSampleLocationDetails, IGetSampleStratumDetails } from 'interfaces/useSamplingSiteApi.interface'; -import { IGetSurveyStratum } from 'interfaces/useSurveyApi.interface'; +import { IGetSurveyStratum, IPostSurveyStratum } from 'interfaces/useSurveyApi.interface'; import { useContext, useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; @@ -30,7 +30,7 @@ export const SamplingStratumForm = () => { const [searchText, setSearchText] = useState(''); - const handleAddStratum = (stratum: IGetSurveyStratum) => { + const handleAddStratum = (stratum: IPostSurveyStratum | IGetSurveyStratum) => { setFieldValue(`stratums[${values.stratums.length}]`, stratum); }; const handleRemoveItem = (stratum: IGetSurveyStratum | IGetSampleStratumDetails) => { @@ -59,7 +59,7 @@ export const SamplingStratumForm = () => { noOptionsText="No records found" options={options} filterOptions={(options, state) => { - const searchFilter = createFilterOptions({ ignoreCase: true }); + const searchFilter = createFilterOptions({ ignoreCase: true }); const unselectedOptions = options.filter((option) => values.stratums.every((existing) => existing.survey_stratum_id !== option.survey_stratum_id) ); diff --git a/app/src/features/surveys/sampling-information/sites/create/CreateSamplingSitePage.tsx b/app/src/features/surveys/sampling-information/sites/create/CreateSamplingSitePage.tsx index 8fe0550e6e..454dcf5be8 100644 --- a/app/src/features/surveys/sampling-information/sites/create/CreateSamplingSitePage.tsx +++ b/app/src/features/surveys/sampling-information/sites/create/CreateSamplingSitePage.tsx @@ -124,7 +124,7 @@ export const CreateSamplingSitePage = () => { validateOnBlur={true} validateOnChange={false} onSubmit={handleSubmit}> - + ) => void; - isChecked?: boolean; - handleCheckboxChange?: (sampleSiteId: number) => void; -} - -export const SamplingSiteCard = (props: ISamplingSiteCardProps) => { - const { sampleSite, handleMenuClick, handleCheckboxChange, isChecked } = props; - - return ( - - - {handleCheckboxChange && ( - - { - event.stopPropagation(); - handleCheckboxChange(sampleSite.survey_sample_site_id); - }} - inputProps={{ 'aria-label': 'controlled' }} - /> - - )} - - {sampleSite.name} - - - - {sampleSite.description} - - - } - detailsContent={ - - {sampleSite.description} - {sampleSite.name} - - } - onMenuClick={handleMenuClick} - /> - ); -}; diff --git a/app/src/features/surveys/sampling-information/sites/manage/SamplingSiteManageSiteList.tsx b/app/src/features/surveys/sampling-information/sites/manage/SamplingSiteManageSiteList.tsx deleted file mode 100644 index 2766f741fb..0000000000 --- a/app/src/features/surveys/sampling-information/sites/manage/SamplingSiteManageSiteList.tsx +++ /dev/null @@ -1,367 +0,0 @@ -import { mdiDotsVertical, mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Checkbox from '@mui/material/Checkbox'; -import grey from '@mui/material/colors/grey'; -import Divider from '@mui/material/Divider'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import FormGroup from '@mui/material/FormGroup'; -import IconButton from '@mui/material/IconButton'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import ListItemText from '@mui/material/ListItemText'; -import Menu, { MenuProps } from '@mui/material/Menu'; -import MenuItem from '@mui/material/MenuItem'; -import Paper from '@mui/material/Paper'; -import Stack from '@mui/material/Stack'; -import Toolbar from '@mui/material/Toolbar'; -import Typography from '@mui/material/Typography'; -import { LoadingGuard } from 'components/loading/LoadingGuard'; -import { SkeletonMap, SkeletonTable } from 'components/loading/SkeletonLoaders'; -import { SamplingSiteCard } from 'features/surveys/sampling-information/sites/manage/SamplingSiteCard'; -import { SamplingSiteMapContainer } from 'features/surveys/sampling-information/sites/manage/SamplingSiteMapContainer'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { useCodesContext, useDialogContext, useSurveyContext } from 'hooks/useContext'; -import { useEffect, useState } from 'react'; -import { Link as RouterLink } from 'react-router-dom'; - -/** - * Renders a list of sampling sites. - * - * @return {*} - */ -export const SamplingSiteManageSiteList = () => { - const surveyContext = useSurveyContext(); - const codesContext = useCodesContext(); - const dialogContext = useDialogContext(); - const biohubApi = useBiohubApi(); - - useEffect(() => { - codesContext.codesDataLoader.load(); - }, [codesContext.codesDataLoader]); - - useEffect(() => { - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const [sampleSiteAnchorEl, setSampleSiteAnchorEl] = useState(null); - const [headerAnchorEl, setHeaderAnchorEl] = useState(null); - const [selectedSampleSiteId, setSelectedSampleSiteId] = useState(); - const [checkboxSelectedIds, setCheckboxSelectedIds] = useState([]); - - const sampleSites = surveyContext.sampleSiteDataLoader.data?.sampleSites ?? []; - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const handleSampleSiteMenuClick = ( - event: React.MouseEvent, - sample_site_id: number - ) => { - setSampleSiteAnchorEl(event.currentTarget); - setSelectedSampleSiteId(sample_site_id); - }; - - const handleHeaderMenuClick = (event: React.MouseEvent) => { - setHeaderAnchorEl(event.currentTarget); - }; - - /** - * Handle the delete sampling site API call. - * - */ - const handleDeleteSampleSite = async () => { - await biohubApi.samplingSite - .deleteSampleSite(surveyContext.projectId, surveyContext.surveyId, Number(selectedSampleSiteId)) - .then(() => { - dialogContext.setYesNoDialog({ open: false }); - setSampleSiteAnchorEl(null); - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); - }) - .catch((error: any) => { - dialogContext.setYesNoDialog({ open: false }); - setSampleSiteAnchorEl(null); - dialogContext.setSnackbar({ - snackbarMessage: ( - <> - - Error Deleting Sampling Site - - - {String(error)} - - - ), - open: true - }); - }); - }; - - /** - * Display the delete sampling site dialog. - * - */ - const deleteSampleSiteDialog = () => { - dialogContext.setYesNoDialog({ - dialogTitle: 'Delete Sampling Site?', - dialogContent: ( - - Are you sure you want to delete this sampling site? - - ), - yesButtonLabel: 'Delete Sampling Site', - noButtonLabel: 'Cancel', - yesButtonProps: { color: 'error' }, - onClose: () => { - dialogContext.setYesNoDialog({ open: false }); - }, - onNo: () => { - dialogContext.setYesNoDialog({ open: false }); - }, - open: true, - onYes: () => { - handleDeleteSampleSite(); - } - }); - }; - - const handleBulkDeleteSampleSites = async () => { - await biohubApi.samplingSite - .deleteSampleSites(surveyContext.projectId, surveyContext.surveyId, checkboxSelectedIds) - .then(() => { - dialogContext.setYesNoDialog({ open: false }); - setCheckboxSelectedIds([]); - setHeaderAnchorEl(null); - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); - }) - .catch((error: any) => { - dialogContext.setYesNoDialog({ open: false }); - setCheckboxSelectedIds([]); - setHeaderAnchorEl(null); - dialogContext.setSnackbar({ - snackbarMessage: ( - <> - - Error Deleting Sampling Sites - - - {String(error)} - - - ), - open: true - }); - }); - }; - - const handlePromptConfirmBulkDelete = () => { - dialogContext.setYesNoDialog({ - dialogTitle: 'Delete Sampling Sites?', - dialogContent: ( - - Are you sure you want to delete the selected sampling sites? - - ), - yesButtonLabel: 'Delete Sampling Sites', - noButtonLabel: 'Cancel', - yesButtonProps: { color: 'error' }, - onClose: () => { - dialogContext.setYesNoDialog({ open: false }); - }, - onNo: () => { - dialogContext.setYesNoDialog({ open: false }); - }, - open: true, - onYes: () => { - handleBulkDeleteSampleSites(); - } - }); - }; - - const handleCheckboxChange = (sampleSiteId: number) => { - setCheckboxSelectedIds((prev) => { - if (prev.includes(sampleSiteId)) { - return prev.filter((item) => item !== sampleSiteId); - } else { - return [...prev, sampleSiteId]; - } - }); - }; - - const samplingSiteCount = sampleSites.length ?? 0; - - return ( - <> - setSampleSiteAnchorEl(null)} - anchorEl={sampleSiteAnchorEl} - anchorOrigin={{ - vertical: 'top', - horizontal: 'right' - }} - transformOrigin={{ - vertical: 'top', - horizontal: 'right' - }}> - - - - - - Edit Details - - - - - - - Delete - - - - setHeaderAnchorEl(null)} - anchorEl={headerAnchorEl} - anchorOrigin={{ - vertical: 'top', - horizontal: 'right' - }} - transformOrigin={{ - vertical: 'top', - horizontal: 'right' - }}> - - - - - Delete - - - - - - - Sites ‌ - - ({samplingSiteCount}) - - - - - - - - - - - - - - } - delay={200}> - - - - - - Select All - - } - control={ - 0 && checkboxSelectedIds.length === samplingSiteCount} - indeterminate={ - checkboxSelectedIds.length >= 1 && checkboxSelectedIds.length < samplingSiteCount - } - onClick={() => { - if (checkboxSelectedIds.length === samplingSiteCount) { - setCheckboxSelectedIds([]); - return; - } - - const sampleSiteIds = sampleSites.map((sampleSite) => sampleSite.survey_sample_site_id); - setCheckboxSelectedIds(sampleSiteIds); - }} - inputProps={{ 'aria-label': 'controlled' }} - /> - } - /> - - - - - {surveyContext.sampleSiteDataLoader.data?.sampleSites.map((sampleSite) => { - return ( - { - setSampleSiteAnchorEl(event.currentTarget); - setSelectedSampleSiteId(sampleSite.survey_sample_site_id); - }} - key={sampleSite.survey_sample_site_id} - /> - ); - })} - - - - - - - ); -}; diff --git a/app/src/features/surveys/sampling-information/sites/manage/SamplingSiteMapContainer.tsx b/app/src/features/surveys/sampling-information/sites/map/SamplingSiteMapContainer.tsx similarity index 75% rename from app/src/features/surveys/sampling-information/sites/manage/SamplingSiteMapContainer.tsx rename to app/src/features/surveys/sampling-information/sites/map/SamplingSiteMapContainer.tsx index 3efe5e4094..c9154faa58 100644 --- a/app/src/features/surveys/sampling-information/sites/manage/SamplingSiteMapContainer.tsx +++ b/app/src/features/surveys/sampling-information/sites/map/SamplingSiteMapContainer.tsx @@ -11,10 +11,11 @@ interface ISamplingSitesMapContainerProps { export const SamplingSiteMapContainer = (props: ISamplingSitesMapContainerProps) => { const staticLayers: IStaticLayer[] = props.samplingSites.map((sampleSite) => ({ layerName: 'Sample Sites', - layerColors: { color: blue[500], fillColor: blue[500] }, + layerOptions: { color: blue[500], fillColor: blue[500] }, features: [ { - key: sampleSite.survey_sample_site_id, + id: sampleSite.survey_sample_site_id, + key: `sample-site-${sampleSite.survey_sample_site_id}`, geoJSON: sampleSite.geojson } ] @@ -22,7 +23,7 @@ export const SamplingSiteMapContainer = (props: ISamplingSitesMapContainerProps) return ( - + ); }; diff --git a/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTable.tsx b/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTable.tsx new file mode 100644 index 0000000000..8d446333a9 --- /dev/null +++ b/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTable.tsx @@ -0,0 +1,256 @@ +import { mdiDotsVertical, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import { blueGrey } from '@mui/material/colors'; +import IconButton from '@mui/material/IconButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Menu, { MenuProps } from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Typography from '@mui/material/Typography'; +import { GridColDef, GridRowSelectionModel } from '@mui/x-data-grid'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { Feature } from 'geojson'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useDialogContext, useSurveyContext } from 'hooks/useContext'; +import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { getSamplingSiteSpatialType } from 'utils/spatial-utils'; + +export interface ISamplingSiteRowData { + id: number; + name: string; + description: string; + geojson: Feature; + blocks: string[]; + stratums: string[]; +} + +interface ISamplingSiteTableProps { + sites: IGetSampleLocationDetails[]; + bulkActionSites: GridRowSelectionModel; + setBulkActionSites: (selection: GridRowSelectionModel) => void; +} + +/** + * Returns a table of sampling sites with edit actions + * + * @param props {} + * @returns {*} + */ +export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { + const { sites, bulkActionSites, setBulkActionSites } = props; + + const biohubApi = useBiohubApi(); + const surveyContext = useSurveyContext(); + const dialogContext = useDialogContext(); + + const [actionMenuSite, setActionMenuSite] = useState(); + const [actionMenuAnchorEl, setActionMenuAnchorEl] = useState(null); + + const handleCloseActionMenu = () => { + setActionMenuAnchorEl(null); + }; + + const handleDeleteSamplingSite = async () => { + await biohubApi.samplingSite + .deleteSampleSite(surveyContext.projectId, surveyContext.surveyId, Number(actionMenuSite)) + .then(() => { + dialogContext.setYesNoDialog({ open: false }); + setActionMenuAnchorEl(null); + surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + }) + .catch((error: any) => { + dialogContext.setYesNoDialog({ open: false }); + setActionMenuAnchorEl(null); + dialogContext.setSnackbar({ + snackbarMessage: ( + <> + + Error Deleting SamplingSite + + + {String(error)} + + + ), + open: true + }); + }); + }; + + /** + * Display the delete samplingSite dialog. + * + */ + const deleteSamplingSiteDialog = () => { + dialogContext.setYesNoDialog({ + dialogTitle: 'Delete sampling site?', + dialogText: 'Are you sure you want to permanently delete this sampling site?', + yesButtonLabel: 'Delete Site', + noButtonLabel: 'Cancel', + yesButtonProps: { color: 'error' }, + onClose: () => { + dialogContext.setYesNoDialog({ open: false }); + }, + onNo: () => { + dialogContext.setYesNoDialog({ open: false }); + }, + open: true, + onYes: () => { + handleDeleteSamplingSite(); + } + }); + }; + + const rows: ISamplingSiteRowData[] = sites.map((site) => ({ + id: site.survey_sample_site_id, + name: site.name, + description: site.description || '', + geojson: site.geojson, + blocks: site.blocks.map((block) => block.name), + stratums: site.stratums.map((stratum) => stratum.name) + })); + + const columns: GridColDef[] = [ + { + field: 'name', + headerName: 'Name', + flex: 1 + }, + { + field: 'geometry_type', + headerName: 'Geometry', + flex: 1, + renderCell: (params) => ( + + + + ) + }, + { + field: 'description', + headerName: 'Description', + flex: 1 + }, + + { + field: 'blocks', + headerName: 'Blocks', + flex: 1, + renderCell: (params) => ( + + {params.row.blocks.map((block) => ( + + + + ))} + + ) + }, + { + field: 'stratums', + headerName: 'Strata', + flex: 1, + renderCell: (params) => ( + + {params.row.stratums.map((stratum) => ( + + + + ))} + + ) + }, + { + field: 'actions', + type: 'actions', + sortable: false, + width: 10, + align: 'right', + renderCell: (params) => { + return ( + + { + setActionMenuSite(params.row.id); + setActionMenuAnchorEl(event.currentTarget); + }}> + + + + ); + } + } + ]; + + return ( + <> + {/* ROW ACTION MENU */} + + + + + + + Edit Details + + + { + handleCloseActionMenu(); + deleteSamplingSiteDialog(); + }}> + + + + Delete + + + + {/* DATA TABLE */} + 'auto'} + disableColumnMenu + rows={rows} + getRowId={(row: ISamplingSiteRowData) => row.id} + columns={columns} + rowSelectionModel={bulkActionSites} + onRowSelectionModelChange={setBulkActionSites} + checkboxSelection + initialState={{ + pagination: { + paginationModel: { page: 1, pageSize: 10 } + } + }} + pageSizeOptions={[10, 25, 50]} + /> + + ); +}; diff --git a/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTabs.tsx b/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTabs.tsx new file mode 100644 index 0000000000..f7060fb206 --- /dev/null +++ b/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTabs.tsx @@ -0,0 +1,76 @@ +import { mdiCalendarRange, mdiMapMarker } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Button from '@mui/material/Button'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import Toolbar from '@mui/material/Toolbar'; +import { SetStateAction } from 'react'; + +export enum SamplingSiteManageTableView { + SITES = 'SITES', + PERIODS = 'PERIODS' +} + +interface ISamplingSiteManageTableView { + value: SamplingSiteManageTableView; + icon: React.ReactNode; +} + +export type ISamplingSiteCount = Record; + +interface ISamplingSiteTabsProps { + activeView: SamplingSiteManageTableView; + setActiveView: React.Dispatch>; + viewCounts: Record; +} + +/** + * Renders tab controls for the sampling site table, which allow the user to switch between viewing sites and periods. + * + * @param {ISamplingSiteTabsProps} props + * @return {*} + */ +export const SamplingSiteTabs = (props: ISamplingSiteTabsProps) => { + const { activeView, setActiveView, viewCounts } = props; + + const views: ISamplingSiteManageTableView[] = [ + { value: SamplingSiteManageTableView.SITES, icon: }, + { value: SamplingSiteManageTableView.PERIODS, icon: } + ]; + + const activeViewCount = viewCounts[activeView]; + + const updateDatasetView = (_: React.MouseEvent, view: SamplingSiteManageTableView) => { + if (view) { + setActiveView(view); + } + }; + + return ( + + + {views.map((view) => ( + + {view.value} ({activeViewCount}) + + ))} + + + ); +}; diff --git a/app/src/features/surveys/sampling-information/techniques/SamplingTechniqueContainer.tsx b/app/src/features/surveys/sampling-information/techniques/SamplingTechniqueContainer.tsx index a34f1facfb..fe860d44ad 100644 --- a/app/src/features/surveys/sampling-information/techniques/SamplingTechniqueContainer.tsx +++ b/app/src/features/surveys/sampling-information/techniques/SamplingTechniqueContainer.tsx @@ -1,5 +1,6 @@ import { mdiDotsVertical, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Divider from '@mui/material/Divider'; import IconButton from '@mui/material/IconButton'; @@ -18,7 +19,7 @@ import { useBiohubApi } from 'hooks/useBioHubApi'; import { useDialogContext, useSurveyContext } from 'hooks/useContext'; import { useEffect, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; -import { SamplingTechniqueCardContainer } from './components/SamplingTechniqueCardContainer'; +import { SamplingTechniqueTable } from './table/SamplingTechniqueTable'; /** * Renders a list of techniques. @@ -124,7 +125,7 @@ export const SamplingTechniqueContainer = () => { sx={{ flex: '0 0 auto', pr: 3, - pl: 2 + pl: 3 }}> Techniques ‌ @@ -157,13 +158,15 @@ export const SamplingTechniqueContainer = () => { } - delay={200}> - + isLoadingFallback={} + isLoadingFallbackDelay={100}> + + + ); diff --git a/app/src/features/surveys/sampling-information/techniques/components/NoTechniquesOverlay.tsx b/app/src/features/surveys/sampling-information/techniques/components/NoTechniquesOverlay.tsx deleted file mode 100644 index c47fde60b3..0000000000 --- a/app/src/features/surveys/sampling-information/techniques/components/NoTechniquesOverlay.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { mdiArrowTopRight } from '@mdi/js'; -import Icon from '@mdi/react'; -import Box from '@mui/material/Box'; -import grey from '@mui/material/colors/grey'; -import Typography from '@mui/material/Typography'; - -export const NoTechniquesOverlay = () => { - return ( - - - - Add a technique  - - - - Techniques describe how you collected data. You can apply your techniques to sampling sites, during which - you'll also create sampling periods that describe when a technique was conducted. - - - - ); -}; diff --git a/app/src/features/surveys/sampling-information/techniques/components/SamplingTechniqueCard.tsx b/app/src/features/surveys/sampling-information/techniques/components/SamplingTechniqueCard.tsx deleted file mode 100644 index 9a382b6ec1..0000000000 --- a/app/src/features/surveys/sampling-information/techniques/components/SamplingTechniqueCard.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import Box from '@mui/material/Box'; -import { blue } from '@mui/material/colors'; -import Stack from '@mui/material/Stack'; -import Typography from '@mui/material/Typography'; -import { AccordionCard } from 'components/accordion/AccordionCard'; -import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; -import { IGetTechniqueResponse } from 'interfaces/useTechniqueApi.interface'; - -interface ISamplingTechniqueCardProps { - technique: IGetTechniqueResponse; - method_lookup_name: string; - handleMenuClick?: (event: React.MouseEvent) => void; -} - -const SamplingTechniqueCard = (props: ISamplingTechniqueCardProps) => { - const { technique, method_lookup_name, handleMenuClick } = props; - - const attributes = [...technique.attributes.qualitative_attributes, ...technique.attributes.qualitative_attributes]; - - return ( - - - {technique.name} - - - - {technique.description} - - - } - detailsContent={ - - - - - Release time - - - - - {technique.description && ( - - - Technique comment - - - {technique.description} - - - )} - - {attributes.map((attribute) => ( - {attribute.method_technique_attribute_qualitative_id} - ))} - - } - onMenuClick={handleMenuClick} - /> - ); -}; - -export default SamplingTechniqueCard; diff --git a/app/src/features/surveys/sampling-information/techniques/form/components/TechniqueForm.tsx b/app/src/features/surveys/sampling-information/techniques/components/TechniqueForm.tsx similarity index 93% rename from app/src/features/surveys/sampling-information/techniques/form/components/TechniqueForm.tsx rename to app/src/features/surveys/sampling-information/techniques/components/TechniqueForm.tsx index cbe5eb6e2f..df999b5a4d 100644 --- a/app/src/features/surveys/sampling-information/techniques/form/components/TechniqueForm.tsx +++ b/app/src/features/surveys/sampling-information/techniques/components/TechniqueForm.tsx @@ -1,11 +1,11 @@ import Divider from '@mui/material/Divider'; import Stack from '@mui/material/Stack'; import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; -import { TechniqueAttributesForm } from 'features/surveys/sampling-information/techniques/form/components/attributes/TechniqueAttributesForm'; +import { TechniqueAttributesForm } from 'features/surveys/sampling-information/techniques/components/attributes/TechniqueAttributesForm'; import { CreateTechniqueFormValues, UpdateTechniqueFormValues -} from 'features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer'; +} from 'features/surveys/sampling-information/techniques/components/TechniqueFormContainer'; import { useFormikContext } from 'formik'; import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader from 'hooks/useDataLoader'; diff --git a/app/src/features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer.tsx b/app/src/features/surveys/sampling-information/techniques/components/TechniqueFormContainer.tsx similarity index 98% rename from app/src/features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer.tsx rename to app/src/features/surveys/sampling-information/techniques/components/TechniqueFormContainer.tsx index eecf6dd6a7..95a8f4a046 100644 --- a/app/src/features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer.tsx +++ b/app/src/features/surveys/sampling-information/techniques/components/TechniqueFormContainer.tsx @@ -1,6 +1,6 @@ import Stack from '@mui/material/Stack'; import FormikErrorSnackbar from 'components/alert/FormikErrorSnackbar'; -import { TechniqueForm } from 'features/surveys/sampling-information/techniques/form/components/TechniqueForm'; +import { TechniqueForm } from 'features/surveys/sampling-information/techniques/components/TechniqueForm'; import { Formik, FormikProps } from 'formik'; import { ICreateTechniqueRequest, IGetTechniqueResponse } from 'interfaces/useTechniqueApi.interface'; import { isDefined } from 'utils/Utils'; diff --git a/app/src/features/surveys/sampling-information/techniques/components/attractants/TechniqueAttractantsForm.tsx b/app/src/features/surveys/sampling-information/techniques/components/attractants/TechniqueAttractantsForm.tsx new file mode 100644 index 0000000000..d7160c0e8b --- /dev/null +++ b/app/src/features/surveys/sampling-information/techniques/components/attractants/TechniqueAttractantsForm.tsx @@ -0,0 +1,114 @@ +import { mdiClose } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Box from '@mui/material/Box'; +import Collapse from '@mui/material/Collapse'; +import grey from '@mui/material/colors/grey'; +import Grid from '@mui/material/Grid'; +import IconButton from '@mui/material/IconButton'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; +import AutocompleteField from 'components/fields/AutocompleteField'; +import { + CreateTechniqueFormValues, + UpdateTechniqueFormValues +} from 'features/surveys/sampling-information/techniques/components/TechniqueFormContainer'; + +import { useFormikContext } from 'formik'; +import { useCodesContext } from 'hooks/useContext'; +import { useEffect } from 'react'; +import { TransitionGroup } from 'react-transition-group'; + +/** + * Technique attractants form. + * + * @template FormValues + * @return {*} + */ +export const TechniqueAttractantsForm = < + FormValues extends CreateTechniqueFormValues | UpdateTechniqueFormValues +>() => { + const codesContext = useCodesContext(); + + const { values, setFieldValue } = useFormikContext(); + + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext.codesDataLoader]); + + const attractants = codesContext.codesDataLoader.data?.attractants ?? []; + + return ( + + + Attractants (optional) + ({ + value: option.id, + label: option.name, + description: option.description + })) + .filter( + (option) => !values.attractants.some((attractant) => attractant.attractant_lookup_id === option.value) + ) ?? [] + } + onChange={(_, value) => { + if (value?.value) { + setFieldValue('attractants', [...values.attractants, { attractant_lookup_id: value.value }]); + } + }} + /> + + + + {values.attractants.map((attractant, index) => { + const lookup = attractants.find((option) => option.id === attractant.attractant_lookup_id); + return ( + + + + {lookup?.name} + + {lookup?.description} + + + + { + setFieldValue( + 'attractants', + values.attractants.length > 1 ? values.attractants.filter((id) => id !== attractant) : [] + ); + }}> + + + + + + ); + })} + + + + ); +}; diff --git a/app/src/features/surveys/sampling-information/techniques/form/components/attributes/TechniqueAttributesForm.tsx b/app/src/features/surveys/sampling-information/techniques/components/attributes/TechniqueAttributesForm.tsx similarity index 97% rename from app/src/features/surveys/sampling-information/techniques/form/components/attributes/TechniqueAttributesForm.tsx rename to app/src/features/surveys/sampling-information/techniques/components/attributes/TechniqueAttributesForm.tsx index db8c22f651..52f12b6ebe 100644 --- a/app/src/features/surveys/sampling-information/techniques/form/components/attributes/TechniqueAttributesForm.tsx +++ b/app/src/features/surveys/sampling-information/techniques/components/attributes/TechniqueAttributesForm.tsx @@ -7,7 +7,7 @@ import { CreateTechniqueFormValues, TechniqueAttributeFormValues, UpdateTechniqueFormValues -} from 'features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer'; +} from 'features/surveys/sampling-information/techniques/components/TechniqueFormContainer'; import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; import { ITechniqueAttributeQualitative, ITechniqueAttributeQuantitative } from 'interfaces/useReferenceApi.interface'; import { TransitionGroup } from 'react-transition-group'; diff --git a/app/src/features/surveys/sampling-information/techniques/form/components/attributes/components/TechniqueAttributeForm.tsx b/app/src/features/surveys/sampling-information/techniques/components/attributes/components/TechniqueAttributeForm.tsx similarity index 92% rename from app/src/features/surveys/sampling-information/techniques/form/components/attributes/components/TechniqueAttributeForm.tsx rename to app/src/features/surveys/sampling-information/techniques/components/attributes/components/TechniqueAttributeForm.tsx index f45bd05ee8..a794356a34 100644 --- a/app/src/features/surveys/sampling-information/techniques/form/components/attributes/components/TechniqueAttributeForm.tsx +++ b/app/src/features/surveys/sampling-information/techniques/components/attributes/components/TechniqueAttributeForm.tsx @@ -6,17 +6,17 @@ import grey from '@mui/material/colors/grey'; import IconButton from '@mui/material/IconButton'; import Stack from '@mui/material/Stack'; import AutocompleteField from 'components/fields/AutocompleteField'; -import { TechniqueAttributeValueControl } from 'features/surveys/sampling-information/techniques/form/components/attributes/components/TechniqueAttributeValueControl'; +import { TechniqueAttributeValueControl } from 'features/surveys/sampling-information/techniques/components/attributes/components/TechniqueAttributeValueControl'; import { formatAttributesForAutoComplete, getAttributeId, getAttributeType, getRemainingAttributes -} from 'features/surveys/sampling-information/techniques/form/components/attributes/components/utils'; +} from 'features/surveys/sampling-information/techniques/components/attributes/components/utils'; import { CreateTechniqueFormValues, UpdateTechniqueFormValues -} from 'features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer'; +} from 'features/surveys/sampling-information/techniques/components/TechniqueFormContainer'; import { FieldArrayRenderProps, useFormikContext } from 'formik'; import { ITechniqueAttributeQualitative, ITechniqueAttributeQuantitative } from 'interfaces/useReferenceApi.interface'; import { useMemo } from 'react'; @@ -111,7 +111,6 @@ export const TechniqueAttributeForm = diff --git a/app/src/features/surveys/sampling-information/techniques/form/components/attributes/components/TechniqueAttributeValueControl.tsx b/app/src/features/surveys/sampling-information/techniques/components/attributes/components/TechniqueAttributeValueControl.tsx similarity index 88% rename from app/src/features/surveys/sampling-information/techniques/form/components/attributes/components/TechniqueAttributeValueControl.tsx rename to app/src/features/surveys/sampling-information/techniques/components/attributes/components/TechniqueAttributeValueControl.tsx index 70ea10f4f8..6d5830fd8a 100644 --- a/app/src/features/surveys/sampling-information/techniques/form/components/attributes/components/TechniqueAttributeValueControl.tsx +++ b/app/src/features/surveys/sampling-information/techniques/components/attributes/components/TechniqueAttributeValueControl.tsx @@ -3,13 +3,12 @@ import CustomTextField from 'components/fields/CustomTextField'; import { CreateTechniqueFormValues, UpdateTechniqueFormValues -} from 'features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer'; -import { FieldArrayRenderProps, useFormikContext } from 'formik'; +} from 'features/surveys/sampling-information/techniques/components/TechniqueFormContainer'; +import { useFormikContext } from 'formik'; import { ITechniqueAttributeQualitative, ITechniqueAttributeQuantitative } from 'interfaces/useReferenceApi.interface'; interface ITechniqueAttributeValueControlProps { selectedAttributeTypeDefinition?: ITechniqueAttributeQuantitative | ITechniqueAttributeQualitative; - arrayHelpers: FieldArrayRenderProps; index: number; } @@ -76,7 +75,7 @@ export const TechniqueAttributeValueControl = < return ( - - - - - - ({ - value: option.value as number, - label: option.label, - description: option.subText - }))} - onChange={(_, value) => { - if (value?.value) { - setFieldValue('method_lookup_id', value.value); - } - }} - /> - - - - + + + - + + ({ + value: option.value as number, + label: option.label, + description: option.subText + }))} + onChange={(_, value) => { + if (value?.value) { + setFieldValue('method_lookup_id', value.value); + } + }} + /> + + + + + ); }; diff --git a/app/src/features/surveys/sampling-information/techniques/form/create/CreateTechniquePage.tsx b/app/src/features/surveys/sampling-information/techniques/create/CreateTechniquePage.tsx similarity index 98% rename from app/src/features/surveys/sampling-information/techniques/form/create/CreateTechniquePage.tsx rename to app/src/features/surveys/sampling-information/techniques/create/CreateTechniquePage.tsx index 414390df00..c44f9db7d0 100644 --- a/app/src/features/surveys/sampling-information/techniques/form/create/CreateTechniquePage.tsx +++ b/app/src/features/surveys/sampling-information/techniques/create/CreateTechniquePage.tsx @@ -11,7 +11,7 @@ import PageHeader from 'components/layout/PageHeader'; import { CreateTechniqueI18N } from 'constants/i18n'; import TechniqueFormContainer, { CreateTechniqueFormValues -} from 'features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer'; +} from 'features/surveys/sampling-information/techniques/components/TechniqueFormContainer'; import { FormikProps } from 'formik'; import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; diff --git a/app/src/features/surveys/sampling-information/techniques/form/edit/EditTechniquePage.tsx b/app/src/features/surveys/sampling-information/techniques/edit/EditTechniquePage.tsx similarity index 98% rename from app/src/features/surveys/sampling-information/techniques/form/edit/EditTechniquePage.tsx rename to app/src/features/surveys/sampling-information/techniques/edit/EditTechniquePage.tsx index 45c742c015..4ed1ce7ba9 100644 --- a/app/src/features/surveys/sampling-information/techniques/form/edit/EditTechniquePage.tsx +++ b/app/src/features/surveys/sampling-information/techniques/edit/EditTechniquePage.tsx @@ -11,7 +11,7 @@ import PageHeader from 'components/layout/PageHeader'; import { EditTechniqueI18N } from 'constants/i18n'; import TechniqueFormContainer, { UpdateTechniqueFormValues -} from 'features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer'; +} from 'features/surveys/sampling-information/techniques/components/TechniqueFormContainer'; import { FormikProps } from 'formik'; import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; diff --git a/app/src/features/surveys/sampling-information/techniques/form/components/attractants/TechniqueAttractantsForm.tsx b/app/src/features/surveys/sampling-information/techniques/form/components/attractants/TechniqueAttractantsForm.tsx deleted file mode 100644 index 847fab3806..0000000000 --- a/app/src/features/surveys/sampling-information/techniques/form/components/attractants/TechniqueAttractantsForm.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { mdiClose } from '@mdi/js'; -import { Icon } from '@mdi/react'; -import Box from '@mui/material/Box'; -import Collapse from '@mui/material/Collapse'; -import grey from '@mui/material/colors/grey'; -import Grid from '@mui/material/Grid'; -import IconButton from '@mui/material/IconButton'; -import Paper from '@mui/material/Paper'; -import Typography from '@mui/material/Typography'; -import AutocompleteField from 'components/fields/AutocompleteField'; -import { - CreateTechniqueFormValues, - UpdateTechniqueFormValues -} from 'features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer'; - -import { useFormikContext } from 'formik'; -import { useCodesContext } from 'hooks/useContext'; -import { useEffect } from 'react'; -import { TransitionGroup } from 'react-transition-group'; - -/** - * Technique attractants form. - * - * @template FormValues - * @return {*} - */ -export const TechniqueAttractantsForm = < - FormValues extends CreateTechniqueFormValues | UpdateTechniqueFormValues ->() => { - const codesContext = useCodesContext(); - - const { values, setFieldValue } = useFormikContext(); - - useEffect(() => { - codesContext.codesDataLoader.load(); - }, [codesContext.codesDataLoader]); - - const attractants = codesContext.codesDataLoader.data?.attractants ?? []; - - return ( - <> - - - Attractants (optional) - ({ - value: option.id, - label: option.name, - description: option.description - })) - .filter( - (option) => !values.attractants.some((attractant) => attractant.attractant_lookup_id === option.value) - ) ?? [] - } - onChange={(_, value) => { - if (value?.value) { - setFieldValue('attractants', [...values.attractants, { attractant_lookup_id: value.value }]); - } - }} - /> - - - - {values.attractants.map((attractant, index) => { - const lookup = attractants.find((option) => option.id === attractant.attractant_lookup_id); - return ( - - - - {lookup?.name} - - {lookup?.description} - - - - { - setFieldValue( - 'attractants', - values.attractants.length > 1 ? values.attractants.filter((id) => id !== attractant) : [] - ); - }}> - - - - - - ); - })} - - - - - ); -}; diff --git a/app/src/features/surveys/sampling-information/techniques/components/SamplingTechniqueCardContainer.tsx b/app/src/features/surveys/sampling-information/techniques/table/SamplingTechniqueTable.tsx similarity index 74% rename from app/src/features/surveys/sampling-information/techniques/components/SamplingTechniqueCardContainer.tsx rename to app/src/features/surveys/sampling-information/techniques/table/SamplingTechniqueTable.tsx index eda2fc5012..6374ad0796 100644 --- a/app/src/features/surveys/sampling-information/techniques/components/SamplingTechniqueCardContainer.tsx +++ b/app/src/features/surveys/sampling-information/techniques/table/SamplingTechniqueTable.tsx @@ -9,26 +9,29 @@ import Menu, { MenuProps } from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import Typography from '@mui/material/Typography'; import { GridRowSelectionModel } from '@mui/x-data-grid'; -import { GridOverlay } from '@mui/x-data-grid/components/containers/GridOverlay'; import { GridColDef } from '@mui/x-data-grid/models/colDef/gridColDef'; import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; import { DeleteTechniqueI18N } from 'constants/i18n'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useCodesContext, useDialogContext, useSurveyContext } from 'hooks/useContext'; -import { IGetTechniqueResponse } from 'interfaces/useTechniqueApi.interface'; -import { useState } from 'react'; +import { IGetTechniqueResponse, TechniqueAttractant } from 'interfaces/useTechniqueApi.interface'; +import { useEffect, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { getCodesName } from 'utils/Utils'; -interface ITechniqueRowData { +export interface ITechniqueRowData { id: number; - method_lookup: string; + method_lookup_id: number; name: string; description: string | null; + attractants: TechniqueAttractant[]; + distance_threshold: number | null; } -interface ISamplingTechniqueCardContainer { +interface ISamplingTechniqueTable { techniques: IGetTechniqueResponse[]; bulkActionTechniques: GridRowSelectionModel; setBulkActionTechniques: (selection: GridRowSelectionModel) => void; @@ -39,7 +42,7 @@ interface ISamplingTechniqueCardContainer { * * @returns */ -export const SamplingTechniqueCardContainer = (props: ISamplingTechniqueCardContainer) => { +export const SamplingTechniqueTable = (props: ISamplingTechniqueTable) => { const { techniques, bulkActionTechniques, setBulkActionTechniques } = props; // Individual row action menu @@ -51,6 +54,10 @@ export const SamplingTechniqueCardContainer = (prop const codesContext = useCodesContext(); const biohubApi = useBiohubApi(); + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext.codesDataLoader]); + /** * Handle the delete technique API call. * @@ -109,10 +116,11 @@ export const SamplingTechniqueCardContainer = (prop const rows: ITechniqueRowData[] = techniques.map((technique) => ({ id: technique.method_technique_id, - method_lookup: - getCodesName(codesContext.codesDataLoader.data, 'sample_methods', technique.method_lookup_id) ?? '', + method_lookup_id: technique.method_lookup_id, name: technique.name, - description: technique.description + description: technique.description, + attractants: technique.attractants, + distance_threshold: technique.distance_threshold })) || []; const columns: GridColDef[] = [ @@ -123,7 +131,10 @@ export const SamplingTechniqueCardContainer = (prop headerName: 'Method', renderCell: (params) => ( - + ) }, @@ -152,11 +163,36 @@ export const SamplingTechniqueCardContainer = (prop ); } }, + { + field: 'attractants', + flex: 0.5, + headerName: 'Attractants', + renderCell: (params) => ( + + {params.row.attractants.map((attractant) => ( + + + + ))} + + ) + }, + { + field: 'distance_threshold', + headerName: 'Distance threshold', + flex: 0.3, + renderCell: (params) => (params.row.distance_threshold ? <>{params.row.distance_threshold} m : <>) + }, { field: 'actions', type: 'actions', sortable: false, - flex: 1, + flex: 0.3, align: 'right', renderCell: (params) => { return ( @@ -224,10 +260,18 @@ export const SamplingTechniqueCardContainer = (prop - + + } + hasNoDataFallbackDelay={100}> 'auto'} rows={rows} columns={columns} disableRowSelectionOnClick @@ -235,41 +279,14 @@ export const SamplingTechniqueCardContainer = (prop checkboxSelection rowSelectionModel={bulkActionTechniques} onRowSelectionModelChange={setBulkActionTechniques} - noRowsOverlay={ - - - - Start by adding sampling information  - - - - Add techniques, then apply your techniques to sampling sites - - - - } - sx={{ - '& .MuiDataGrid-virtualScroller': { - height: rows.length === 0 ? '250px' : 'unset', - overflowY: 'auto !important', - overflowX: 'hidden' - }, - '& .MuiDataGrid-overlay': { - height: '250px', - display: 'flex', - justifyContent: 'center', - alignItems: 'center' - }, - '& .MuiDataGrid-columnHeaderDraggableContainer': { - minWidth: '50px' - }, - // '& .MuiDataGrid-cell--textLeft': { justifyContent: 'flex-end' } - '& .MuiDataGrid-cell--textLeft:last-child': { - // justifyContent: 'flex-end !important' + initialState={{ + pagination: { + paginationModel: { page: 1, pageSize: 10 } } }} + pageSizeOptions={[10, 25, 50]} /> - + ); }; diff --git a/app/src/features/surveys/telemetry/ManualTelemetryList.tsx b/app/src/features/surveys/telemetry/ManualTelemetryList.tsx deleted file mode 100644 index 6ffb733aca..0000000000 --- a/app/src/features/surveys/telemetry/ManualTelemetryList.tsx +++ /dev/null @@ -1,601 +0,0 @@ -import { mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import { LoadingButton } from '@mui/lab'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import grey from '@mui/material/colors/grey'; -import Dialog from '@mui/material/Dialog'; -import DialogActions from '@mui/material/DialogActions'; -import DialogContent from '@mui/material/DialogContent'; -import DialogTitle from '@mui/material/DialogTitle'; -import Divider from '@mui/material/Divider'; -import FormControl from '@mui/material/FormControl'; -import FormHelperText from '@mui/material/FormHelperText'; -import InputLabel from '@mui/material/InputLabel'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import Menu, { MenuProps } from '@mui/material/Menu'; -import MenuItem from '@mui/material/MenuItem'; -import Paper from '@mui/material/Paper'; -import Select from '@mui/material/Select'; -import Stack from '@mui/material/Stack'; -import useTheme from '@mui/material/styles/useTheme'; -import Toolbar from '@mui/material/Toolbar'; -import Typography from '@mui/material/Typography'; -import useMediaQuery from '@mui/material/useMediaQuery'; -import { useOnMount } from '@mui/x-data-grid/internals'; -import { SkeletonListStack } from 'components/loading/SkeletonLoaders'; -import { AttachmentType } from 'constants/attachments'; -import { DialogContext } from 'contexts/dialogContext'; -import { SurveyContext } from 'contexts/surveyContext'; -import { TelemetryDataContext } from 'contexts/telemetryDataContext'; -import { default as dayjs } from 'dayjs'; -import { Formik } from 'formik'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { useTelemetryApi } from 'hooks/useTelemetryApi'; -import { ISimpleCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; -import { isEqual as _deepEquals } from 'lodash'; -import { get } from 'lodash-es'; -import { useContext, useMemo, useState } from 'react'; -import { datesSameNullable } from 'utils/Utils'; -import yup from 'utils/YupSchema'; -import { InferType } from 'yup'; -import { ANIMAL_FORM_MODE } from '../view/survey-animals/animal'; -import { AnimalTelemetryDeviceSchema, Device, IAnimalDeployment } from '../view/survey-animals/telemetry-device/device'; -import TelemetryDeviceForm from '../view/survey-animals/telemetry-device/TelemetryDeviceForm'; -import ManualTelemetryCard from './ManualTelemetryCard'; - -export const AnimalDeploymentSchema = AnimalTelemetryDeviceSchema.shape({ - survey_critter_id: yup.number().required('An animal selection is required'), // add survey critter id to form - critter_id: yup.string(), - attachmentFile: yup.mixed(), - attachmentType: yup.mixed().oneOf(Object.values(AttachmentType)) -}); -export type AnimalDeployment = InferType; - -export interface ICritterDeployment { - critter: ISimpleCritterWithInternalId; - deployment: IAnimalDeployment; -} - -const ManualTelemetryList = () => { - const theme = useTheme(); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); - const surveyContext = useContext(SurveyContext); - const telemetryContext = useContext(TelemetryDataContext); - const dialogContext = useContext(DialogContext); - const biohubApi = useBiohubApi(); - const telemetryApi = useTelemetryApi(); - - const defaultFormValues = { - survey_critter_id: '' as unknown as number, // form needs '' to display the no value text - deployments: [ - { - deployment_id: '', - attachment_start: '', - attachment_end: undefined - } - ], - device_id: '' as unknown as number, // form needs '' to display the no value text - device_make: '', - device_model: '', - frequency: undefined, - frequency_unit: undefined, - attachmentType: undefined, - attachmentFile: undefined, - critter_id: '' - }; - - const [anchorEl, setAnchorEl] = useState(null); - - const [showDialog, setShowDialog] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [formMode, setFormMode] = useState(ANIMAL_FORM_MODE.ADD); - const [critterId, setCritterId] = useState(''); - const [deviceId, setDeviceId] = useState(0); - const [formData, setFormData] = useState(defaultFormValues); - - useOnMount(() => { - surveyContext.deploymentDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); - surveyContext.critterDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); - }); - - const deployments = surveyContext.deploymentDataLoader.data; - const critters = surveyContext.critterDataLoader.data; - - const critterDeployments: ICritterDeployment[] = useMemo(() => { - const data: ICritterDeployment[] = []; - // combine all critter and deployments into a flat list - surveyContext.deploymentDataLoader.data?.forEach((deployment) => { - const critter = surveyContext.critterDataLoader.data?.find( - (critter) => critter.critter_id === deployment.critter_id - ); - if (critter) { - data.push({ critter, deployment }); - } - }); - return data; - }, [surveyContext.critterDataLoader.data, surveyContext.deploymentDataLoader.data]); - - const handleMenuOpen = async (event: React.MouseEvent, device_id: number) => { - setAnchorEl(event.currentTarget); - setDeviceId(device_id); - - const critterDeployment = critterDeployments.find((item) => item.deployment.device_id === device_id); - - // need to map deployment back into object for initial values - if (critterDeployment) { - const deviceDetails = await telemetryApi.devices.getDeviceDetails( - device_id, - critterDeployment.deployment.device_make - ); - const editData: AnimalDeployment = { - survey_critter_id: Number(critterDeployment.critter?.survey_critter_id), - deployments: [ - { - deployment_id: critterDeployment.deployment.deployment_id, - attachment_start: dayjs(critterDeployment.deployment.attachment_start).format('YYYY-MM-DD'), - attachment_end: critterDeployment.deployment.attachment_end - ? dayjs(critterDeployment.deployment.attachment_end).format('YYYY-MM-DD') - : null - } - ], - device_id: critterDeployment.deployment.device_id, - device_make: deviceDetails.device?.device_make ? String(deviceDetails.device?.device_make) : '', - device_model: deviceDetails.device?.device_model ? String(deviceDetails.device?.device_model) : '', - frequency: deviceDetails.device?.frequency ? Number(deviceDetails.device?.frequency) : undefined, - frequency_unit: deviceDetails.device?.frequency_unit ? String(deviceDetails.device?.frequency_unit) : '', - attachmentType: undefined, - attachmentFile: undefined, - critter_id: critterDeployment.deployment.critter_id - }; - setCritterId(critterDeployment.critter?.survey_critter_id); - setFormData(editData); - } - }; - - const handleSubmit = async (data: AnimalDeployment) => { - if (formMode === ANIMAL_FORM_MODE.ADD) { - // ADD NEW DEPLOYMENT - await handleAddDeployment(data); - } else { - // EDIT EXISTING DEPLOYMENT - await handleEditDeployment(data); - } - // UPLOAD/ REPLACE ANY FILES FOUND - if (data.attachmentFile && data.attachmentType) { - await handleUploadFile(data.attachmentFile, data.attachmentType); - } - }; - - const handleDeleteDeployment = async () => { - try { - const deployment = deployments?.find((item) => item.device_id === deviceId); - if (!deployment) { - throw new Error('Invalid Deployment Data'); - } - const critter = critters?.find((item) => item.critter_id === deployment?.critter_id); - if (!critter) { - throw new Error('Invalid Critter Data'); - } - - const found = telemetryContext.telemetryDataLoader.data?.find( - (item) => item.deployment_id === deployment.deployment_id - ); - if (!found) { - await biohubApi.survey.removeDeployment( - surveyContext.projectId, - surveyContext.surveyId, - critter.survey_critter_id, - deployment.deployment_id - ); - dialogContext.setYesNoDialog({ open: false }); - surveyContext.deploymentDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); - } else { - dialogContext.setYesNoDialog({ open: false }); - // Deployment is used in telemetry, do not delete until it is scrubbed - throw new Error('Deployment is used in telemetry'); - } - } catch (e) { - dialogContext.setSnackbar({ - snackbarMessage: ( - <> - - Error Deleting Deployment - - - {String(e)} - - - ), - open: true - }); - } - }; - - const deleteDeploymentDialog = () => { - dialogContext.setYesNoDialog({ - dialogTitle: 'Delete Deployment?', - dialogContent: ( - - Are you sure you want to delete this deployment? - - ), - yesButtonLabel: 'Delete Deployment', - noButtonLabel: 'Cancel', - yesButtonProps: { color: 'error' }, - onClose: () => { - dialogContext.setYesNoDialog({ open: false }); - }, - onNo: () => { - dialogContext.setYesNoDialog({ open: false }); - }, - open: true, - onYes: () => { - handleDeleteDeployment(); - } - }); - }; - - const handleAddDeployment = async (data: AnimalDeployment) => { - try { - const critter = critters?.find((a) => a.survey_critter_id === data.survey_critter_id); - - if (!critter) { - throw new Error('Invalid critter data'); - } - - await biohubApi.survey.addDeployment( - surveyContext.projectId, - surveyContext.surveyId, - Number(data.survey_critter_id), - //Being explicit here for simplicity. - { - critter_id: critter.critter_id, - device_id: Number(data.device_id), - device_make: data.device_make ?? undefined, - frequency: data.frequency ?? undefined, - frequency_unit: data.frequency_unit ?? undefined, - device_model: data.device_model ?? undefined, - attachment_start: data.deployments?.[0].attachment_start, - attachment_end: data.deployments?.[0].attachment_end ?? undefined - } - ); - surveyContext.deploymentDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); - // success snack bar - dialogContext.setSnackbar({ - snackbarMessage: ( - - Deployment Added - - ), - open: true - }); - } catch (error) { - dialogContext.setSnackbar({ - snackbarMessage: ( - <> - - Error adding Deployment - - - {String(error)} - - - ), - open: true - }); - } - }; - - const handleEditDeployment = async (data: AnimalDeployment) => { - try { - await updateDeployments(data); - await updateDevice(data); - dialogContext.setSnackbar({ - snackbarMessage: ( - - Deployment Updated - - ), - open: true - }); - } catch (error) { - dialogContext.setSnackbar({ - snackbarMessage: ( - <> - - Error Deleting Sampling Site - - - {String(error)} - - - ), - open: true - }); - } - }; - - const updateDeployments = async (data: AnimalDeployment) => { - for (const deployment of data.deployments ?? []) { - const existingDeployment = deployments?.find((item) => item.deployment_id === deployment.deployment_id); - if ( - !datesSameNullable(deployment?.attachment_start, existingDeployment?.attachment_start) || - !datesSameNullable(deployment?.attachment_end, existingDeployment?.attachment_end) - ) { - try { - await biohubApi.survey.updateDeployment( - surveyContext.projectId, - surveyContext.surveyId, - data.survey_critter_id, - deployment - ); - } catch (error) { - throw new Error(`Failed to update deployment ${deployment.deployment_id}`); - } - } - } - }; - - const updateDevice = async (data: AnimalDeployment) => { - const existingDevice = critterDeployments.find((item) => item.deployment.device_id === data.device_id); - const device = new Device({ ...data, collar_id: existingDevice?.deployment.collar_id }); - try { - if (existingDevice && !_deepEquals(new Device(existingDevice.deployment), device)) { - await telemetryApi.devices.upsertCollar(device); - } - } catch (error) { - throw new Error(`Failed to update collar ${device.collar_id}`); - } - }; - - const handleUploadFile = async (file?: File, attachmentType?: AttachmentType) => { - try { - if (file && attachmentType === AttachmentType.KEYX) { - await biohubApi.survey.uploadSurveyKeyx(surveyContext.projectId, surveyContext.surveyId, file); - } else if (file && attachmentType === AttachmentType.OTHER) { - await biohubApi.survey.uploadSurveyAttachments(surveyContext.projectId, surveyContext.surveyId, file); - } - } catch (error) { - dialogContext.setSnackbar({ - snackbarMessage: ( - <> - - Error Uploading File - - - {`Failed to upload attachment ${file?.name}`} - - - ), - open: true - }); - } - }; - - return ( - <> - { - setAnchorEl(null); - setCritterId(''); - setDeviceId(0); - }} - anchorEl={anchorEl} - anchorOrigin={{ - vertical: 'top', - horizontal: 'right' - }} - transformOrigin={{ - vertical: 'top', - horizontal: 'right' - }}> - { - setFormMode(ANIMAL_FORM_MODE.EDIT); - setShowDialog(true); - setAnchorEl(null); - }}> - - - - Edit Details - - { - setAnchorEl(null); - deleteDeploymentDialog(); - }}> - - - - Delete - - - { - setIsLoading(true); - await handleSubmit(values); - setIsLoading(false); - setShowDialog(false); - actions.resetForm(); - setCritterId(''); - }}> - {(formikProps) => { - return ( - <> - - Critter Deployments - - <> - - Critter - - - - {get(formikProps.errors, 'survey_critter_id')} - - - - - - - - { - formikProps.submitForm(); - }}> - Save - - - - - - - - - Deployments ‌ - - ({Number(critterDeployments?.length ?? 0).toLocaleString()}) - - - - - - - - {surveyContext.deploymentDataLoader.isLoading ? ( - - ) : ( - <> - {!critterDeployments.length && ( - - No Deployments - - )} - {critterDeployments?.map((item) => ( - { - handleMenuOpen(event, id); - }} - /> - ))} - - )} - - - - - ); - }} - - - ); -}; - -export default ManualTelemetryList; diff --git a/app/src/features/surveys/telemetry/ManualTelemetryPage.tsx b/app/src/features/surveys/telemetry/ManualTelemetryPage.tsx deleted file mode 100644 index cdbc745ac3..0000000000 --- a/app/src/features/surveys/telemetry/ManualTelemetryPage.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import Box from '@mui/material/Box'; -import CircularProgress from '@mui/material/CircularProgress'; -import Stack from '@mui/material/Stack'; -import { ProjectContext } from 'contexts/projectContext'; -import { SurveyContext } from 'contexts/surveyContext'; -import { TelemetryDataContextProvider } from 'contexts/telemetryDataContext'; -import { TelemetryTableContextProvider } from 'contexts/telemetryTableContext'; -import { useContext, useMemo } from 'react'; -import ManualTelemetryHeader from './ManualTelemetryHeader'; -import ManualTelemetryList from './ManualTelemetryList'; -import ManualTelemetryTableContainer from './telemetry-table/ManualTelemetryTableContainer'; - -const ManualTelemetryPage = () => { - const surveyContext = useContext(SurveyContext); - const projectContext = useContext(ProjectContext); - - const deploymentIds = useMemo(() => { - return surveyContext.deploymentDataLoader.data?.map((item) => item.deployment_id); - }, [surveyContext.deploymentDataLoader.data]); - - if (!surveyContext.surveyDataLoader.data || !projectContext.projectDataLoader.data) { - return ; - } - - return ( - - - - - {/* Telematry List */} - - - - {/* Telemetry Component */} - - - - - - - - - ); -}; - -export default ManualTelemetryPage; diff --git a/app/src/features/surveys/telemetry/ManualTelemetrySection.tsx b/app/src/features/surveys/telemetry/ManualTelemetrySection.tsx deleted file mode 100644 index 38a1548bc8..0000000000 --- a/app/src/features/surveys/telemetry/ManualTelemetrySection.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Divider from '@mui/material/Divider'; -import Toolbar from '@mui/material/Toolbar'; -import Typography from '@mui/material/Typography'; -import { Link as RouterLink } from 'react-router-dom'; -import NoSurveySectionData from '../components/NoSurveySectionData'; - -const ManualTelemetrySection = () => { - return ( - - - - Telemetry - - - - - - - - - ); -}; - -export default ManualTelemetrySection; diff --git a/app/src/features/surveys/telemetry/ManualTelemetryCard.tsx b/app/src/features/surveys/telemetry/TelemetryCard.tsx similarity index 94% rename from app/src/features/surveys/telemetry/ManualTelemetryCard.tsx rename to app/src/features/surveys/telemetry/TelemetryCard.tsx index b77f6b3e85..2f3755329b 100644 --- a/app/src/features/surveys/telemetry/ManualTelemetryCard.tsx +++ b/app/src/features/surveys/telemetry/TelemetryCard.tsx @@ -8,7 +8,7 @@ import grey from '@mui/material/colors/grey'; import IconButton from '@mui/material/IconButton'; import Typography from '@mui/material/Typography'; import { default as dayjs } from 'dayjs'; -export interface ManualTelemetryCardProps { +export interface TelemetryCardProps { device_id: number; device_make: string; name: string; // should be animal alias @@ -18,7 +18,7 @@ export interface ManualTelemetryCardProps { onMenu: (e: React.MouseEvent, id: number) => void; } -const ManualTelemetryCard = (props: ManualTelemetryCardProps) => { +export const TelemetryCard = (props: TelemetryCardProps) => { return ( { ); }; - -export default ManualTelemetryCard; diff --git a/app/src/features/surveys/telemetry/ManualTelemetryHeader.tsx b/app/src/features/surveys/telemetry/TelemetryHeader.tsx similarity index 86% rename from app/src/features/surveys/telemetry/ManualTelemetryHeader.tsx rename to app/src/features/surveys/telemetry/TelemetryHeader.tsx index f8fa0dd22a..85fb278961 100644 --- a/app/src/features/surveys/telemetry/ManualTelemetryHeader.tsx +++ b/app/src/features/surveys/telemetry/TelemetryHeader.tsx @@ -4,14 +4,14 @@ import Typography from '@mui/material/Typography'; import PageHeader from 'components/layout/PageHeader'; import { Link as RouterLink } from 'react-router-dom'; -export interface ManualTelemetryHeaderProps { +export interface TelemetryHeaderProps { project_id: number; project_name: string; survey_id: number; survey_name: string; } -const ManualTelemetryHeader: React.FC = (props) => { +export const TelemetryHeader = (props: TelemetryHeaderProps) => { const { project_id, project_name, survey_id, survey_name } = props; return ( = (props) => { /> ); }; - -export default ManualTelemetryHeader; diff --git a/app/src/features/surveys/telemetry/TelemetryPage.tsx b/app/src/features/surveys/telemetry/TelemetryPage.tsx new file mode 100644 index 0000000000..51e0123188 --- /dev/null +++ b/app/src/features/surveys/telemetry/TelemetryPage.tsx @@ -0,0 +1,57 @@ +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import Stack from '@mui/material/Stack'; +import { TelemetryTableContextProvider } from 'contexts/telemetryTableContext'; +import { SurveyDeploymentList } from 'features/surveys/telemetry/list/SurveyDeploymentList'; +import { TelemetryTableContainer } from 'features/surveys/telemetry/table/TelemetryTableContainer'; +import { TelemetryHeader } from 'features/surveys/telemetry/TelemetryHeader'; +import { useProjectContext, useSurveyContext, useTelemetryDataContext } from 'hooks/useContext'; +import { useEffect } from 'react'; + +export const TelemetryPage = () => { + const projectContext = useProjectContext(); + const surveyContext = useSurveyContext(); + const telemetryDataContext = useTelemetryDataContext(); + + const deploymentsDataLoader = telemetryDataContext.deploymentsDataLoader; + + useEffect(() => { + deploymentsDataLoader.load(surveyContext.projectId, surveyContext.surveyId); + }, [deploymentsDataLoader, surveyContext.projectId, surveyContext.surveyId]); + + if (!surveyContext.surveyDataLoader.data || !projectContext.projectDataLoader.data) { + return ; + } + + return ( + + + + {/* Telematry List */} + + + + {/* Telemetry Component */} + + deployment.bctw_deployment_id) ?? []}> + + + + + + ); +}; diff --git a/app/src/features/surveys/telemetry/TelemetryRouter.tsx b/app/src/features/surveys/telemetry/TelemetryRouter.tsx new file mode 100644 index 0000000000..999be6ba09 --- /dev/null +++ b/app/src/features/surveys/telemetry/TelemetryRouter.tsx @@ -0,0 +1,65 @@ +import { ProjectRoleRouteGuard } from 'components/security/RouteGuards'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; +import { DialogContextProvider } from 'contexts/dialogContext'; +import { CreateDeploymentPage } from 'features/surveys/telemetry/deployments/create/CreateDeploymentPage'; +import { EditDeploymentPage } from 'features/surveys/telemetry/deployments/edit/EditDeploymentPage'; +import { TelemetryPage } from 'features/surveys/telemetry/TelemetryPage'; +import { Redirect, Switch } from 'react-router'; +import RouteWithTitle from 'utils/RouteWithTitle'; +import { getTitle } from 'utils/Utils'; + +/** + * Router for all `/admin/projects/:id/surveys/:survey_id/telemetry/*` pages. + * + * @return {*} + */ +export const TelemetryRouter = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/app/src/features/surveys/telemetry/deployments/components/form/DeploymentForm.tsx b/app/src/features/surveys/telemetry/deployments/components/form/DeploymentForm.tsx new file mode 100644 index 0000000000..87b947112b --- /dev/null +++ b/app/src/features/surveys/telemetry/deployments/components/form/DeploymentForm.tsx @@ -0,0 +1,142 @@ +import LoadingButton from '@mui/lab/LoadingButton/LoadingButton'; +import Button from '@mui/material/Button'; +import Container from '@mui/material/Container'; +import Divider from '@mui/material/Divider'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; +import { + DeploymentDetailsForm, + DeploymentDetailsFormInitialValues, + DeploymentDetailsFormYupSchema +} from 'features/surveys/telemetry/deployments/components/form/deployment-details/DeploymentDetailsForm'; +import { + DeploymentDeviceDetailsForm, + DeploymentDeviceDetailsFormInitialValues, + DeploymentDeviceDetailsFormYupSchema +} from 'features/surveys/telemetry/deployments/components/form/device-details/DeploymentDeviceDetailsForm'; +import { + DeploymentTimelineForm, + DeploymentTimelineFormInitialValues, + DeploymentTimelineFormYupSchema +} from 'features/surveys/telemetry/deployments/components/form/timeline/DeploymentTimelineForm'; +import { useFormikContext } from 'formik'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { ICreateAnimalDeployment } from 'interfaces/useTelemetryApi.interface'; +import { useEffect } from 'react'; +import { useHistory } from 'react-router'; + +export const DeploymentFormInitialValues = { + ...DeploymentDetailsFormInitialValues, + ...DeploymentTimelineFormInitialValues, + ...DeploymentDeviceDetailsFormInitialValues +}; + +export const DeploymentFormYupSchema = DeploymentDetailsFormYupSchema.concat(DeploymentTimelineFormYupSchema).concat( + DeploymentDeviceDetailsFormYupSchema +); + +interface IDeploymentFormProps { + isSubmitting: boolean; + isEdit?: boolean; +} + +/** + * Deployment form component. + * + * @param {IDeploymentFormProps} props + * @return {*} + */ +export const DeploymentForm = (props: IDeploymentFormProps) => { + const { isSubmitting, isEdit } = props; + + const { submitForm, values } = useFormikContext(); + + const surveyContext = useSurveyContext(); + + const biohubApi = useBiohubApi(); + + const history = useHistory(); + + const critterDataLoader = useDataLoader((critterId: number) => + biohubApi.survey.getCritterById(surveyContext.projectId, surveyContext.surveyId, critterId) + ); + + const frequencyUnitDataLoader = useDataLoader(() => biohubApi.telemetry.getCodeValues('frequency_unit')); + const deviceMakesDataLoader = useDataLoader(() => biohubApi.telemetry.getCodeValues('device_make')); + + // Fetch frequency unit and device make code values from BCTW on component mount + useEffect(() => { + frequencyUnitDataLoader.load(); + deviceMakesDataLoader.load(); + }, [deviceMakesDataLoader, frequencyUnitDataLoader]); + + // Fetch critter data when critter_id changes (ie. when the user selects a critter) + useEffect(() => { + if (values.critter_id) { + critterDataLoader.refresh(values.critter_id); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [values.critter_id]); + + return ( + + + + + ({ label: data.code, value: data.id })) ?? []} + isEdit={isEdit} + /> + + + + + + + + + + + + ({ label: data.code, value: data.id })) ?? []} + /> + + + + + + { + submitForm(); + }}> + Save and Exit + + + + + + + ); +}; diff --git a/app/src/features/surveys/telemetry/deployments/components/form/DeploymentFormHeader.tsx b/app/src/features/surveys/telemetry/deployments/components/form/DeploymentFormHeader.tsx new file mode 100644 index 0000000000..b575cefc52 --- /dev/null +++ b/app/src/features/surveys/telemetry/deployments/components/form/DeploymentFormHeader.tsx @@ -0,0 +1,106 @@ +import { LoadingButton } from '@mui/lab'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Button from '@mui/material/Button'; +import { grey } from '@mui/material/colors'; +import Container from '@mui/material/Container'; +import Link from '@mui/material/Link'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { useFormikContext } from 'formik'; +import { ICreateAnimalDeployment } from 'interfaces/useTelemetryApi.interface'; +import { useHistory } from 'react-router'; +import { Link as RouterLink } from 'react-router-dom'; + +export interface IDeploymentFormHeaderProps { + project_id: number; + project_name: string; + survey_id: number; + survey_name: string; + is_submitting: boolean; + title: string; + breadcrumb: string; +} + +/** + * Renders the header of the create and edit deployment pages. + * + * @param {IDeploymentFormHeaderProps} props + * @return {*} + */ +export const DeploymentFormHeader = (props: IDeploymentFormHeaderProps) => { + const history = useHistory(); + const formikProps = useFormikContext(); + + const { project_id, survey_id, survey_name, project_name, is_submitting, title, breadcrumb } = props; + + return ( + <> + + + + + {project_name} + + + {survey_name} + + + Manage Telemetry + + + {breadcrumb} + + + + + {title} + + + { + formikProps.submitForm(); + }}> + Save and Exit + + + + + + + + ); +}; diff --git a/app/src/features/surveys/telemetry/deployments/components/form/deployment-details/DeploymentDetailsForm.tsx b/app/src/features/surveys/telemetry/deployments/components/form/deployment-details/DeploymentDetailsForm.tsx new file mode 100644 index 0000000000..6f5c1ee0ba --- /dev/null +++ b/app/src/features/surveys/telemetry/deployments/components/form/deployment-details/DeploymentDetailsForm.tsx @@ -0,0 +1,137 @@ +import Grid from '@mui/material/Grid'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { AnimalAutocompleteField } from 'components/fields/AnimalAutocompleteField'; +import AutocompleteField, { IAutocompleteFieldOption } from 'components/fields/AutocompleteField'; +import CustomTextField from 'components/fields/CustomTextField'; +import { useFormikContext } from 'formik'; +import { useSurveyContext } from 'hooks/useContext'; +import { ICritterSimpleResponse } from 'interfaces/useCritterApi.interface'; +import { ICreateAnimalDeployment } from 'interfaces/useTelemetryApi.interface'; +import { Link as RouterLink } from 'react-router-dom'; +import { isDefined } from 'utils/Utils'; +import yup from 'utils/YupSchema'; + +export const DeploymentDetailsFormInitialValues: yup.InferType = { + device_id: null as unknown as string, + critter_id: null as unknown as number, + frequency: null, + frequency_unit: null +}; + +export const DeploymentDetailsFormYupSchema = yup.object({ + device_id: yup.string().nullable().required('You must enter the device ID. This is typically the serial number'), + critter_id: yup.number().nullable().required('You must select the animal that the device is associated to'), + frequency: yup.lazy(() => + yup + .number() + .nullable() + .when('frequency_unit', { + is: (frequency_unit: number) => isDefined(frequency_unit), // when frequency_unit is defined + then: yup.number().nullable().required('Frequency is required') + }) + ), + frequency_unit: yup.lazy(() => + yup + .number() + .nullable() + .when('frequency', { + is: (frequency: number) => isDefined(frequency), // when frequency is defined + then: yup.number().nullable().required('Frequency unit is required') + }) + ) +}); + +interface IDeploymentDetailsFormProps { + surveyAnimals: ICritterSimpleResponse[]; + frequencyUnits: IAutocompleteFieldOption[]; + isEdit?: boolean; +} + +/** + * Deployment form - deployment details section. + * + * @param {IDeploymentDetailsFormProps} props + * @return {*} + */ +export const DeploymentDetailsForm = (props: IDeploymentDetailsFormProps) => { + const { surveyAnimals, frequencyUnits, isEdit } = props; + + const { setFieldValue, values } = useFormikContext(); + + const surveyContext = useSurveyContext(); + + return ( + <> + + + + You must  + + add the animal + +  to your Survey before associating it to a telemetry device. Add animals via the  + + Manage Animals + +  page. + + + + + animal.critter_id === values.critter_id)} + required + clearOnSelect + onSelect={(animal: ICritterSimpleResponse) => { + if (animal) { + setFieldValue('critter_id', animal.critter_id); + } + }} + /> + + + + + + + + + + ); +}; diff --git a/app/src/features/surveys/telemetry/deployments/components/form/device-details/DeploymentDeviceDetailsForm.tsx b/app/src/features/surveys/telemetry/deployments/components/form/device-details/DeploymentDeviceDetailsForm.tsx new file mode 100644 index 0000000000..5e74ea44c4 --- /dev/null +++ b/app/src/features/surveys/telemetry/deployments/components/form/device-details/DeploymentDeviceDetailsForm.tsx @@ -0,0 +1,41 @@ +import Grid from '@mui/material/Grid'; +import AutocompleteField, { IAutocompleteFieldOption } from 'components/fields/AutocompleteField'; +import CustomTextField from 'components/fields/CustomTextField'; +import yup from 'utils/YupSchema'; + +export const DeploymentDeviceDetailsFormInitialValues: yup.InferType = { + device_make: null as unknown as number, + device_model: null +}; + +export const DeploymentDeviceDetailsFormYupSchema = yup.object({ + device_make: yup.number().nullable().required('You must enter the device make'), + device_model: yup.string().nullable() +}); + +interface IDeploymentDeviceDetailsFormProps { + deviceMakes: IAutocompleteFieldOption[]; +} + +/** + * Deployment form - device details section. + * + * @param {IDeploymentDeviceDetailsFormProps} props + * @return {*} + */ +export const DeploymentDeviceDetailsForm = (props: IDeploymentDeviceDetailsFormProps) => { + const { deviceMakes } = props; + + return ( + <> + + + + + + + + + + ); +}; diff --git a/app/src/features/surveys/telemetry/deployments/components/form/timeline/DeploymentTimelineForm.tsx b/app/src/features/surveys/telemetry/deployments/components/form/timeline/DeploymentTimelineForm.tsx new file mode 100644 index 0000000000..df3b446537 --- /dev/null +++ b/app/src/features/surveys/telemetry/deployments/components/form/timeline/DeploymentTimelineForm.tsx @@ -0,0 +1,277 @@ +import Box from '@mui/material/Box'; +import Collapse from '@mui/material/Collapse'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Grid from '@mui/material/Grid'; +import Radio from '@mui/material/Radio'; +import RadioGroup from '@mui/material/RadioGroup'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import AutocompleteField from 'components/fields/AutocompleteField'; +import { DateField } from 'components/fields/DateField'; +import { TimeField } from 'components/fields/TimeField'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import dayjs from 'dayjs'; +import { useFormikContext } from 'formik'; +import { useSurveyContext } from 'hooks/useContext'; +import { ICaptureResponse, IMortalityResponse } from 'interfaces/useCritterApi.interface'; +import { ICreateAnimalDeployment } from 'interfaces/useTelemetryApi.interface'; +import { useMemo, useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { TransitionGroup } from 'react-transition-group'; +import yup from 'utils/YupSchema'; + +// Types to know how the deployment ended, determining which form components to display +type DeploymentEndType = 'capture' | 'mortality' | 'fell_off'; + +export const DeploymentTimelineFormInitialValues: yup.InferType = { + critterbase_start_capture_id: null as unknown as string, + critterbase_end_mortality_id: null, + critterbase_end_capture_id: null, + attachment_end_date: null, + attachment_end_time: null +}; + +export const DeploymentTimelineFormYupSchema = yup.object({ + critterbase_start_capture_id: yup.string().nullable().required('You must select the initial capture event'), + critterbase_end_mortality_id: yup.string().uuid().nullable(), + critterbase_end_capture_id: yup.string().uuid().nullable(), + attachment_end_date: yup.lazy(() => + yup + .string() + .nullable() + .when('attachment_end_time', { + is: (attachment_end_time: string | null) => attachment_end_time !== null, + then: yup.string().nullable().required('End Date is required'), + otherwise: yup.string().nullable() + }) + ), + attachment_end_time: yup.lazy(() => + yup + .string() + .nullable() + .when('attachment_end_date', { + is: (attachment_end_date: string | null) => attachment_end_date !== null, + then: yup.string().nullable().required('End time is required'), + otherwise: yup.string().nullable() + }) + ) +}); + +interface IDeploymentTimelineFormProps { + captures: ICaptureResponse[]; + mortalities: IMortalityResponse[]; +} + +/** + * Deployment form - deployment timeline section. + * + * @param {IDeploymentTimelineFormProps} props + * @return {*} + */ +export const DeploymentTimelineForm = (props: IDeploymentTimelineFormProps) => { + const { captures, mortalities } = props; + + const formikProps = useFormikContext(); + + const { values, setFieldValue } = formikProps; + + // Determine the initial deployment end type based on the form values + const initialDeploymentEndType = useMemo(() => { + if (values.critterbase_end_mortality_id) { + return 'mortality'; + } else if (values.critterbase_end_capture_id) { + return 'capture'; + } else if (values.attachment_end_date) { + return 'fell_off'; + } else { + return null; + } + }, [values]); + + const [deploymentEndType, setDeploymentEndType] = useState(initialDeploymentEndType); + + const surveyContext = useSurveyContext(); + + return ( + + + + Start of deployment + + + You must  + {values.critter_id ? ( + + add the capture + + ) : ( + 'add the capture' + )} +  during which the device was deployed before adding the deployment. + + ({ + value: capture.capture_id, + label: dayjs(capture.capture_date).format(DATE_FORMAT.LongDateTimeFormat) + }))} + required + /> + + + + + End of deployment (optional) + + + Select how the deployment ended. If due to a mortality, you must  + {values.critter_id ? ( + + report the mortality + + ) : ( + 'report the mortality' + )} +  before removing the device. + + + + } + label="Fell off" + onChange={() => { + setDeploymentEndType('fell_off'); + setFieldValue('critterbase_end_capture_id', null); + setFieldValue('critterbase_end_mortality_id', null); + }} + onClick={() => { + if (deploymentEndType === 'fell_off') { + // if the user clicks on the selected radio button, unselect it + setDeploymentEndType(null); + setFieldValue('attachment_end_date', null); + setFieldValue('attachment_end_time', null); + setFieldValue('critterbase_end_capture_id', null); + setFieldValue('critterbase_end_mortality_id', null); + } + }} + /> + } + label="Capture" + onChange={() => { + setDeploymentEndType('capture'); + setFieldValue('attachment_end_date', null); + setFieldValue('attachment_end_time', null); + setFieldValue('critterbase_end_mortality_id', null); + }} + onClick={() => { + if (deploymentEndType === 'capture') { + // if the user clicks on the selected radio button, unselect it + setDeploymentEndType(null); + setFieldValue('attachment_end_date', null); + setFieldValue('attachment_end_time', null); + setFieldValue('critterbase_end_capture_id', null); + setFieldValue('critterbase_end_mortality_id', null); + } + }} + /> + } + disabled={!mortalities.length} + label="Mortality" + onChange={() => { + setDeploymentEndType('mortality'); + setFieldValue('attachment_end_date', null); + setFieldValue('attachment_end_time', null); + setFieldValue('critterbase_end_capture_id', null); + }} + onClick={() => { + if (deploymentEndType === 'mortality') { + // if the user clicks on the selected radio button, unselect it + setDeploymentEndType(null); + setFieldValue('attachment_end_date', null); + setFieldValue('attachment_end_time', null); + setFieldValue('critterbase_end_capture_id', null); + setFieldValue('critterbase_end_mortality_id', null); + } + }} + /> + + + + + + {deploymentEndType === 'capture' && ( + { + if (option?.value) { + setFieldValue('critterbase_end_capture_id', option.value); + } + }} + options={captures.map((capture) => ({ + value: capture.capture_id, + label: dayjs(capture.capture_date).format(DATE_FORMAT.LongDateTimeFormat) + }))} + sx={{ width: '100%' }} + /> + )} + {deploymentEndType === 'fell_off' && ( + + + + + )} + {deploymentEndType === 'mortality' && ( + + ({ + value: mortality.mortality_id, + label: dayjs(mortality.mortality_timestamp).format(DATE_FORMAT.LongDateTimeFormat) + }))} + sx={{ width: '100%' }} + /> + + )} + + + + + + ); +}; diff --git a/app/src/features/surveys/telemetry/deployments/create/CreateDeploymentPage.tsx b/app/src/features/surveys/telemetry/deployments/create/CreateDeploymentPage.tsx new file mode 100644 index 0000000000..319d87c619 --- /dev/null +++ b/app/src/features/surveys/telemetry/deployments/create/CreateDeploymentPage.tsx @@ -0,0 +1,122 @@ +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import FormikErrorSnackbar from 'components/alert/FormikErrorSnackbar'; +import { CreateAnimalDeploymentI18N } from 'constants/i18n'; +import { + DeploymentForm, + DeploymentFormInitialValues, + DeploymentFormYupSchema +} from 'features/surveys/telemetry/deployments/components/form/DeploymentForm'; +import { DeploymentFormHeader } from 'features/surveys/telemetry/deployments/components/form/DeploymentFormHeader'; +import { Formik, FormikProps } from 'formik'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useDialogContext, useProjectContext, useSurveyContext, useTelemetryDataContext } from 'hooks/useContext'; +import { SKIP_CONFIRMATION_DIALOG, useUnsavedChangesDialog } from 'hooks/useUnsavedChangesDialog'; +import { ICreateAnimalDeployment } from 'interfaces/useTelemetryApi.interface'; +import { useRef, useState } from 'react'; +import { Prompt, useHistory } from 'react-router'; + +/** + * Renders the Create Deployment page. + * + * @return {*} + */ +export const CreateDeploymentPage = () => { + const history = useHistory(); + + const biohubApi = useBiohubApi(); + + const dialogContext = useDialogContext(); + const projectContext = useProjectContext(); + const surveyContext = useSurveyContext(); + const telemetryDataContext = useTelemetryDataContext(); + + const formikRef = useRef>(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { locationChangeInterceptor } = useUnsavedChangesDialog(); + + const critters = surveyContext.critterDataLoader.data ?? []; + + if (!surveyContext.surveyDataLoader.data || !projectContext.projectDataLoader.data) { + return ; + } + + const handleSubmit = async (values: ICreateAnimalDeployment) => { + setIsSubmitting(true); + + try { + const critter_id = Number(critters?.find((animal) => animal.critter_id === values.critter_id)?.critter_id); + + if (!critter_id) { + throw new Error('Invalid critter data'); + } + + await biohubApi.survey.createDeployment(surveyContext.projectId, surveyContext.surveyId, critter_id, { + device_id: Number(values.device_id), + device_make: values.device_make, + frequency: values.frequency, + frequency_unit: values.frequency_unit, + device_model: values.device_model, + critterbase_start_capture_id: values.critterbase_start_capture_id, + critterbase_end_capture_id: values.critterbase_end_capture_id, + critterbase_end_mortality_id: values.critterbase_end_mortality_id, + attachment_end_date: values.attachment_end_date, + attachment_end_time: values.attachment_end_time + }); + + telemetryDataContext.deploymentsDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + + // create complete, navigate back to telemetry page + history.push( + `/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/telemetry`, + SKIP_CONFIRMATION_DIALOG + ); + } catch (error) { + dialogContext.setErrorDialog({ + dialogTitle: CreateAnimalDeploymentI18N.createErrorTitle, + dialogText: CreateAnimalDeploymentI18N.createErrorText, + dialogError: (error as APIError).message, + dialogErrorDetails: (error as APIError)?.errors, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + }, + open: true + }); + setIsSubmitting(false); + } + }; + + return ( + <> + + + + + + + + + + + + ); +}; diff --git a/app/src/features/surveys/telemetry/deployments/edit/EditDeploymentPage.tsx b/app/src/features/surveys/telemetry/deployments/edit/EditDeploymentPage.tsx new file mode 100644 index 0000000000..aceb5e4c7f --- /dev/null +++ b/app/src/features/surveys/telemetry/deployments/edit/EditDeploymentPage.tsx @@ -0,0 +1,148 @@ +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import FormikErrorSnackbar from 'components/alert/FormikErrorSnackbar'; +import { EditAnimalDeploymentI18N } from 'constants/i18n'; +import { + DeploymentForm, + DeploymentFormYupSchema +} from 'features/surveys/telemetry/deployments/components/form/DeploymentForm'; +import { DeploymentFormHeader } from 'features/surveys/telemetry/deployments/components/form/DeploymentFormHeader'; +import { Formik, FormikProps } from 'formik'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useDialogContext, useProjectContext, useSurveyContext, useTelemetryDataContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { SKIP_CONFIRMATION_DIALOG, useUnsavedChangesDialog } from 'hooks/useUnsavedChangesDialog'; +import { ICreateAnimalDeployment } from 'interfaces/useTelemetryApi.interface'; +import { useEffect, useRef, useState } from 'react'; +import { Prompt, useHistory, useParams } from 'react-router'; + +/** + * Renders the Edit Deployment page. + * + * @return {*} + */ +export const EditDeploymentPage = () => { + const history = useHistory(); + + const biohubApi = useBiohubApi(); + + const dialogContext = useDialogContext(); + const projectContext = useProjectContext(); + const surveyContext = useSurveyContext(); + const telemetryDataContext = useTelemetryDataContext(); + + const formikRef = useRef>(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { locationChangeInterceptor } = useUnsavedChangesDialog(); + + const urlParams: Record = useParams(); + const deploymentId: number | undefined = Number(urlParams['deployment_id']); + + const critters = surveyContext.critterDataLoader.data ?? []; + + const deploymentDataLoader = useDataLoader(biohubApi.survey.getDeploymentById); + const deployment = deploymentDataLoader.data; + + useEffect(() => { + deploymentDataLoader.load(surveyContext.projectId, surveyContext.surveyId, deploymentId); + }, [deploymentDataLoader, deploymentId, surveyContext.projectId, surveyContext.surveyId]); + + if (!surveyContext.surveyDataLoader.data || !projectContext.projectDataLoader.data || !deployment) { + return ; + } + + const deploymentFormInitialValues = { + critter_id: deployment.critter_id, + device_id: String(deployment.device_id), + frequency: deployment.frequency, + frequency_unit: deployment.frequency_unit, + device_model: deployment.device_model, + device_make: deployment.device_make, + critterbase_start_capture_id: deployment.critterbase_start_capture_id, + critterbase_end_capture_id: deployment.critterbase_end_capture_id, + critterbase_end_mortality_id: deployment.critterbase_end_mortality_id, + attachment_end_date: deployment.attachment_end_date, + attachment_end_time: deployment.attachment_end_time + }; + + const handleSubmit = async (values: ICreateAnimalDeployment) => { + setIsSubmitting(true); + + try { + const critter_id = Number(critters?.find((animal) => animal.critter_id === values.critter_id)?.critter_id); + + if (!critter_id) { + throw new Error('Invalid critter data'); + } + + await biohubApi.survey.updateDeployment(surveyContext.projectId, surveyContext.surveyId, deploymentId, { + critter_id: values.critter_id, + device_id: Number(values.device_id), + device_make: values.device_make, + frequency: values.frequency, + frequency_unit: values.frequency_unit, + device_model: values.device_model, + critterbase_start_capture_id: values.critterbase_start_capture_id, + critterbase_end_capture_id: values.critterbase_end_capture_id, + critterbase_end_mortality_id: values.critterbase_end_mortality_id, + attachment_end_date: values.attachment_end_date, + attachment_end_time: values.attachment_end_time + }); + + telemetryDataContext.deploymentsDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + + // edit complete, navigate back to telemetry page + history.push( + `/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/telemetry`, + SKIP_CONFIRMATION_DIALOG + ); + } catch (error) { + dialogContext.setErrorDialog({ + dialogTitle: EditAnimalDeploymentI18N.createErrorTitle, + dialogText: EditAnimalDeploymentI18N.createErrorText, + dialogError: (error as APIError).message, + dialogErrorDetails: (error as APIError)?.errors, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + }, + open: true + }); + + setIsSubmitting(false); + } + }; + + return ( + <> + + + + + + + + + + + + ); +}; diff --git a/app/src/features/surveys/telemetry/device-keys/TelemetryDeviceKeysButton.tsx b/app/src/features/surveys/telemetry/device-keys/TelemetryDeviceKeysButton.tsx new file mode 100644 index 0000000000..556fa99888 --- /dev/null +++ b/app/src/features/surveys/telemetry/device-keys/TelemetryDeviceKeysButton.tsx @@ -0,0 +1,36 @@ +import { mdiKeyWireless } from '@mdi/js'; +import Icon from '@mdi/react'; +import IconButton from '@mui/material/IconButton'; +import { TelemetryDeviceKeysDialog } from 'features/surveys/telemetry/device-keys/TelemetryDeviceKeysDialog'; +import { useState } from 'react'; + +export interface ITelemetryDeviceKeysButtonProps { + /** + * Controls the disabled state of the button. + * + * @type {boolean} + * @memberof ITelemetryDeviceKeysButtonProps + */ + disabled?: boolean; +} + +export const TelemetryDeviceKeysButton = (props: ITelemetryDeviceKeysButtonProps) => { + const { disabled } = props; + + const [open, setOpen] = useState(false); + + return ( + <> + { + setOpen(false); + }} + /> + + setOpen(true)} disabled={disabled} aria-label="Manage telemetry device keys"> + + + + ); +}; diff --git a/app/src/features/surveys/telemetry/device-keys/TelemetryDeviceKeysDialog.tsx b/app/src/features/surveys/telemetry/device-keys/TelemetryDeviceKeysDialog.tsx new file mode 100644 index 0000000000..ab379def70 --- /dev/null +++ b/app/src/features/surveys/telemetry/device-keys/TelemetryDeviceKeysDialog.tsx @@ -0,0 +1,146 @@ +import LoadingButton from '@mui/lab/LoadingButton'; +import Box from '@mui/material/Box'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import useTheme from '@mui/material/styles/useTheme'; +import Typography from '@mui/material/Typography'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { AxiosProgressEvent, CancelTokenSource } from 'axios'; +import FileUpload from 'components/file-upload/FileUpload'; +import { AttachmentTypeFileExtensions } from 'constants/attachments'; +import { TelemetryDeviceKeysList } from 'features/surveys/telemetry/device-keys/TelemetryDeviceKeysList'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import useIsMounted from 'hooks/useIsMounted'; +import { useEffect } from 'react'; + +export interface ITelemetryDeviceKeysDialogProps { + /** + * Set to `true` to open the dialog, `false` to close the dialog. + */ + open: boolean; + /** + * Callback fired when the dialog is closed. + */ + onClose?: () => void; +} + +/** + * A dialog for managing telemetry device keys. + * + * @param {ITelemetryDeviceKeysDialogProps} props + * @return {*} + */ +export const TelemetryDeviceKeysDialog = (props: ITelemetryDeviceKeysDialogProps) => { + const { open, onClose } = props; + + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + + const surveyContext = useSurveyContext(); + const biohubApi = useBiohubApi(); + + const isMounted = useIsMounted(); + + const uploadHandler = async ( + file: File, + cancelToken: CancelTokenSource, + handleFileUploadProgress: (progressEvent: AxiosProgressEvent) => void + ) => { + return biohubApi.telemetry + .uploadTelemetryDeviceCredentialFile( + surveyContext.projectId, + surveyContext.surveyId, + file, + cancelToken, + handleFileUploadProgress + ) + .then(() => { + if (!isMounted()) { + return; + } + + telemetryDeviceKeyFileDataLoader.refresh(); + }) + .catch((error) => { + if (!isMounted()) { + return; + } + + throw error; + }) + .finally(() => { + if (!isMounted()) { + return; + } + }); + }; + + const acceptedFileExtensions = Array.from( + new Set([...AttachmentTypeFileExtensions.KEYX, ...AttachmentTypeFileExtensions.CFG]) + ); + + const telemetryDeviceKeyFileDataLoader = useDataLoader(() => + biohubApi.telemetry.getTelemetryDeviceKeyFiles(surveyContext.projectId, surveyContext.surveyId) + ); + + useEffect(() => { + if (!open) { + // If the dialog is not open, do not load the data + return; + } + + telemetryDeviceKeyFileDataLoader.load(); + }, [open, telemetryDeviceKeyFileDataLoader]); + + if (!open) { + return <>; + } + + return ( + + + Manage Device Keys + + + + Device keys allow telemetry data from Vectronic to be automatically loaded into your Survey. + + Vectronic device keys are .keyx files. + Lotek device keys are .cfg files. + + Telemetry data from other manufacturers must be imported manually. + + + + + + + + + + Close + + + + + ); +}; diff --git a/app/src/features/surveys/telemetry/device-keys/TelemetryDeviceKeysList.tsx b/app/src/features/surveys/telemetry/device-keys/TelemetryDeviceKeysList.tsx new file mode 100644 index 0000000000..4acb190112 --- /dev/null +++ b/app/src/features/surveys/telemetry/device-keys/TelemetryDeviceKeysList.tsx @@ -0,0 +1,156 @@ +import { mdiArrowTopRight } from '@mdi/js'; +import Box from '@mui/material/Box'; +import { grey } from '@mui/material/colors'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { GridColDef } from '@mui/x-data-grid'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonList } from 'components/loading/SkeletonLoaders'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import { TelemetryDeviceKeyFileI18N } from 'constants/i18n'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useDialogContext, useSurveyContext } from 'hooks/useContext'; +import { TelemetryDeviceKeyFile } from 'interfaces/useTelemetryApi.interface'; +import { getFormattedDate } from 'utils/Utils'; + +export interface ITelemetryDeviceKeysListProps { + isLoading?: boolean; + telementryCredentialAttachments: TelemetryDeviceKeyFile[]; +} + +export const TelemetryDeviceKeysList = (props: ITelemetryDeviceKeysListProps) => { + const { isLoading, telementryCredentialAttachments } = props; + + const dialogContext = useDialogContext(); + const surveyContext = useSurveyContext(); + + const biohubApi = useBiohubApi(); + + const handleDownload = async (attachment: TelemetryDeviceKeyFile) => { + try { + const response = await biohubApi.survey.getSurveyAttachmentSignedURL( + surveyContext.projectId, + surveyContext.surveyId, + attachment.survey_telemetry_credential_attachment_id, + attachment.file_type + ); + + if (!response) { + return; + } + + window.open(response); + } catch (error) { + const apiError = error as APIError; + // Show error dialog + dialogContext.setErrorDialog({ + open: true, + dialogTitle: TelemetryDeviceKeyFileI18N.downloadErrorTitle, + dialogText: TelemetryDeviceKeyFileI18N.downloadErrorText, + dialogErrorDetails: apiError.errors, + onOk: () => dialogContext.setErrorDialog({ open: false }), + onClose: () => dialogContext.setErrorDialog({ open: false }) + }); + } + }; + + const rows = telementryCredentialAttachments; + + // Define the columns for the DataGrid + const columns: GridColDef[] = [ + { + field: 'survey_telemetry_credential_attachment_id', + headerName: 'ID', + width: 70, + minWidth: 70, + renderHeader: () => ( + + ID + + ), + renderCell: (params) => ( + + {params.row.survey_telemetry_credential_attachment_id} + + ) + }, + { + field: 'file_name', + headerName: 'Name', + flex: 1, + disableColumnMenu: true, + renderCell: (params) => { + return ( + + handleDownload(params.row)} tabIndex={0}> + {params.value} + + + ); + } + }, + { + field: 'last_modified', + headerName: 'Last Modified', + flex: 1, + disableColumnMenu: true, + renderHeader: () => ( + + Last Modified + + ), + renderCell: (params) => ( + + {getFormattedDate(DATE_FORMAT.ShortMediumDateTimeFormat, params.row.update_date ?? params.row.create_date)} + + ) + } + ]; + + return ( + } + isLoadingFallbackDelay={100} + hasNoData={false} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + row.survey_telemetry_credential_attachment_id} + columns={columns} + pageSizeOptions={[5]} + rowSelection={false} + checkboxSelection={false} + hideFooter + disableRowSelectionOnClick + disableColumnSelector + disableColumnFilter + disableColumnMenu + sortingOrder={['asc', 'desc']} + data-testid="funding-source-table" + /> + + + ); +}; diff --git a/app/src/features/surveys/telemetry/list/SurveyDeploymentList.tsx b/app/src/features/surveys/telemetry/list/SurveyDeploymentList.tsx new file mode 100644 index 0000000000..74478d3932 --- /dev/null +++ b/app/src/features/surveys/telemetry/list/SurveyDeploymentList.tsx @@ -0,0 +1,335 @@ +import { mdiDotsVertical, mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import grey from '@mui/material/colors/grey'; +import Divider from '@mui/material/Divider'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormGroup from '@mui/material/FormGroup'; +import IconButton from '@mui/material/IconButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import Menu, { MenuProps } from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonList } from 'components/loading/SkeletonLoaders'; +import { TelemetryDeviceKeysButton } from 'features/surveys/telemetry/device-keys/TelemetryDeviceKeysButton'; +import { SurveyDeploymentListItem } from 'features/surveys/telemetry/list/SurveyDeploymentListItem'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useDialogContext, useSurveyContext, useTelemetryDataContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect, useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; + +/** + * Renders a list of all deployments in the survey + * + * @returns {*} + */ +export const SurveyDeploymentList = () => { + const dialogContext = useDialogContext(); + const surveyContext = useSurveyContext(); + const telemetryDataContext = useTelemetryDataContext(); + + const biohubApi = useBiohubApi(); + + const [anchorEl, setAnchorEl] = useState(null); + + const [checkboxSelectedIds, setCheckboxSelectedIds] = useState([]); + const [selectedDeploymentId, setSelectedDeploymentId] = useState(); + + const frequencyUnitDataLoader = useDataLoader(() => biohubApi.telemetry.getCodeValues('frequency_unit')); + const deviceMakesDataLoader = useDataLoader(() => biohubApi.telemetry.getCodeValues('device_make')); + + const deploymentsDataLoader = telemetryDataContext.deploymentsDataLoader; + const deployments = deploymentsDataLoader.data ?? []; + const deploymentCount = deployments?.length ?? 0; + + useEffect(() => { + frequencyUnitDataLoader.load(); + deviceMakesDataLoader.load(); + deploymentsDataLoader.load(surveyContext.projectId, surveyContext.surveyId); + }, [ + deploymentsDataLoader, + deviceMakesDataLoader, + frequencyUnitDataLoader, + surveyContext.projectId, + surveyContext.surveyId + ]); + + /** + * Callback for when a deployment action menu is clicked. + * + * @param {React.MouseEvent} event + * @param {number} deploymentId + */ + const handledDeploymentMenuClick = (event: React.MouseEvent, deploymentId: number) => { + setAnchorEl(event.currentTarget); + setSelectedDeploymentId(deploymentId); + }; + + /** + * Callback for when a checkbox is toggled. + * + * @param {number} deploymentId + */ + const handleCheckboxChange = (deploymentId: number) => { + setCheckboxSelectedIds((prev) => { + if (prev.includes(deploymentId)) { + return prev.filter((item) => item !== deploymentId); + } else { + return [...prev, deploymentId]; + } + }); + }; + + /** + * Callback for when the delete deployment action is confirmed. + */ + const handleDeleteDeployment = async () => { + await biohubApi.survey + .deleteDeployment(surveyContext.projectId, surveyContext.surveyId, Number(selectedDeploymentId)) + .then(() => { + dialogContext.setYesNoDialog({ open: false }); + setAnchorEl(null); + deploymentsDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + }) + .catch((error: any) => { + dialogContext.setYesNoDialog({ open: false }); + setAnchorEl(null); + dialogContext.setSnackbar({ + snackbarMessage: ( + <> + + Error Deleting Deployment + + + {String(error)} + + + ), + open: true + }); + }); + }; + + /** + * Display the delete deployment confirmation dialog. + */ + const renderDeleteDeploymentDialog = () => { + dialogContext.setYesNoDialog({ + dialogTitle: 'Delete Deployment?', + dialogContent: ( + + Are you sure you want to delete this deployment? All telemetry data from the deployment will also be + permanently deleted. + + ), + yesButtonLabel: 'Delete Deployment', + noButtonLabel: 'Cancel', + yesButtonProps: { color: 'error' }, + onClose: () => { + dialogContext.setYesNoDialog({ open: false }); + }, + onNo: () => { + dialogContext.setYesNoDialog({ open: false }); + }, + open: true, + onYes: () => { + handleDeleteDeployment(); + } + }); + }; + + return ( + <> + { + setAnchorEl(null); + }} + anchorEl={anchorEl} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right' + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right' + }}> + setAnchorEl(null)}> + + + + Edit Details + + { + renderDeleteDeploymentDialog(); + setAnchorEl(null); + }}> + + + + Delete + + + + + + + Deployments ‌ + + ({deploymentCount}) + + + + + + + + + + + + + + + } + isLoadingFallbackDelay={100} + hasNoData={!deploymentCount} + hasNoDataFallback={ + + No Deployments + + } + hasNoDataFallbackDelay={100}> + + + + + Select All + + } + control={ + 0 && checkboxSelectedIds.length === deploymentCount} + indeterminate={ + checkboxSelectedIds.length >= 1 && checkboxSelectedIds.length < deploymentCount + } + onClick={() => { + if (checkboxSelectedIds.length === deploymentCount) { + setCheckboxSelectedIds([]); + return; + } + + const deploymentIds = deployments.map((deployment) => deployment.deployment_id); + setCheckboxSelectedIds(deploymentIds); + }} + inputProps={{ 'aria-label': 'controlled' }} + /> + } + /> + + + + + {deployments.map((deployment) => { + const animal = surveyContext.critterDataLoader.data?.find( + (animal) => animal.critterbase_critter_id === deployment.critterbase_critter_id + ); + + if (!animal) { + return null; + } + + // Replace the deployment frequency_unit IDs with their human readable codes + const hydratedDeployment = { + ...deployment, + frequency_unit: + frequencyUnitDataLoader.data?.find( + (frequencyUnitOption) => frequencyUnitOption.id === deployment.frequency_unit + )?.code ?? null + }; + + return ( + + ); + })} + + + + + + + + ); +}; diff --git a/app/src/features/surveys/telemetry/list/SurveyDeploymentListItem.tsx b/app/src/features/surveys/telemetry/list/SurveyDeploymentListItem.tsx new file mode 100644 index 0000000000..fe139225e9 --- /dev/null +++ b/app/src/features/surveys/telemetry/list/SurveyDeploymentListItem.tsx @@ -0,0 +1,154 @@ +import { mdiChevronDown, mdiDotsVertical } from '@mdi/js'; +import Icon from '@mdi/react'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import Box from '@mui/material/Box'; +import Checkbox from '@mui/material/Checkbox'; +import green from '@mui/material/colors/green'; +import grey from '@mui/material/colors/grey'; +import IconButton from '@mui/material/IconButton'; +import List from '@mui/material/List'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { PulsatingDot } from 'components/misc/PulsatingDot'; +import dayjs from 'dayjs'; +import { SurveyDeploymentListItemDetails } from 'features/surveys/telemetry/list/SurveyDeploymentListItemDetails'; +import { ICritterSimpleResponse } from 'interfaces/useCritterApi.interface'; +import { IAnimalDeployment } from 'interfaces/useTelemetryApi.interface'; + +export interface ISurveyDeploymentListItemProps { + animal: ICritterSimpleResponse; + deployment: Omit & { frequency_unit: string | null }; + isChecked: boolean; + handleDeploymentMenuClick: (event: React.MouseEvent, deploymentId: number) => void; + handleCheckboxChange: (deploymentId: number) => void; +} + +/** + * Renders a list item for a single deployment record. + * + * @param {ISurveyDeploymentListItemProps} props + * @return {*} + */ +export const SurveyDeploymentListItem = (props: ISurveyDeploymentListItemProps) => { + const { animal, deployment, isChecked, handleDeploymentMenuClick, handleCheckboxChange } = props; + + const isDeploymentOver = + deployment.critterbase_end_mortality_id || + deployment.critterbase_end_capture_id || + dayjs(`${deployment.attachment_end_date} ${deployment.attachment_end_time}`).isBefore(dayjs()); + + return ( + + + } + aria-controls="panel1bh-content" + sx={{ + flex: '1 1 auto', + py: 0, + pr: 7, + pl: 0, + height: 75, + overflow: 'hidden', + '& .MuiAccordionSummary-content': { + flex: '1 1 auto', + py: 0, + pl: 0, + overflow: 'hidden', + whiteSpace: 'nowrap' + } + }}> + + { + event.stopPropagation(); + handleCheckboxChange(deployment.deployment_id); + }} + inputProps={{ 'aria-label': 'controlled' }} + /> + + + + {deployment.device_id} + + + {deployment.frequency} {deployment.frequency_unit} + + + + {animal.animal_id} + + + + + + {!isDeploymentOver && ( + + + + )} + + ) => + handleDeploymentMenuClick(event, deployment.deployment_id) + } + aria-label="deployment-settings"> + + + + + + + + + + ); +}; diff --git a/app/src/features/surveys/telemetry/list/SurveyDeploymentListItemDetails.tsx b/app/src/features/surveys/telemetry/list/SurveyDeploymentListItemDetails.tsx new file mode 100644 index 0000000000..7c1d130914 --- /dev/null +++ b/app/src/features/surveys/telemetry/list/SurveyDeploymentListItemDetails.tsx @@ -0,0 +1,92 @@ +import { mdiArrowRightThin } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Box from '@mui/material/Box'; +import grey from '@mui/material/colors/grey'; +import Skeleton from '@mui/material/Skeleton'; +import Typography from '@mui/material/Typography'; +import { DATE_FORMAT, TIME_FORMAT } from 'constants/dateTimeFormats'; +import dayjs from 'dayjs'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { IAnimalDeployment } from 'interfaces/useTelemetryApi.interface'; +import { useEffect } from 'react'; + +interface ISurveyDeploymentListItemDetailsProps { + deployment: Omit & { frequency_unit: string | null }; +} + +/** + * Renders information about a single telemetry deployment such as start and end dates + * + * @param props {ISurveyDeploymentListItemDetailsProps} + * @returns + */ +export const SurveyDeploymentListItemDetails = (props: ISurveyDeploymentListItemDetailsProps) => { + const { deployment } = props; + const critterbaseApi = useCritterbaseApi(); + + // TODO: Make these API calls in the parent as once call, then pass data as props + const startCaptureDataLoader = useDataLoader((captureId: string) => critterbaseApi.capture.getCapture(captureId)); + const endCaptureDataLoader = useDataLoader((captureId: string) => critterbaseApi.capture.getCapture(captureId)); + const endMortalityDataLoader = useDataLoader((mortalityId: string) => + critterbaseApi.mortality.getMortality(mortalityId) + ); + + useEffect(() => { + startCaptureDataLoader.load(deployment.critterbase_start_capture_id); + if (deployment.critterbase_end_capture_id) { + endCaptureDataLoader.load(deployment.critterbase_end_capture_id); + } + if (deployment.critterbase_end_mortality_id) { + endMortalityDataLoader.load(deployment.critterbase_end_mortality_id); + } + }, [startCaptureDataLoader, endCaptureDataLoader, endMortalityDataLoader, deployment]); + + const endCapture = endCaptureDataLoader.data; + const endMortality = endMortalityDataLoader.data; + + const endDate = endCapture?.capture_date || endMortality?.mortality_timestamp || deployment.attachment_end_date; + + const endDateFormatted = endDate ? dayjs(endDate).format(DATE_FORMAT.MediumDateFormat) : null; + + if (!startCaptureDataLoader.data) { + return ; + } + + const startDate = dayjs(startCaptureDataLoader.data.capture_date).format(DATE_FORMAT.MediumDateFormat); + const startTime = startCaptureDataLoader.data.capture_time; + + const endTime = + endCapture?.capture_time || + (endMortality?.mortality_timestamp && + dayjs(endMortality?.mortality_timestamp).format(TIME_FORMAT.LongTimeFormat24Hour)) || + deployment.attachment_end_time; + + return ( + + + + {startDate} + + + {startTime} + + + {endDateFormatted && ( + <> + + + + + + {endDateFormatted} + + + {endTime} + + + + )} + + ); +}; diff --git a/app/src/features/surveys/telemetry/telemetry-table/ManualTelemetryTable.tsx b/app/src/features/surveys/telemetry/table/TelemetryTable.tsx similarity index 67% rename from app/src/features/surveys/telemetry/telemetry-table/ManualTelemetryTable.tsx rename to app/src/features/surveys/telemetry/table/TelemetryTable.tsx index 3340509d4f..b01def63f0 100644 --- a/app/src/features/surveys/telemetry/telemetry-table/ManualTelemetryTable.tsx +++ b/app/src/features/surveys/telemetry/table/TelemetryTable.tsx @@ -1,18 +1,23 @@ import { cyan, grey } from '@mui/material/colors'; -import { DataGrid, GridColDef, GridRowParams } from '@mui/x-data-grid'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; import { - GenericActionsColDef, GenericDateColDef, GenericLatitudeColDef, GenericLongitudeColDef, GenericTimeColDef } from 'components/data-grid/GenericGridColumnDefinitions'; import { SkeletonTable } from 'components/loading/SkeletonLoaders'; -import { SurveyContext } from 'contexts/surveyContext'; import { IManualTelemetryTableRow } from 'contexts/telemetryTableContext'; -import { useTelemetryTableContext } from 'hooks/useContext'; -import { useCallback, useContext } from 'react'; -import { DeploymentColDef, TelemetryTypeColDef } from './utils/GridColumnDefinitions'; +import { + DeploymentColDef, + DeviceColDef, + TelemetryTypeColDef +} from 'features/surveys/telemetry/table/utils/GridColumnDefinitions'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext, useTelemetryDataContext, useTelemetryTableContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { IAnimalDeploymentWithCritter } from 'interfaces/useSurveyApi.interface'; +import { useEffect, useMemo } from 'react'; const MANUAL_TELEMETRY_TYPE = 'MANUAL'; @@ -20,28 +25,56 @@ interface IManualTelemetryTableProps { isLoading: boolean; } -const ManualTelemetryTable = (props: IManualTelemetryTableProps) => { +export const TelemetryTable = (props: IManualTelemetryTableProps) => { + const biohubApi = useBiohubApi(); + + const surveyContext = useSurveyContext(); + const telemetryDataContext = useTelemetryDataContext(); const telemetryTableContext = useTelemetryTableContext(); - const surveyContext = useContext(SurveyContext); - const { critterDeployments } = surveyContext; + const deploymentDataLoader = telemetryDataContext.deploymentsDataLoader; + const critterDataLoader = useDataLoader(biohubApi.survey.getSurveyCritters); - // Disable the delete action when record is 'Manual' telemetry or saving - const actionsDisabled = useCallback( - (params: GridRowParams) => { - return telemetryTableContext.isSaving || params.row.telemetry_type !== MANUAL_TELEMETRY_TYPE; - }, - [telemetryTableContext.isSaving] - ); + useEffect(() => { + deploymentDataLoader.load(surveyContext.projectId, surveyContext.surveyId); + critterDataLoader.load(surveyContext.projectId, surveyContext.surveyId); + }, [critterDataLoader, deploymentDataLoader, surveyContext.projectId, surveyContext.surveyId]); + + /** + * Merges critters with associated deployments + * + * @returns {ICritterDeployment[]} Critter deployments + */ + const critterDeployments: IAnimalDeploymentWithCritter[] = useMemo(() => { + const critterDeployments: IAnimalDeploymentWithCritter[] = []; + const critters = critterDataLoader.data ?? []; + const deployments = deploymentDataLoader.data ?? []; + + if (!critters.length || !deployments.length) { + return []; + } + + const critterMap = new Map(critters.map((critter) => [critter.critterbase_critter_id, critter])); + + deployments.forEach((deployment) => { + const critter = critterMap.get(String(deployment.critterbase_critter_id)); + if (critter) { + critterDeployments.push({ critter, deployment }); + } + }); + + return critterDeployments; + }, [critterDataLoader.data, deploymentDataLoader.data]); const columns: GridColDef[] = [ DeploymentColDef({ critterDeployments, hasError: telemetryTableContext.hasError }), - TelemetryTypeColDef(), + // TODO: Show animal nickname as a column + DeviceColDef({ critterDeployments }), GenericDateColDef({ field: 'date', headerName: 'Date', hasError: telemetryTableContext.hasError }), GenericTimeColDef({ field: 'time', headerName: 'Time', hasError: telemetryTableContext.hasError }), GenericLatitudeColDef({ field: 'latitude', headerName: 'Latitude', hasError: telemetryTableContext.hasError }), GenericLongitudeColDef({ field: 'longitude', headerName: 'Longitude', hasError: telemetryTableContext.hasError }), - GenericActionsColDef({ disabled: actionsDisabled, onDelete: telemetryTableContext.deleteRecords }) + TelemetryTypeColDef() ]; return ( @@ -76,6 +109,12 @@ const ManualTelemetryTable = (props: IManualTelemetryTableProps) => { noRowsLabel: 'No Records' }} getRowHeight={() => 'auto'} + initialState={{ + pagination: { + paginationModel: { page: 0, pageSize: 25 } + } + }} + pageSizeOptions={[25, 50, 100]} slots={{ loadingOverlay: SkeletonTable }} @@ -164,5 +203,3 @@ const ManualTelemetryTable = (props: IManualTelemetryTableProps) => { /> ); }; - -export default ManualTelemetryTable; diff --git a/app/src/features/surveys/telemetry/telemetry-table/ManualTelemetryTableContainer.tsx b/app/src/features/surveys/telemetry/table/TelemetryTableContainer.tsx similarity index 96% rename from app/src/features/surveys/telemetry/telemetry-table/ManualTelemetryTableContainer.tsx rename to app/src/features/surveys/telemetry/table/TelemetryTableContainer.tsx index 6487d76000..1115fbf82e 100644 --- a/app/src/features/surveys/telemetry/telemetry-table/ManualTelemetryTableContainer.tsx +++ b/app/src/features/surveys/telemetry/table/TelemetryTableContainer.tsx @@ -22,14 +22,14 @@ import { UploadFileStatus } from 'components/file-upload/FileUploadItem'; import { TelemetryTableI18N } from 'constants/i18n'; import { DialogContext, ISnackbarProps } from 'contexts/dialogContext'; import { SurveyContext } from 'contexts/surveyContext'; +import { TelemetryTable } from 'features/surveys/telemetry/table/TelemetryTable'; +import { useBiohubApi } from 'hooks/useBioHubApi'; import { useTelemetryTableContext } from 'hooks/useContext'; -import { useTelemetryApi } from 'hooks/useTelemetryApi'; import { useContext, useDeferredValue, useState } from 'react'; import { pluralize as p } from 'utils/Utils'; -import ManualTelemetryTable from './ManualTelemetryTable'; -const ManualTelemetryTableContainer = () => { - const telemetryApi = useTelemetryApi(); +export const TelemetryTableContainer = () => { + const biohubApi = useBiohubApi(); const dialogContext = useContext(DialogContext); const telemetryTableContext = useTelemetryTableContext(); @@ -58,10 +58,10 @@ const ManualTelemetryTableContainer = () => { }; const handleFileImport = async (file: File) => { - telemetryApi.uploadCsvForImport(surveyContext.projectId, surveyContext.surveyId, file).then((response) => { + biohubApi.telemetry.uploadCsvForImport(surveyContext.projectId, surveyContext.surveyId, file).then((response) => { setShowImportDialog(false); setProcessingRecords(true); - telemetryApi + biohubApi.telemetry .processTelemetryCsvSubmission(response.submission_id) .then(() => { showSnackBar({ @@ -276,12 +276,10 @@ const ManualTelemetryTableContainer = () => { - + ); }; - -export default ManualTelemetryTableContainer; diff --git a/app/src/features/surveys/telemetry/telemetry-table/utils/GridColumnDefinitions.tsx b/app/src/features/surveys/telemetry/table/utils/GridColumnDefinitions.tsx similarity index 69% rename from app/src/features/surveys/telemetry/telemetry-table/utils/GridColumnDefinitions.tsx rename to app/src/features/surveys/telemetry/table/utils/GridColumnDefinitions.tsx index 288ed17557..a18885cc98 100644 --- a/app/src/features/surveys/telemetry/telemetry-table/utils/GridColumnDefinitions.tsx +++ b/app/src/features/surveys/telemetry/table/utils/GridColumnDefinitions.tsx @@ -1,9 +1,10 @@ +import { Typography } from '@mui/material'; import { GridCellParams, GridColDef } from '@mui/x-data-grid'; import AutocompleteDataGridEditCell from 'components/data-grid/autocomplete/AutocompleteDataGridEditCell'; import AutocompleteDataGridViewCell from 'components/data-grid/autocomplete/AutocompleteDataGridViewCell'; import { IManualTelemetryTableRow } from 'contexts/telemetryTableContext'; +import { IAnimalDeploymentWithCritter } from 'interfaces/useSurveyApi.interface'; import { capitalize } from 'lodash-es'; -import { ICritterDeployment } from '../../ManualTelemetryList'; export const TelemetryTypeColDef = (): GridColDef => { return { @@ -21,7 +22,7 @@ export const TelemetryTypeColDef = (): GridColDef => { }; export const DeploymentColDef = (props: { - critterDeployments: ICritterDeployment[]; + critterDeployments: IAnimalDeploymentWithCritter[]; hasError: (params: GridCellParams) => boolean; }): GridColDef => { return { @@ -42,7 +43,7 @@ export const DeploymentColDef = (props: { options={props.critterDeployments.map((item) => { return { label: `${item.critter.animal_id}: ${item.deployment.device_id}`, - value: item.deployment.deployment_id + value: item.deployment.bctw_deployment_id }; })} error={error} @@ -57,7 +58,7 @@ export const DeploymentColDef = (props: { dataGridProps={params} options={props.critterDeployments.map((item) => ({ label: `${item.critter.animal_id}: ${item.deployment.device_id}`, - value: item.deployment.deployment_id + value: item.deployment.bctw_deployment_id }))} error={error} /> @@ -65,3 +66,26 @@ export const DeploymentColDef = (props: { } }; }; + +export const DeviceColDef = (props: { + critterDeployments: IAnimalDeploymentWithCritter[]; +}): GridColDef => { + return { + field: 'device_id', + headerName: 'Device', + hideable: true, + minWidth: 120, + disableColumnMenu: true, + headerAlign: 'left', + align: 'left', + renderCell: (params) => ( + + { + props.critterDeployments.find( + (deployment) => deployment.deployment.bctw_deployment_id === params.row.deployment_id + )?.deployment.device_id + } + + ) + }; +}; diff --git a/app/src/features/surveys/view/SurveyAnimals.test.tsx b/app/src/features/surveys/view/SurveyAnimals.test.tsx deleted file mode 100644 index b72538159e..0000000000 --- a/app/src/features/surveys/view/SurveyAnimals.test.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { AuthStateContext } from 'contexts/authStateContext'; -import { IProjectAuthStateContext, ProjectAuthStateContext } from 'contexts/projectAuthStateContext'; -import { IProjectContext, ProjectContext } from 'contexts/projectContext'; -import { ISurveyContext, SurveyContext } from 'contexts/surveyContext'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { DataLoader } from 'hooks/useDataLoader'; -import { useTelemetryApi } from 'hooks/useTelemetryApi'; -import { BrowserRouter } from 'react-router-dom'; -import { getMockAuthState, SystemAdminAuthState } from 'test-helpers/auth-helpers'; -import { cleanup, fireEvent, render, waitFor } from 'test-helpers/test-utils'; -import SurveyAnimals from './SurveyAnimals'; - -jest.mock('../../../hooks/useBioHubApi'); -jest.mock('../../../hooks/useTelemetryApi'); -const mockBiohubApi = useBiohubApi as jest.Mock; -const mockTelemetryApi = useTelemetryApi as jest.Mock; - -const mockUseBiohub = { - survey: { - getSurveyCritters: jest.fn(), - getDeploymentsInSurvey: jest.fn(), - createCritterAndAddToSurvey: jest.fn(), - addDeployment: jest.fn() - } -}; - -const mockUseTelemetry = { - devices: { - getDeviceDetails: jest.fn() - } -}; - -describe('SurveyAnimals', () => { - const mockSurveyContext: ISurveyContext = { - artifactDataLoader: { - data: null, - load: jest.fn() - } as unknown as DataLoader, - surveyId: 1, - projectId: 1, - surveyDataLoader: { - data: { surveyData: { survey_details: { survey_name: 'name' } } }, - load: jest.fn() - } as unknown as DataLoader - } as unknown as ISurveyContext; - - const mockProjectAuthStateContext: IProjectAuthStateContext = { - getProjectParticipant: () => null, - hasProjectRole: () => true, - hasProjectPermission: () => true, - hasSystemRole: () => true, - getProjectId: () => 1, - hasLoadedParticipantInfo: true - }; - - const mockProjectContext: IProjectContext = { - artifactDataLoader: { - data: null, - load: jest.fn() - } as unknown as DataLoader, - projectId: 1, - projectDataLoader: { - data: { projectData: { project: { project_name: 'name' } } }, - load: jest.fn() - } as unknown as DataLoader - } as unknown as IProjectContext; - - const authState = getMockAuthState({ base: SystemAdminAuthState }); - - beforeEach(() => { - mockBiohubApi.mockImplementation(() => mockUseBiohub); - mockUseBiohub.survey.getDeploymentsInSurvey.mockClear(); - mockUseBiohub.survey.getSurveyCritters.mockClear(); - mockUseBiohub.survey.createCritterAndAddToSurvey.mockClear(); - mockUseBiohub.survey.addDeployment.mockClear(); - - mockTelemetryApi.mockImplementation(() => mockUseTelemetry); - mockUseTelemetry.devices.getDeviceDetails.mockClear(); - }); - - afterEach(() => { - cleanup(); - }); - - it('renders correctly with no animals', async () => { - const { getByText } = render( - - - - - - - - - - - - ); - - await waitFor(() => { - expect(getByText('No Animals')).toBeInTheDocument(); - }); - }); - - it('renders correctly with animals', async () => { - mockUseBiohub.survey.getSurveyCritters.mockResolvedValueOnce([ - { - critter_id: 'critter_uuid', - survey_critter_id: 1, - animal_id: 'animal_alias', - taxon: 'a', - created_at: 'a', - wlh_id: '123-45' - } - ]); - - mockUseBiohub.survey.getDeploymentsInSurvey.mockResolvedValue([{ critter_id: 'critter_uuid', device_id: 123 }]); - mockUseBiohub.survey.createCritterAndAddToSurvey.mockResolvedValue({}); - mockUseTelemetry.devices.getDeviceDetails.mockResolvedValue({ device: undefined, deployments: [] }); - const { getByText, getByTestId } = render( - - - - - - - - - - - - ); - - await waitFor(() => { - expect(getByText('123-45')).toBeInTheDocument(); - expect(getByTestId('survey-animal-table')).toBeInTheDocument(); - fireEvent.click(getByTestId('animal actions')); - fireEvent.click(getByTestId('animal-table-row-edit-critter')); - }); - await waitFor(() => { - expect(getByText('Manage Animals')).toBeInTheDocument(); - expect(getByText('animal_alias')).toBeInTheDocument(); - }); - }); -}); diff --git a/app/src/features/surveys/view/SurveyAnimals.tsx b/app/src/features/surveys/view/SurveyAnimals.tsx deleted file mode 100644 index fe7e168078..0000000000 --- a/app/src/features/surveys/view/SurveyAnimals.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { mdiCog } from '@mdi/js'; -import Icon from '@mdi/react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Divider from '@mui/material/Divider'; -import Toolbar from '@mui/material/Toolbar'; -import Typography from '@mui/material/Typography'; -import ComponentDialog from 'components/dialog/ComponentDialog'; -import YesNoDialog from 'components/dialog/YesNoDialog'; -import { ProjectRoleGuard } from 'components/security/Guards'; -import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; -import { DialogContext } from 'contexts/dialogContext'; -import { SurveyContext } from 'contexts/surveyContext'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import useDataLoader from 'hooks/useDataLoader'; -import React, { useContext, useEffect, useMemo, useState } from 'react'; -import { Link as RouterLink, useHistory } from 'react-router-dom'; -import NoSurveySectionData from '../components/NoSurveySectionData'; -import { SurveyAnimalsTable } from './survey-animals/SurveyAnimalsTable'; -import TelemetryMap from './survey-animals/telemetry-device/TelemetryMap'; - -const SurveyAnimals: React.FC = () => { - const biohubApi = useBiohubApi(); - const dialogContext = useContext(DialogContext); - const surveyContext = useContext(SurveyContext); - const history = useHistory(); - - const [openRemoveCritterDialog, setOpenRemoveCritterDialog] = useState(false); - const [openViewTelemetryDialog, setOpenViewTelemetryDialog] = useState(false); - const [selectedCritterId, setSelectedCritterId] = useState(null); - - const { projectId, surveyId } = surveyContext; - const { - refresh: refreshCritters, - load: loadCritters, - data: critterData - } = useDataLoader(() => biohubApi.survey.getSurveyCritters(projectId, surveyId)); - - const { load: loadDeployments, data: deploymentData } = useDataLoader(() => - biohubApi.survey.getDeploymentsInSurvey(projectId, surveyId) - ); - - if (!critterData) { - loadCritters(); - } - - const currentCritterbaseCritterId = useMemo( - () => critterData?.find((a) => a.survey_critter_id === selectedCritterId)?.critter_id, - [critterData, selectedCritterId] - ); - - if (!deploymentData) { - loadDeployments(); - } - - const { - refresh: refreshTelemetry, - data: telemetryData, - isLoading: telemetryLoading - } = useDataLoader(() => - biohubApi.survey.getCritterTelemetry( - projectId, - surveyId, - selectedCritterId ?? 0, - '1970-01-01', - new Date().toISOString() - ) - ); - - useEffect(() => { - if (currentCritterbaseCritterId) { - refreshTelemetry(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentCritterbaseCritterId]); - - const setPopup = (message: string) => { - dialogContext.setSnackbar({ - open: true, - snackbarMessage: ( - - {message} - - ) - }); - }; - - const handleRemoveCritter = async () => { - try { - if (!selectedCritterId) { - setPopup('Failed to remove critter from survey.'); - return; - } - await biohubApi.survey.removeCrittersFromSurvey(projectId, surveyId, [selectedCritterId]); - } catch (e) { - setPopup('Failed to remove critter from survey.'); - } - setOpenRemoveCritterDialog(false); - refreshCritters(); - }; - - return ( - - setOpenRemoveCritterDialog(false)} - onNo={() => setOpenRemoveCritterDialog(false)} - onYes={handleRemoveCritter} - /> - - - Animals - - - - - - - - {critterData?.length ? ( - { - setOpenRemoveCritterDialog(true); - }} - onEditCritter={() => { - history.push(`animals/${selectedCritterId}/edit`); - }} - onMapOpen={() => { - setOpenViewTelemetryDialog(true); - }} - /> - ) : ( - - )} - - setOpenViewTelemetryDialog(false)}> - {telemetryData?.points.features.length ? ( - a.critter_id === currentCritterbaseCritterId)} - /> - ) : ( - - {telemetryLoading - ? 'Loading telemetry...' - : "No telemetry has been collected for this animal's deployments."} - - )} - - - ); -}; -export default SurveyAnimals; diff --git a/app/src/features/surveys/view/SurveyAttachments.test.tsx b/app/src/features/surveys/view/SurveyAttachments.test.tsx index 5c796e4f7c..aa6f6bcc91 100644 --- a/app/src/features/surveys/view/SurveyAttachments.test.tsx +++ b/app/src/features/surveys/view/SurveyAttachments.test.tsx @@ -120,7 +120,9 @@ describe('SurveyAttachments', () => { const mockSurveyContext: ISurveyContext = { artifactDataLoader: { data: null, - load: jest.fn() + load: jest.fn(), + isLoading: false, + isReady: true } as unknown as DataLoader, surveyId: 1, projectId: 1, @@ -152,7 +154,7 @@ describe('SurveyAttachments', () => { } as unknown as DataLoader } as unknown as IProjectContext; - const { getByText } = render( + const { getByTestId } = render( @@ -168,7 +170,7 @@ describe('SurveyAttachments', () => { ); await waitFor(() => { - expect(getByText('No documents found')).toBeInTheDocument(); + expect(getByTestId('survey-attachments-list-no-data-overlay')).toBeInTheDocument(); }); }); @@ -185,7 +187,9 @@ describe('SurveyAttachments', () => { } ] }, - load: jest.fn() + load: jest.fn(), + isLoading: false, + isReady: true } as unknown as DataLoader, surveyId: 1, projectId: 1, @@ -263,13 +267,17 @@ describe('SurveyAttachments', () => { } ] }, - load: jest.fn() + load: jest.fn(), + isLoading: false, + isReady: true } as unknown as DataLoader, surveyId: 1, projectId: 1, surveyDataLoader: { data: { surveyData: { survey_details: { survey_name: 'name' } } }, - load: jest.fn() + load: jest.fn(), + isLoading: false, + isReady: true } as unknown as DataLoader } as unknown as ISurveyContext; @@ -356,13 +364,17 @@ describe('SurveyAttachments', () => { } ] }, - load: jest.fn() + load: jest.fn(), + isLoading: false, + isReady: true } as unknown as DataLoader, surveyId: 1, projectId: 1, surveyDataLoader: { data: { surveyData: { survey_details: { survey_name: 'name' } } }, - load: jest.fn() + load: jest.fn(), + isLoading: false, + isReady: true } as unknown as DataLoader } as unknown as ISurveyContext; @@ -448,13 +460,17 @@ describe('SurveyAttachments', () => { } ] }, - load: jest.fn() + load: jest.fn(), + isLoading: false, + isReady: true } as unknown as DataLoader, surveyId: 1, projectId: 1, surveyDataLoader: { data: { surveyData: { survey_details: { survey_name: 'name' } } }, - load: jest.fn() + load: jest.fn(), + isLoading: false, + isReady: true } as unknown as DataLoader } as unknown as ISurveyContext; diff --git a/app/src/features/surveys/view/SurveyAttachments.tsx b/app/src/features/surveys/view/SurveyAttachments.tsx index bcef262b37..36e9b2c8c8 100644 --- a/app/src/features/surveys/view/SurveyAttachments.tsx +++ b/app/src/features/surveys/view/SurveyAttachments.tsx @@ -1,4 +1,4 @@ -import { mdiAttachment, mdiFilePdfBox, mdiFolderKeyOutline, mdiTrayArrowUp } from '@mdi/js'; +import { mdiAttachment, mdiFilePdfBox, mdiTrayArrowUp } from '@mdi/js'; import Icon from '@mdi/react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; @@ -11,11 +11,11 @@ import { H2MenuToolbar } from 'components/toolbar/ActionToolbars'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; import { SurveyContext } from 'contexts/surveyContext'; import { useBiohubApi } from 'hooks/useBioHubApi'; -import React, { useContext, useState } from 'react'; +import { useContext, useState } from 'react'; import { AttachmentType } from '../../../constants/attachments'; import SurveyAttachmentsList from './SurveyAttachmentsList'; -const SurveyAttachments: React.FC = () => { +const SurveyAttachments = () => { const biohubApi = useBiohubApi(); const surveyContext = useContext(SurveyContext); @@ -23,20 +23,15 @@ const SurveyAttachments: React.FC = () => { const { projectId, surveyId } = surveyContext; const [openUploadAttachments, setOpenUploadAttachments] = useState(false); - const [attachmentType, setAttachmentType] = useState< - AttachmentType.REPORT | AttachmentType.OTHER | AttachmentType.KEYX - >(AttachmentType.OTHER); + const [attachmentType, setAttachmentType] = useState( + AttachmentType.OTHER + ); const handleUploadReportClick = () => { setAttachmentType(AttachmentType.REPORT); setOpenUploadAttachments(true); }; - const handleUploadKeyxClick = () => { - setAttachmentType(AttachmentType.KEYX); - setOpenUploadAttachments(true); - }; - const handleUploadAttachmentClick = () => { setAttachmentType(AttachmentType.OTHER); setOpenUploadAttachments(true); @@ -44,9 +39,7 @@ const SurveyAttachments: React.FC = () => { const getUploadHandler = (): IUploadHandler => { return (file, cancelToken, handleFileUploadProgress) => { - return attachmentType === AttachmentType.KEYX - ? biohubApi.survey.uploadSurveyKeyx(projectId, surveyId, file, cancelToken, handleFileUploadProgress) - : biohubApi.survey.uploadSurveyAttachments(projectId, surveyId, file, cancelToken, handleFileUploadProgress); + return biohubApi.survey.uploadSurveyAttachments(projectId, surveyId, file, cancelToken, handleFileUploadProgress); }; }; @@ -64,8 +57,6 @@ const SurveyAttachments: React.FC = () => { switch (attachmentType) { case AttachmentType.REPORT: return 'Upload Report'; - case AttachmentType.KEYX: - return 'Upload KeyX'; case AttachmentType.OTHER: return 'Upload Attachments'; default: @@ -99,11 +90,6 @@ const SurveyAttachments: React.FC = () => { menuIcon: , menuOnClick: handleUploadReportClick }, - { - menuLabel: 'Upload KeyX Files', - menuIcon: , - menuOnClick: handleUploadKeyxClick - }, { menuLabel: 'Upload Attachments', menuIcon: , diff --git a/app/src/features/surveys/view/SurveyAttachmentsList.tsx b/app/src/features/surveys/view/SurveyAttachmentsList.tsx index b4fdde67ee..939d6ec04a 100644 --- a/app/src/features/surveys/view/SurveyAttachmentsList.tsx +++ b/app/src/features/surveys/view/SurveyAttachmentsList.tsx @@ -1,5 +1,9 @@ +import { mdiArrowTopRight } from '@mdi/js'; import AttachmentsList from 'components/attachments/list/AttachmentsList'; import SurveyReportAttachmentDialog from 'components/dialog/attachments/survey/SurveyReportAttachmentDialog'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; import { AttachmentsI18N } from 'constants/i18n'; import { DialogContext } from 'contexts/dialogContext'; import { SurveyContext } from 'contexts/surveyContext'; @@ -96,6 +100,11 @@ const SurveyAttachmentsList: React.FC = () => { }); }; + const attachments = [ + ...(surveyContext.artifactDataLoader.data?.attachmentsList || []), + ...(surveyContext.artifactDataLoader.data?.reportAttachmentsList || []) + ]; + return ( <> { open={viewReportDetailsDialogOpen} onClose={() => setViewReportDetailsDialogOpen(false)} /> - - attachments={[ - ...(surveyContext.artifactDataLoader.data?.attachmentsList || []), - ...(surveyContext.artifactDataLoader.data?.reportAttachmentsList || []) - ]} - handleDownload={handleDownload} - handleDelete={handleDelete} - handleViewDetails={handleViewDetails} - emptyStateText="No documents found" - /> + } + isLoadingFallbackDelay={100} + hasNoData={!attachments.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + attachments={attachments} + handleDownload={handleDownload} + handleDelete={handleDelete} + handleViewDetails={handleViewDetails} + emptyStateText="No documents found" + /> + ); }; diff --git a/app/src/features/surveys/view/SurveyDetails.test.tsx b/app/src/features/surveys/view/SurveyDetails.test.tsx index 51999f09c1..7c6044d245 100644 --- a/app/src/features/surveys/view/SurveyDetails.test.tsx +++ b/app/src/features/surveys/view/SurveyDetails.test.tsx @@ -53,7 +53,6 @@ describe('SurveyDetails', () => { const mockArtifactDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockDeploymentDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; it('renders correctly', async () => { @@ -63,13 +62,11 @@ describe('SurveyDetails', () => { value={{ projectId: 1, surveyId: 1, - critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, techniqueDataLoader: mockTechniqueDataLoader, - critterDataLoader: mockCritterDataLoader, - deploymentDataLoader: mockDeploymentDataLoader + critterDataLoader: mockCritterDataLoader }}> diff --git a/app/src/features/surveys/view/SurveyHeader.test.tsx b/app/src/features/surveys/view/SurveyHeader.test.tsx index 0bcb3a8342..a1c685afab 100644 --- a/app/src/features/surveys/view/SurveyHeader.test.tsx +++ b/app/src/features/surveys/view/SurveyHeader.test.tsx @@ -1,4 +1,5 @@ import { AuthStateContext, IAuthState } from 'contexts/authStateContext'; +import { CodesContext, ICodesContext } from 'contexts/codesContext'; import { ConfigContext, IConfig } from 'contexts/configContext'; import { DialogContextProvider } from 'contexts/dialogContext'; import { IProjectAuthStateContext, ProjectAuthStateContext } from 'contexts/projectAuthStateContext'; @@ -12,6 +13,7 @@ import { IGetProjectForViewResponse } from 'interfaces/useProjectApi.interface'; import { IGetSurveyForViewResponse } from 'interfaces/useSurveyApi.interface'; import { Router } from 'react-router'; import { getMockAuthState, SystemAdminAuthState, SystemUserAuthState } from 'test-helpers/auth-helpers'; +import { codes } from 'test-helpers/code-helpers'; import { getProjectForViewResponse } from 'test-helpers/project-helpers'; import { getSurveyForViewResponse } from 'test-helpers/survey-helpers'; import { cleanup, fireEvent, render, waitFor } from 'test-helpers/test-utils'; @@ -28,6 +30,13 @@ const mockUseApi = { } }; +const mockCodesContext: ICodesContext = { + codesDataLoader: { + data: codes, + load: () => {} + } as DataLoader +}; + const mockSurveyContext: ISurveyContext = { surveyDataLoader: { data: getSurveyForViewResponse @@ -41,13 +50,9 @@ const mockSurveyContext: ISurveyContext = { critterDataLoader: { data: null } as DataLoader, - deploymentDataLoader: { - data: null - } as DataLoader, techniqueDataLoader: { data: [] } as DataLoader, - critterDeployments: [], surveyId: 1, projectId: 1 }; @@ -104,11 +109,13 @@ describe('SurveyHeader', () => { - - - - - + + + + + + + diff --git a/app/src/features/surveys/view/SurveyMap.tsx b/app/src/features/surveys/view/SurveyMap.tsx index 624e8ee0b9..586501c5ea 100644 --- a/app/src/features/surveys/view/SurveyMap.tsx +++ b/app/src/features/surveys/view/SurveyMap.tsx @@ -11,77 +11,23 @@ import { useMemo } from 'react'; import { LayersControl, MapContainer as LeafletMapContainer } from 'react-leaflet'; import { calculateUpdatedMapBounds } from 'utils/mapBoundaryUploadHelpers'; -export interface ISurveyMapPointMetadata { - label: string; - value: string; -} - -export interface ISurveyMapSupplementaryLayer { - /** - * The name of the layer - */ - layerName: string; - /** - * The colour of the layer - */ - layerColors?: { - color: string; - fillColor: string; - opacity?: number; - }; - /** - * The array of map points - */ - mapPoints: ISurveyMapPoint[]; - /** - * The title of the feature type displayed in the popup - */ - popupRecordTitle: string; -} - -export interface ISurveyMapPoint { - /** - * Unique key for the point - */ - key: string; - /** - * The geometric feature to display - */ - feature: Feature; - - /** - * Callback to fetch metadata, which is fired when the geometry's popup - * is opened - */ - onLoadMetadata: () => Promise; - /** - * Optional link that renders a button to view/manage/edit the data - * that the geometry belongs to - */ - link?: string; -} - interface ISurveyMapProps { staticLayers: IStaticLayer[]; - supplementaryLayers: ISurveyMapSupplementaryLayer[]; isLoading: boolean; } const SurveyMap = (props: ISurveyMapProps) => { const bounds: LatLngBoundsExpression | undefined = useMemo(() => { - const allMapFeatures: Feature[] = [ - ...props.supplementaryLayers.flatMap((supplementaryLayer) => - supplementaryLayer.mapPoints.map((mapPoint) => mapPoint.feature) - ), - ...props.staticLayers.flatMap((staticLayer) => staticLayer.features.map((feature) => feature.geoJSON)) - ]; + const allMapFeatures: Feature[] = props.staticLayers.flatMap((staticLayer) => + staticLayer.features.map((feature) => feature.geoJSON) + ); if (allMapFeatures.length > 0) { return calculateUpdatedMapBounds(allMapFeatures); } else { return calculateUpdatedMapBounds([ALL_OF_BC_BOUNDARY]); } - }, [props.supplementaryLayers, props.staticLayers]); + }, [props.staticLayers]); return ( <> diff --git a/app/src/features/surveys/view/SurveyMapPopup.tsx b/app/src/features/surveys/view/SurveyMapPopup.tsx index 7fdb8a8caa..7dbf8ad007 100644 --- a/app/src/features/surveys/view/SurveyMapPopup.tsx +++ b/app/src/features/surveys/view/SurveyMapPopup.tsx @@ -2,44 +2,49 @@ import Box from '@mui/material/Box'; import Skeleton from '@mui/material/Skeleton'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; -import { ISurveyMapPointMetadata } from './SurveyMap'; -interface ISurveyMapPopupProps { +export interface ISurveyMapPopupProps { isLoading: boolean; title: string; - metadata: ISurveyMapPointMetadata[]; + metadata: { + label: string; + value: string | number; + }[]; } /** * Returns a popup component for displaying information about a leaflet map layer upon being clicked * - * @param props {ISurveyMapPopupProps} - * @returns + * @param {ISurveyMapPopupProps} props + * @return {*} */ -const SurveyMapPopup = (props: ISurveyMapPopupProps) => { +export const SurveyMapPopup = (props: ISurveyMapPopupProps) => { + const { isLoading, title, metadata } = props; + return ( - {props.isLoading ? ( - + {isLoading ? ( + - + - + - + @@ -57,17 +62,17 @@ const SurveyMapPopup = (props: ISurveyMapPopupProps) => { textOverflow: 'ellipsis', textTransform: 'uppercase' }}> - {props.title} + {title} - {props.metadata.map((metadata) => ( + {metadata.map((metadata) => ( - + {metadata.label}: @@ -81,5 +86,3 @@ const SurveyMapPopup = (props: ISurveyMapPopupProps) => { ); }; - -export default SurveyMapPopup; diff --git a/app/src/features/surveys/view/SurveyMapTooltip.tsx b/app/src/features/surveys/view/SurveyMapTooltip.tsx index d672a8d57d..654392e14a 100644 --- a/app/src/features/surveys/view/SurveyMapTooltip.tsx +++ b/app/src/features/surveys/view/SurveyMapTooltip.tsx @@ -1,20 +1,23 @@ import Typography from '@mui/material/Typography'; +import { Tooltip } from 'react-leaflet'; interface ISurveyMapTooltipProps { - label: string; + title: string; } /** * Returns a popup component for displaying information about a leaflet map layer upon hover * - * @param props {ISurveyMapPopupProps} - * @returns + * @param {ISurveyMapTooltipProps} props + * @return {*} */ const SurveyMapTooltip = (props: ISurveyMapTooltipProps) => { return ( - - {props.label} - + + + {props.title} + + ); }; diff --git a/app/src/features/surveys/view/SurveyPage.tsx b/app/src/features/surveys/view/SurveyPage.tsx index 26502c5ccd..a1e7c05aac 100644 --- a/app/src/features/surveys/view/SurveyPage.tsx +++ b/app/src/features/surveys/view/SurveyPage.tsx @@ -8,9 +8,8 @@ import { TaxonomyContextProvider } from 'contexts/taxonomyContext'; import SurveyDetails from 'features/surveys/view/SurveyDetails'; import React, { useContext, useEffect } from 'react'; import { SurveySamplingContainer } from './components/sampling-data/SurveySamplingContainer'; -import SurveySpatialData from './components/spatial-data/SurveySpatialData'; import SurveyStudyArea from './components/SurveyStudyArea'; -import SurveyAnimals from './SurveyAnimals'; +import { SurveySpatialContainer } from './survey-spatial/SurveySpatialContainer'; import SurveyAttachments from './SurveyAttachments'; import SurveyHeader from './SurveyHeader'; @@ -40,12 +39,10 @@ const SurveyPage: React.FC = () => { - - - - - + + + diff --git a/app/src/features/surveys/view/components/Partnerships.tsx b/app/src/features/surveys/view/components/Partnerships.tsx index 1bae504921..3040371202 100644 --- a/app/src/features/surveys/view/components/Partnerships.tsx +++ b/app/src/features/surveys/view/components/Partnerships.tsx @@ -29,25 +29,60 @@ const Partnerships = () => { Indigenous Partnerships - {surveyData.partnerships.indigenous_partnerships?.map((indigenousPartnership: number) => { - return ( - - {codes.first_nations?.find((item: any) => item.id === indigenousPartnership)?.name} - - ); - })} + + {surveyData.partnerships.indigenous_partnerships?.map((indigenousPartnership: number) => { + return ( + + {codes.first_nations?.find((item: any) => item.id === indigenousPartnership)?.name} + + ); + })} + + {!hasIndigenousPartnerships && None} Other Partnerships - {surveyData.partnerships.stakeholder_partnerships?.map((stakeholderPartnership: string) => { - return ( - - {stakeholderPartnership} - - ); - })} + + {surveyData.partnerships.stakeholder_partnerships?.map((stakeholderPartnership: string) => { + return ( + + {stakeholderPartnership} + + ); + })} + {!hasStakeholderPartnerships && None} diff --git a/app/src/features/surveys/view/components/SamplingMethods.tsx b/app/src/features/surveys/view/components/SamplingMethods.tsx index 1350db1bd0..a2ee8a2e0f 100644 --- a/app/src/features/surveys/view/components/SamplingMethods.tsx +++ b/app/src/features/surveys/view/components/SamplingMethods.tsx @@ -5,7 +5,7 @@ import ListItemText from '@mui/material/ListItemText'; import Typography from '@mui/material/Typography'; import { CodesContext } from 'contexts/codesContext'; import { SurveyContext } from 'contexts/surveyContext'; -import { IGetSurveyBlock, IGetSurveyStratum } from 'interfaces/useSurveyApi.interface'; +import { IGetSurveyBlock } from 'interfaces/useSurveyApi.interface'; import { useContext } from 'react'; /** @@ -53,7 +53,7 @@ const SamplingMethods = () => { Stratums - {site_selection.stratums?.map((stratum: IGetSurveyStratum) => { + {site_selection.stratums?.map((stratum) => { return ( { ))} ) : ( - - No funding sources found + + + No funding sources found + )} diff --git a/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx b/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx index 91b33810fb..1bb03a26b5 100644 --- a/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx +++ b/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx @@ -25,7 +25,6 @@ describe('SurveyGeneralInformation', () => { const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockDeploymentDataLoader = { data: [] } as DataLoader; const { getByTestId } = render( @@ -37,9 +36,7 @@ describe('SurveyGeneralInformation', () => { artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, techniqueDataLoader: mockTechniqueDataLoader, - critterDataLoader: mockCritterDataLoader, - deploymentDataLoader: mockDeploymentDataLoader, - critterDeployments: [] + critterDataLoader: mockCritterDataLoader }}> @@ -66,7 +63,6 @@ describe('SurveyGeneralInformation', () => { const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockDeploymentDataLoader = { data: [] } as DataLoader; const { getByTestId } = render( @@ -78,9 +74,7 @@ describe('SurveyGeneralInformation', () => { artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, - techniqueDataLoader: mockTechniqueDataLoader, - deploymentDataLoader: mockDeploymentDataLoader, - critterDeployments: [] + techniqueDataLoader: mockTechniqueDataLoader }}> @@ -95,9 +89,7 @@ describe('SurveyGeneralInformation', () => { const mockArtifactDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; - const mockCritterDataLoader = { data: [] } as DataLoader; - const mockDeploymentDataLoader = { data: [] } as DataLoader; const { container } = render( @@ -109,9 +101,7 @@ describe('SurveyGeneralInformation', () => { artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, techniqueDataLoader: mockTechniqueDataLoader, - critterDataLoader: mockCritterDataLoader, - deploymentDataLoader: mockDeploymentDataLoader, - critterDeployments: [] + critterDataLoader: mockCritterDataLoader }}> diff --git a/app/src/features/surveys/view/components/SurveyGeneralInformation.tsx b/app/src/features/surveys/view/components/SurveyGeneralInformation.tsx index 36066eecaf..057c79ecfb 100644 --- a/app/src/features/surveys/view/components/SurveyGeneralInformation.tsx +++ b/app/src/features/surveys/view/components/SurveyGeneralInformation.tsx @@ -73,51 +73,26 @@ const SurveyGeneralInformation = () => { display: 'inline-block', mr: 1.25, '&::after': { - content: `','`, + content: "';'", position: 'absolute', top: 0 }, '&:last-child::after': { display: 'none' } - }}> - {[...focalSpecies.commonNames, `(${focalSpecies.scientificName})`].filter(Boolean).join(' ')} + }} + data-testid="focal_species"> + + {focalSpecies.commonNames.join(',')} + {' '} + + ({focalSpecies.scientificName}) + ); })} - - - Secondary Species - - {species.ancillary_species?.map((ancillarySpecies: ITaxonomy) => { - return ( - - {[...ancillarySpecies.commonNames, `(${ancillarySpecies.scientificName})`].filter(Boolean).join(' ')} - - ); - })} - {species.ancillary_species?.length <= 0 && ( - No secondary species of interest - )} - - ); }; diff --git a/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx b/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx index c9f7f79040..ef52862732 100644 --- a/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx +++ b/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx @@ -15,7 +15,6 @@ describe('SurveyProprietaryData', () => { const mockArtifactDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockDeploymentDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; const { getByTestId } = render( @@ -23,13 +22,11 @@ describe('SurveyProprietaryData', () => { value={{ projectId: 1, surveyId: 1, - critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, techniqueDataLoader: mockTechniqueDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, - critterDataLoader: mockCritterDataLoader, - deploymentDataLoader: mockDeploymentDataLoader + critterDataLoader: mockCritterDataLoader }}> @@ -47,7 +44,6 @@ describe('SurveyProprietaryData', () => { const mockArtifactDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockDeploymentDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; const { getByTestId } = render( @@ -55,13 +51,11 @@ describe('SurveyProprietaryData', () => { value={{ projectId: 1, surveyId: 1, - critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, techniqueDataLoader: mockTechniqueDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, - critterDataLoader: mockCritterDataLoader, - deploymentDataLoader: mockDeploymentDataLoader + critterDataLoader: mockCritterDataLoader }}> @@ -77,7 +71,6 @@ describe('SurveyProprietaryData', () => { const mockArtifactDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockDeploymentDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; const { container } = render( @@ -85,12 +78,10 @@ describe('SurveyProprietaryData', () => { value={{ projectId: 1, surveyId: 1, - critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, - deploymentDataLoader: mockDeploymentDataLoader, techniqueDataLoader: mockTechniqueDataLoader }}> diff --git a/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx b/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx index a45560e2c3..3390775e55 100644 --- a/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx +++ b/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx @@ -23,22 +23,19 @@ describe('SurveyPurposeAndMethodologyData', () => { const mockArtifactDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockDeploymentDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; - const { getByTestId, getAllByTestId } = render( + const { getByTestId } = render( @@ -46,10 +43,6 @@ describe('SurveyPurposeAndMethodologyData', () => { ); expect(getByTestId('intended_outcome_codes').textContent).toEqual('Intended Outcome 1'); - expect(getAllByTestId('survey_vantage_code').map((item) => item.textContent)).toEqual([ - 'Vantage Code 1', - 'Vantage Code 2' - ]); expect(getByTestId('survey_additional_details').textContent).toEqual('details'); }); @@ -75,21 +68,18 @@ describe('SurveyPurposeAndMethodologyData', () => { const mockArtifactDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockDeploymentDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; - const { getByTestId, getAllByTestId, queryByTestId } = render( + const { getByTestId, queryByTestId } = render( @@ -98,10 +88,6 @@ describe('SurveyPurposeAndMethodologyData', () => { ); expect(getByTestId('intended_outcome_codes').textContent).toEqual('Intended Outcome 1'); - expect(getAllByTestId('survey_vantage_code').map((item) => item.textContent)).toEqual([ - 'Vantage Code 1', - 'Vantage Code 2' - ]); expect(queryByTestId('survey_additional_details')).toBeNull(); }); }); diff --git a/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.tsx b/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.tsx index a4169fd42b..2e276cb1f3 100644 --- a/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.tsx +++ b/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.tsx @@ -26,29 +26,31 @@ const SurveyPurposeAndMethodologyData = () => { Ecological Variables - {surveyData.purpose_and_methodology.intended_outcome_ids?.map((outcomeId: number) => { - return ( - - {codes?.intended_outcomes?.find((item: any) => item.id === outcomeId)?.name} - - ); - })} + + {surveyData.purpose_and_methodology.intended_outcome_ids?.map((intended_outcome_id: number) => { + return ( + + {codes?.intended_outcomes?.find((item: any) => item.id === intended_outcome_id)?.name} + + ); + })} + {surveyData.purpose_and_methodology.additional_details && ( <> @@ -60,33 +62,6 @@ const SurveyPurposeAndMethodologyData = () => { )} - - - Vantage Code(s) - {surveyData.purpose_and_methodology.vantage_code_ids?.map((vc_id: number) => { - return ( - - {codes?.vantage_codes?.find((item: any) => item.id === vc_id)?.name} - - ); - })} - ); }; diff --git a/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx b/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx index 0817cb6a2b..47e7ca53e1 100644 --- a/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx +++ b/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx @@ -44,7 +44,6 @@ describe.skip('SurveyStudyArea', () => { const mockArtifactDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockDeploymentDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; const { container } = render( @@ -52,13 +51,11 @@ describe.skip('SurveyStudyArea', () => { value={{ projectId: 1, surveyId: 1, - critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, techniqueDataLoader: mockTechniqueDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, - critterDataLoader: mockCritterDataLoader, - deploymentDataLoader: mockDeploymentDataLoader + critterDataLoader: mockCritterDataLoader }}> @@ -83,7 +80,6 @@ describe.skip('SurveyStudyArea', () => { const mockArtifactDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockDeploymentDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; const { container, queryByTestId } = render( @@ -91,13 +87,11 @@ describe.skip('SurveyStudyArea', () => { value={{ projectId: 1, surveyId: 1, - critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, techniqueDataLoader: mockTechniqueDataLoader, - critterDataLoader: mockCritterDataLoader, - deploymentDataLoader: mockDeploymentDataLoader + critterDataLoader: mockCritterDataLoader }}> @@ -114,7 +108,6 @@ describe.skip('SurveyStudyArea', () => { const mockArtifactDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockDeploymentDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; const { container, getByTestId } = render( @@ -122,12 +115,10 @@ describe.skip('SurveyStudyArea', () => { value={{ projectId: 1, surveyId: 1, - critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, - deploymentDataLoader: mockDeploymentDataLoader, techniqueDataLoader: mockTechniqueDataLoader }}> @@ -149,7 +140,6 @@ describe.skip('SurveyStudyArea', () => { const mockArtifactDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockDeploymentDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; const mockProjectAuthStateContext: IProjectAuthStateContext = { @@ -167,12 +157,10 @@ describe.skip('SurveyStudyArea', () => { value={{ projectId: 1, surveyId: 1, - critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, - deploymentDataLoader: mockDeploymentDataLoader, techniqueDataLoader: mockTechniqueDataLoader }}> @@ -244,7 +232,6 @@ describe.skip('SurveyStudyArea', () => { const mockArtifactDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockDeploymentDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; mockUseApi.survey.getSurveyForView.mockResolvedValue({ @@ -280,13 +267,11 @@ describe.skip('SurveyStudyArea', () => { value={{ projectId: 1, surveyId: 1, - critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, - techniqueDataLoader: mockTechniqueDataLoader, - deploymentDataLoader: mockDeploymentDataLoader + techniqueDataLoader: mockTechniqueDataLoader }}> diff --git a/app/src/features/surveys/view/components/analytics/SurveyObservationAnalytics.tsx b/app/src/features/surveys/view/components/analytics/SurveyObservationAnalytics.tsx new file mode 100644 index 0000000000..a12191b6b1 --- /dev/null +++ b/app/src/features/surveys/view/components/analytics/SurveyObservationAnalytics.tsx @@ -0,0 +1,194 @@ +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import grey from '@mui/material/colors/grey'; +import Divider from '@mui/material/Divider'; +import Stack from '@mui/material/Stack'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import Typography from '@mui/material/Typography'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { ObservationAnalyticsDataTableContainer } from 'features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import startCase from 'lodash-es/startCase'; +import { useEffect, useState } from 'react'; +import { ObservationAnalyticsNoDataOverlay } from './components/ObservationAnalyticsNoDataOverlay'; +type GroupByColumnType = 'column' | 'quantitative_measurement' | 'qualitative_measurement'; + +export type IGroupByOption = { + label: string; + field: string; + type: GroupByColumnType; +}; + +const initialGroupByColumnOptions: IGroupByOption[] = [ + { label: 'Sampling Site', field: 'survey_sample_site_id', type: 'column' } +]; + +const allGroupByColumnOptions: IGroupByOption[] = [ + ...initialGroupByColumnOptions, + { label: 'Sampling Method', field: 'survey_sample_method_id', type: 'column' }, + { label: 'Sampling Period', field: 'survey_sample_period_id', type: 'column' }, + { label: 'Species', field: 'itis_tsn', type: 'column' }, + { label: 'Date', field: 'observation_date', type: 'column' } +]; + +export const SurveyObservationAnalytics = () => { + const biohubApi = useBiohubApi(); + + const { surveyId, projectId } = useSurveyContext(); + + const [groupByColumns, setGroupByColumns] = useState(initialGroupByColumnOptions); + const [groupByQualitativeMeasurements, setGroupByQualitativeMeasurements] = useState([]); + const [groupByQuantitativeMeasurements, setGroupByQuantitativeMeasurements] = useState([]); + + const measurementDefinitionsDataLoader = useDataLoader(() => + biohubApi.observation.getObservationMeasurementDefinitions(projectId, surveyId) + ); + + useEffect(() => { + measurementDefinitionsDataLoader.load(); + }, [measurementDefinitionsDataLoader]); + + const groupByOptions: IGroupByOption[] = [ + ...allGroupByColumnOptions, + ...(measurementDefinitionsDataLoader.data?.qualitative_measurements.map((measurement) => ({ + label: startCase(measurement.measurement_name), + field: measurement.taxon_measurement_id, + type: 'qualitative_measurement' as GroupByColumnType + })) ?? []), + ...(measurementDefinitionsDataLoader.data?.quantitative_measurements.map((measurement) => ({ + label: startCase(measurement.measurement_name), + field: measurement.taxon_measurement_id, + type: 'quantitative_measurement' as GroupByColumnType + })) ?? []) + ]; + + const handleToggleChange = (_: React.MouseEvent, value: IGroupByOption[]) => { + if (!value[0]?.type) return; + + // Update group by arrays + if (value[0].type === 'column') { + updateGroupBy(value[0], setGroupByColumns); + } + if (value[0].type === 'qualitative_measurement') { + updateGroupBy(value[0], setGroupByQualitativeMeasurements); + } + if (value[0].type === 'quantitative_measurement') { + updateGroupBy(value[0], setGroupByQuantitativeMeasurements); + } + }; + + const updateGroupBy = (value: IGroupByOption, setGroupBy: React.Dispatch>) => + setGroupBy((groupBy) => + groupBy.some((item) => item.field === value.field) + ? groupBy.filter((item) => item.field !== value.field) + : [...groupBy, value] + ); + + const allGroupByColumns = [...groupByColumns, ...groupByQualitativeMeasurements, ...groupByQuantitativeMeasurements]; + + return ( + + + + + } + isLoadingFallbackDelay={100}> + + {/* Group by header */} + + + GROUP BY + + + + + {/* Render toggle buttons for each group by option */} + {groupByOptions.map((option) => ( + item.field === option.field) || + groupByQualitativeMeasurements.some((item) => item.field === option.field) || + groupByQuantitativeMeasurements.some((item) => item.field === option.field) + }> + + item.field === option.field) || + groupByQualitativeMeasurements.some((item) => item.field === option.field) || + groupByQuantitativeMeasurements.some((item) => item.field === option.field) + } + /> + {option.label} + + + ))} + + + + + + + {/* Overlay for when no group by columns are selected */} + {allGroupByColumns.length === 0 && !measurementDefinitionsDataLoader.isLoading && ( + + )} + + {/* Data grid displaying fetched data */} + {measurementDefinitionsDataLoader.data && allGroupByColumns.length > 0 && ( + + )} + + + ); +}; diff --git a/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsDataTable.tsx b/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsDataTable.tsx new file mode 100644 index 0000000000..2fed674685 --- /dev/null +++ b/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsDataTable.tsx @@ -0,0 +1,68 @@ +import { GridColDef, GridColumnVisibilityModel } from '@mui/x-data-grid'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { IObservationAnalyticsRow } from 'features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer'; + +const rowHeight = 50; + +interface IObservationAnalyticsDataTableProps { + isLoading: boolean; + columns: GridColDef[]; + rows: IObservationAnalyticsRow[]; + columnVisibilityModel: GridColumnVisibilityModel; +} + +/** + * Observation Analytics Data Table. + * + * @param {IObservationAnalyticsDataTableProps} props + * @return {*} + */ +export const ObservationAnalyticsDataTable = (props: IObservationAnalyticsDataTableProps) => { + const { isLoading, columns, rows, columnVisibilityModel } = props; + + return ( + + ); +}; diff --git a/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer.tsx b/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer.tsx new file mode 100644 index 0000000000..b5e09248f1 --- /dev/null +++ b/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer.tsx @@ -0,0 +1,154 @@ +import { GridColumnVisibilityModel } from '@mui/x-data-grid'; +import { ObservationAnalyticsDataTable } from 'features/surveys/view/components/analytics/components/ObservationAnalyticsDataTable'; +import { IGroupByOption } from 'features/surveys/view/components/analytics/SurveyObservationAnalytics'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext, useTaxonomyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { IObservationCountByGroup } from 'interfaces/useAnalyticsApi.interface'; +import { useEffect, useMemo } from 'react'; +import { + getBasicGroupByColDefs, + getDateColDef, + getIndividualCountColDef, + getIndividualPercentageColDef, + getRowCountColDef, + getSamplingMethodColDef, + getSamplingPeriodColDef, + getSamplingSiteColDef, + getSpeciesColDef +} from './ObservationsAnalyticsGridColumnDefinitions'; + +export type IObservationAnalyticsRow = Omit< + IObservationCountByGroup, + 'quantitative_measurements' | 'qualitative_measurements' +> & { + [key: string]: string | number | null; +}; + +// Base columns that are always displayed, and not part of the group by columns +const BaseColumns = ['row_count', 'individual_count', 'individual_percentage']; + +interface IObservationAnalyticsDataTableContainerProps { + groupByColumns: IGroupByOption[]; + groupByQuantitativeMeasurements: IGroupByOption[]; + groupByQualitativeMeasurements: IGroupByOption[]; +} + +/** + * Observation Analytics Data Table Container. + * Fetches and parses the observation analytics data and passes it to the ObservationAnalyticsDataTable component. + * + * @param {IObservationAnalyticsDataTableContainerProps} props + * @return {*} + */ +export const ObservationAnalyticsDataTableContainer = (props: IObservationAnalyticsDataTableContainerProps) => { + const { groupByColumns, groupByQuantitativeMeasurements, groupByQualitativeMeasurements } = props; + + const biohubApi = useBiohubApi(); + const surveyContext = useSurveyContext(); + const taxonomyContext = useTaxonomyContext(); + + const analyticsDataLoader = useDataLoader( + ( + surveyId: number, + groupByColumns: IGroupByOption[], + groupByQuantitativeMeasurements: IGroupByOption[], + groupByQualitativeMeasurements: IGroupByOption[] + ) => + biohubApi.analytics.getObservationCountByGroup( + [surveyId], + groupByColumns.map((item) => item.field), + groupByQuantitativeMeasurements.map((item) => item.field), + groupByQualitativeMeasurements.map((item) => item.field) + ) + ); + + useEffect( + () => { + analyticsDataLoader.refresh( + surveyContext.surveyId, + groupByColumns, + groupByQuantitativeMeasurements, + groupByQualitativeMeasurements + ); + }, + // eslint-disable-next-line + [groupByColumns, groupByQualitativeMeasurements, groupByQuantitativeMeasurements, surveyContext.surveyId] + ); + + const rows = useMemo( + () => + analyticsDataLoader?.data?.map((row) => { + const { quantitative_measurements, qualitative_measurements, ...nonMeasurementRows } = row; + + const newRow: IObservationAnalyticsRow = nonMeasurementRows; + + qualitative_measurements.forEach((measurement) => { + newRow[measurement.taxon_measurement_id] = measurement.option.option_label; + }); + + quantitative_measurements.forEach((measurement) => { + newRow[measurement.taxon_measurement_id] = measurement.value; + }); + + return newRow; + }) ?? [], + [analyticsDataLoader?.data] + ); + + const sampleSites = useMemo( + () => surveyContext.sampleSiteDataLoader.data?.sampleSites ?? [], + [surveyContext.sampleSiteDataLoader.data?.sampleSites] + ); + + const allGroupByColumns = useMemo( + () => [...groupByColumns, ...groupByQualitativeMeasurements, ...groupByQuantitativeMeasurements], + [groupByColumns, groupByQualitativeMeasurements, groupByQuantitativeMeasurements] + ); + + const columns = useMemo( + () => [ + getRowCountColDef(), + getIndividualCountColDef(), + getIndividualPercentageColDef(), + getSamplingSiteColDef(sampleSites), + getSamplingMethodColDef(sampleSites), + getSamplingPeriodColDef(sampleSites), + getSpeciesColDef(taxonomyContext.getCachedSpeciesTaxonomyById), + getDateColDef(), + ...getBasicGroupByColDefs([...groupByQualitativeMeasurements, ...groupByQuantitativeMeasurements]) + ], + // eslint-disable-next-line + [rows, allGroupByColumns] + ); + + const columnVisibilityModel = useMemo(() => { + const _columnVisibilityModel: GridColumnVisibilityModel = {}; + + for (const column of columns) { + // Set all columns to visible by default + _columnVisibilityModel[column.field] = true; + + if (BaseColumns.includes(column.field)) { + // Don't hide base columns + continue; + } + + if (!allGroupByColumns.some((item) => item.field === column.field)) { + // Set columns that are not part of the group by columns (not selected in the UI) to hidden + _columnVisibilityModel[column.field] = false; + } + } + + return _columnVisibilityModel; + }, [allGroupByColumns, columns]); + + return ( + + ); +}; diff --git a/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsNoDataOverlay.tsx b/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsNoDataOverlay.tsx new file mode 100644 index 0000000000..f56d03e844 --- /dev/null +++ b/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsNoDataOverlay.tsx @@ -0,0 +1,49 @@ +import Box from '@mui/material/Box'; +import grey from '@mui/material/colors/grey'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; + +/** + * Returns an overlay with instructions on how to use the analytics feature. + * + * @return {*} + */ +export const ObservationAnalyticsNoDataOverlay = () => { + return ( + + + Calculate sex ratios, demographics, and more + + + Choose fields to analyze + + + The group by options depend on which fields apply to your observations. To add options, such as life stage + and sex, add fields to your observations by configuring your observations table. + + + + + How the calculations work  + + + The number observations and individuals will be calculated for each group. For example, if you group by life + stage, the number of individuals belonging to each life stage category will be calculated. If you group by + multiple fields, such as life stage and sex, the number of individuals belonging to each life stage and sex + combination will be calculated. + + + + + ); +}; diff --git a/app/src/features/surveys/view/components/analytics/components/ObservationsAnalyticsGridColumnDefinitions.tsx b/app/src/features/surveys/view/components/analytics/components/ObservationsAnalyticsGridColumnDefinitions.tsx new file mode 100644 index 0000000000..b24ef04e98 --- /dev/null +++ b/app/src/features/surveys/view/components/analytics/components/ObservationsAnalyticsGridColumnDefinitions.tsx @@ -0,0 +1,221 @@ +import grey from '@mui/material/colors/grey'; +import Typography from '@mui/material/Typography'; +import { GridColDef } from '@mui/x-data-grid'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import dayjs from 'dayjs'; +import { ScientificNameTypography } from 'features/surveys/animals/components/ScientificNameTypography'; +import { IObservationAnalyticsRow } from 'features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer'; +import { IGroupByOption } from 'features/surveys/view/components/analytics/SurveyObservationAnalytics'; +import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import isEqual from 'lodash-es/isEqual'; + +/** + * Get the column definition for the row count. + * + * @return {*} {GridColDef} + */ +export const getRowCountColDef = (): GridColDef => ({ + headerAlign: 'left', + align: 'left', + field: 'row_count', + headerName: 'Number of observations', + type: 'number', + flex: 1, + minWidth: 180 +}); + +/** + * Get the column definition for the individual count. + * + * @return {*} {GridColDef} + */ +export const getIndividualCountColDef = (): GridColDef => ({ + headerAlign: 'left', + align: 'left', + field: 'individual_count', + headerName: 'Number of individuals', + type: 'number', + flex: 1, + minWidth: 180 +}); + +/** + * Get the column definition for the individual percentage. + * + * @return {*} {GridColDef} + */ +export const getIndividualPercentageColDef = (): GridColDef => ({ + headerAlign: 'left', + align: 'left', + field: 'individual_percentage', + headerName: 'Percentage of individuals', + type: 'number', + flex: 1, + minWidth: 180, + renderCell: (params) => ( + + {params.row.individual_percentage}  + + % + + + ) +}); + +/** + * Get the column definition for the species. + * + * @param {((id: number) => IPartialTaxonomy | null)} getFunction + * @return {*} {GridColDef} + */ +export const getSpeciesColDef = ( + getFunction: (id: number) => IPartialTaxonomy | null +): GridColDef => ({ + headerAlign: 'left', + align: 'left', + field: 'itis_tsn', + headerName: 'Species', + minWidth: 180, + renderCell: (params) => { + if (!params.row.itis_tsn) { + return null; + } + + const species = getFunction(params.row.itis_tsn); + + return ; + } +}); + +/** + * Get the column definition for the sampling site. + * + * @param {IGetSampleLocationDetails[]} sampleSites + * @return {*} {GridColDef} + */ +export const getSamplingSiteColDef = ( + sampleSites: IGetSampleLocationDetails[] +): GridColDef => ({ + headerAlign: 'left', + align: 'left', + field: 'survey_sample_site_id', + headerName: 'Site', + minWidth: 180, + renderCell: (params) => { + if (!params.row.survey_sample_site_id) { + return null; + } + + const site = sampleSites.find((site) => isEqual(params.row.survey_sample_site_id, site.survey_sample_site_id)); + + if (!site) { + return null; + } + + return {site.name}; + } +}); + +/** + * Get the column definition for the sampling method. + * + * @param {IGetSampleLocationDetails[]} sampleSites + * @return {*} {GridColDef} + */ +export const getSamplingMethodColDef = ( + sampleSites: IGetSampleLocationDetails[] +): GridColDef => ({ + headerAlign: 'left', + align: 'left', + field: 'survey_sample_method_id', + headerName: 'Method', + minWidth: 180, + renderCell: (params) => { + if (!params.row.survey_sample_method_id) { + return null; + } + + const method = sampleSites + .flatMap((site) => site.sample_methods) + .find((method) => isEqual(params.row.survey_sample_method_id, method.survey_sample_method_id)); + + if (!method) { + return null; + } + + return {method.technique.name}; + } +}); + +/** + * Get the column definition for the sampling period. + * + * @param {IGetSampleLocationDetails[]} sampleSites + * @return {*} {GridColDef} + */ +export const getSamplingPeriodColDef = ( + sampleSites: IGetSampleLocationDetails[] +): GridColDef => ({ + headerAlign: 'left', + align: 'left', + field: 'survey_sample_period_id', + headerName: 'Period', + minWidth: 220, + renderCell: (params) => { + if (!params.row.survey_sample_period_id) { + return null; + } + + const period = sampleSites + .flatMap((site) => site.sample_methods) + .flatMap((method) => method.sample_periods) + .find((period) => isEqual(params.row.survey_sample_period_id, period.survey_sample_period_id)); + + if (!period) { + return null; + } + + return ( + + {dayjs(period.start_date).format(DATE_FORMAT.ShortMediumDateFormat)}– + {dayjs(period.end_date).format(DATE_FORMAT.ShortMediumDateFormat)} + + ); + } +}); + +/** + * Get the column definition for the date. + * + * @return {*} {GridColDef} + */ +export const getDateColDef = (): GridColDef => ({ + headerAlign: 'left', + align: 'left', + field: 'observation_date', + headerName: 'Date', + minWidth: 180, + renderCell: (params) => + params.row.observation_date ? ( + {dayjs(params.row.observation_date).format(DATE_FORMAT.MediumDateFormat)} + ) : null +}); + +/** + * Get basic group by column definitions for the provided group by options. + * + * @param {IGroupByOption[]} groupByOptions + * @return {*} {GridColDef[]} + */ +export const getBasicGroupByColDefs = (groupByOptions: IGroupByOption[]): GridColDef[] => { + if (!groupByOptions.length) { + return []; + } + + return groupByOptions.map((item) => ({ + field: item.field, + headerName: item.label, + minWidth: 180 + })); +}; diff --git a/app/src/features/surveys/view/components/data-container/SurveyObservationTabularDataContainer.tsx b/app/src/features/surveys/view/components/data-container/SurveyObservationTabularDataContainer.tsx new file mode 100644 index 0000000000..3fc75572f8 --- /dev/null +++ b/app/src/features/surveys/view/components/data-container/SurveyObservationTabularDataContainer.tsx @@ -0,0 +1,81 @@ +import { mdiChartBar, mdiTallyMark5 } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import { useState } from 'react'; +import { SurveySpatialObservationTable } from '../../survey-spatial/components/observation/SurveySpatialObservationTable'; +import { SurveyObservationAnalytics } from '../analytics/SurveyObservationAnalytics'; + +export enum SurveyObservationTabularDataContainerViewEnum { + COUNTS = 'COUNTS', + ANALYTICS = 'ANALYTICS' +} + +interface ISurveyObservationTabularDataContainerProps { + isLoading: boolean; +} + +const SurveyObservationTabularDataContainer = (props: ISurveyObservationTabularDataContainerProps) => { + const { isLoading } = props; + + const [activeDataView, setActiveDataView] = useState( + SurveyObservationTabularDataContainerViewEnum.COUNTS + ); + + const views = [ + { label: 'Counts', value: SurveyObservationTabularDataContainerViewEnum.COUNTS, icon: mdiTallyMark5 }, + { label: 'Analytics', value: SurveyObservationTabularDataContainerViewEnum.ANALYTICS, icon: mdiChartBar } + ]; + + return ( + <> + + { + if (!view) { + // An active view must be selected at all times + return; + } + + setActiveDataView(view); + }} + exclusive + sx={{ + display: 'flex', + gap: 1, + '& Button': { + py: 0.25, + px: 1.5, + border: 'none', + borderRadius: '4px !important', + fontSize: '0.875rem', + fontWeight: 700, + letterSpacing: '0.02rem' + } + }}> + {views.map((view) => ( + } + value={view.value}> + {view.label} + + ))} + + + + {activeDataView === SurveyObservationTabularDataContainerViewEnum.COUNTS && ( + + )} + {activeDataView === SurveyObservationTabularDataContainerViewEnum.ANALYTICS && } + + + ); +}; + +export default SurveyObservationTabularDataContainer; diff --git a/app/src/features/surveys/view/components/sampling-data/SurveySamplingContainer.tsx b/app/src/features/surveys/view/components/sampling-data/SurveySamplingContainer.tsx index 5e890b6aaf..7d37ea1279 100644 --- a/app/src/features/surveys/view/components/sampling-data/SurveySamplingContainer.tsx +++ b/app/src/features/surveys/view/components/sampling-data/SurveySamplingContainer.tsx @@ -1,24 +1,21 @@ import Box from '@mui/material/Box'; import Divider from '@mui/material/Divider'; -import Paper from '@mui/material/Paper'; import { SurveySamplingTabs } from 'features/surveys/view/components/sampling-data/components/SurveySamplingTabs'; import { SurveySamplingHeader } from './components/SurveySamplingHeader'; export const SurveySamplingContainer = () => { return ( - - - + + - + - - - + + ); }; diff --git a/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingTabs.tsx b/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingTabs.tsx index 320a95eeb0..fb71f4ccf4 100644 --- a/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingTabs.tsx +++ b/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingTabs.tsx @@ -1,4 +1,4 @@ -import { mdiAutoFix, mdiMapMarker } from '@mdi/js'; +import { mdiArrowTopRight, mdiAutoFix, mdiCalendarRange, mdiMapMarker } from '@mdi/js'; import Icon from '@mdi/react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; @@ -7,14 +7,26 @@ import ToggleButton from '@mui/material/ToggleButton'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import { LoadingGuard } from 'components/loading/LoadingGuard'; import { SkeletonTable } from 'components/loading/SkeletonLoaders'; -import { SurveySitesTable } from 'features/surveys/view/components/sampling-data/components/SurveySitesTable'; -import { SurveyTechniquesTable } from 'features/surveys/view/components/sampling-data/components/SurveyTechniquesTable'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; +import { + ISamplingSitePeriodRowData, + SamplingPeriodTable +} from 'features/surveys/sampling-information/periods/table/SamplingPeriodTable'; +import { + ISurveySitesRowData, + SurveySitesTable +} from 'features/surveys/view/components/sampling-data/components/SurveySitesTable'; +import { + ISurveyTechniqueRowData, + SurveyTechniquesTable +} from 'features/surveys/view/components/sampling-data/components/SurveyTechniquesTable'; import { useSurveyContext } from 'hooks/useContext'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; export enum SurveySamplingView { TECHNIQUES = 'TECHNIQUES', - SITES = 'SITES' + SITES = 'SITES', + PERIODS = 'PERIODS' } export const SurveySamplingTabs = () => { @@ -49,6 +61,56 @@ export const SurveySamplingTabs = () => { surveyContext.surveyId ]); + const techniques: ISurveyTechniqueRowData[] = + surveyContext.techniqueDataLoader.data?.techniques.map((technique) => ({ + id: technique.method_technique_id, + name: technique.name, + method_lookup_id: technique.method_lookup_id, + description: technique.description, + attractants: technique.attractants, + distance_threshold: technique.distance_threshold + })) ?? []; + + const sampleSites: ISurveySitesRowData[] = useMemo( + () => + surveyContext.sampleSiteDataLoader.data?.sampleSites.map((site) => ({ + id: site.survey_sample_site_id, + name: site.name, + description: site.description, + geojson: site.geojson, + blocks: site.blocks.map((block) => block.name), + stratums: site.stratums.map((stratum) => stratum.name) + })) ?? [], + [surveyContext.sampleSiteDataLoader.data?.sampleSites] + ); + + const samplePeriods: ISamplingSitePeriodRowData[] = useMemo(() => { + const data: ISamplingSitePeriodRowData[] = []; + + for (const site of surveyContext.sampleSiteDataLoader.data?.sampleSites ?? []) { + for (const method of site.sample_methods) { + for (const period of method.sample_periods) { + data.push({ + id: period.survey_sample_period_id, + sample_site: site.name, + sample_method: method.technique.name, + method_response_metric_id: method.method_response_metric_id, + start_date: period.start_date, + end_date: period.end_date, + start_time: period.start_time, + end_time: period.end_time + }); + } + } + } + + return data; + }, [surveyContext.sampleSiteDataLoader.data?.sampleSites]); + + const techniquesCount = surveyContext.techniqueDataLoader.data?.count; + const sampleSitesCount = surveyContext.sampleSiteDataLoader.data?.sampleSites.length; + const samplePeriodsCount = samplePeriods.length; + return ( <> @@ -82,7 +144,7 @@ export const SurveySamplingTabs = () => { color="primary" startIcon={} value={SurveySamplingView.TECHNIQUES}> - {`Techniques (${surveyContext.techniqueDataLoader.data?.count ?? 0})`} + {`${SurveySamplingView.TECHNIQUES} (${techniquesCount ?? 0})`} { color="primary" startIcon={} value={SurveySamplingView.SITES}> - {`Sites (${surveyContext.sampleSiteDataLoader.data?.sampleSites.length ?? 0})`} + {`${SurveySamplingView.SITES} (${sampleSitesCount ?? 0})`} + + } + value={SurveySamplingView.PERIODS}> + {`${SurveySamplingView.PERIODS} (${samplePeriodsCount ?? 0})`} - - - {activeView === SurveySamplingView.TECHNIQUES && ( - - } - delay={200}> - - - - )} - - {activeView === SurveySamplingView.SITES && ( - - } - delay={200}> - - - - )} - + + {activeView === SurveySamplingView.TECHNIQUES && ( + <> + } + isLoadingFallbackDelay={100} + hasNoData={!techniquesCount} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + + + )} + + {activeView === SurveySamplingView.SITES && ( + <> + } + isLoadingFallbackDelay={100} + hasNoData={!sampleSitesCount} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + + + )} + + {activeView === SurveySamplingView.PERIODS && ( + <> + } + isLoadingFallbackDelay={100} + hasNoData={!samplePeriodsCount} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + + + )} ); diff --git a/app/src/features/surveys/view/components/sampling-data/components/SurveySitesTable.tsx b/app/src/features/surveys/view/components/sampling-data/components/SurveySitesTable.tsx index 3fd7f9022e..521efccde4 100644 --- a/app/src/features/surveys/view/components/sampling-data/components/SurveySitesTable.tsx +++ b/app/src/features/surveys/view/components/sampling-data/components/SurveySitesTable.tsx @@ -1,56 +1,79 @@ -import { mdiArrowTopRight } from '@mdi/js'; -import Icon from '@mdi/react'; import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import { GridColDef, GridOverlay } from '@mui/x-data-grid'; +import blueGrey from '@mui/material/colors/blueGrey'; +import { GridColDef } from '@mui/x-data-grid'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; -import { IGetSampleSiteResponse } from 'interfaces/useSamplingSiteApi.interface'; -import { IGetTechniqueResponse } from 'interfaces/useTechniqueApi.interface'; +import { ISamplingSiteRowData } from 'features/surveys/sampling-information/sites/table/SamplingSiteTable'; +import { Feature } from 'geojson'; +import { getSamplingSiteSpatialType } from 'utils/spatial-utils'; + +export interface ISurveySitesRowData { + id: number; + name: string; + description: string; + geojson: Feature; + blocks: string[]; + stratums: string[]; +} export interface ISurveySitesTableProps { - sites?: IGetSampleSiteResponse; + sites: ISurveySitesRowData[]; } export const SurveySitesTable = (props: ISurveySitesTableProps) => { const { sites } = props; - const rows = - sites?.sampleSites.map((site) => ({ - id: site.survey_sample_site_id, - name: site.name, - description: site.description - })) || []; - - const columns: GridColDef[] = [ + const columns: GridColDef[] = [ { field: 'name', headerName: 'Name', - flex: 0.3 + flex: 1 + }, + { + field: 'geometry_type', + headerName: 'Geometry', + flex: 1, + renderCell: (params) => ( + + + + ) }, { field: 'description', headerName: 'Description', + flex: 1 + }, + { + field: 'blocks', + headerName: 'Blocks', + flex: 1, + renderCell: (params) => ( + + {params.row.blocks.map((block) => ( + + + + ))} + + ) + }, + { + field: 'stratums', + headerName: 'Strata', flex: 1, - renderCell: (params) => { - return ( - - - {params.row.description} - - - ); - } + renderCell: (params) => ( + + {params.row.stratums.map((stratum) => ( + + + + ))} + + ) } ]; @@ -60,36 +83,16 @@ export const SurveySitesTable = (props: ISurveySitesTableProps) => { rowSelection={false} autoHeight getRowHeight={() => 'auto'} - rows={rows} + rows={sites} getRowId={(row) => row.id} columns={columns} disableRowSelectionOnClick - noRowsOverlay={ - - - - Start by adding sampling information  - - - - Add Techniques, then apply your techniques to Sites - - - - } - sx={{ - '& .MuiDataGrid-virtualScroller': { - height: rows.length === 0 ? '250px' : 'unset', - overflowY: 'auto !important', - overflowX: 'hidden' - }, - '& .MuiDataGrid-overlay': { - height: '250px', - display: 'flex', - justifyContent: 'center', - alignItems: 'center' + initialState={{ + pagination: { + paginationModel: { page: 1, pageSize: 10 } } }} + pageSizeOptions={[10, 25, 50]} /> ); }; diff --git a/app/src/features/surveys/view/components/sampling-data/components/SurveyTechniquesTable.tsx b/app/src/features/surveys/view/components/sampling-data/components/SurveyTechniquesTable.tsx index 144be73dd8..3aa338f693 100644 --- a/app/src/features/surveys/view/components/sampling-data/components/SurveyTechniquesTable.tsx +++ b/app/src/features/surveys/view/components/sampling-data/components/SurveyTechniquesTable.tsx @@ -1,17 +1,25 @@ -import { mdiArrowTopRight } from '@mdi/js'; -import Icon from '@mdi/react'; import Box from '@mui/material/Box'; import blueGrey from '@mui/material/colors/blueGrey'; import Typography from '@mui/material/Typography'; -import { GridColDef, GridOverlay } from '@mui/x-data-grid'; +import { GridColDef } from '@mui/x-data-grid'; import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { ITechniqueRowData } from 'features/surveys/sampling-information/techniques/table/SamplingTechniqueTable'; import { useCodesContext } from 'hooks/useContext'; -import { IGetTechniqueResponse, IGetTechniquesResponse } from 'interfaces/useTechniqueApi.interface'; +import { TechniqueAttractant } from 'interfaces/useTechniqueApi.interface'; import { getCodesName } from 'utils/Utils'; +export interface ISurveyTechniqueRowData { + id: number; + method_lookup_id: number; + name: string; + description: string | null; + attractants: TechniqueAttractant[]; + distance_threshold: number | null; +} + export interface ISurveyTechniquesTableProps { - techniques?: IGetTechniquesResponse; + techniques: ISurveyTechniqueRowData[]; } export const SurveyTechniquesTable = (props: ISurveyTechniquesTableProps) => { @@ -19,23 +27,15 @@ export const SurveyTechniquesTable = (props: ISurveyTechniquesTableProps) => { const codesContext = useCodesContext(); - const rows = - techniques?.techniques.map((technique) => ({ - id: technique.method_technique_id, - name: getCodesName(codesContext.codesDataLoader.data, 'sample_methods', technique.method_lookup_id) ?? '', - method_lookup_id: technique.method_lookup_id, - description: technique.description - })) || []; - - const columns: GridColDef[] = [ + const columns: GridColDef[] = [ { field: 'name', headerName: 'Name', - flex: 0.3 + flex: 1 }, { field: 'method_lookup_id', - flex: 0.3, + flex: 1, headerName: 'Method', renderCell: (params) => ( { ); } + }, + { + field: 'attractants', + flex: 1, + headerName: 'Attractants', + renderCell: (params) => ( + + {params.row.attractants.map((attractant) => ( + + + + ))} + + ) + }, + { + field: 'distance_threshold', + headerName: 'Distance threshold', + flex: 1, + renderCell: (params) => (params.row.distance_threshold ? <>{params.row.distance_threshold} m : <>) } ]; @@ -77,36 +102,16 @@ export const SurveyTechniquesTable = (props: ISurveyTechniquesTableProps) => { rowSelection={false} autoHeight getRowHeight={() => 'auto'} - rows={rows} + rows={techniques} getRowId={(row) => row.id} columns={columns} disableRowSelectionOnClick - noRowsOverlay={ - - - - Start by adding sampling information  - - - - Add Techniques, then apply your techniques to Sites - - - - } - sx={{ - '& .MuiDataGrid-virtualScroller': { - height: rows.length === 0 ? '250px' : 'unset', - overflowY: 'auto !important', - overflowX: 'hidden' - }, - '& .MuiDataGrid-overlay': { - height: '250px', - display: 'flex', - justifyContent: 'center', - alignItems: 'center' + initialState={{ + pagination: { + paginationModel: { page: 1, pageSize: 10 } } }} + pageSizeOptions={[10, 25, 50]} /> ); }; diff --git a/app/src/features/surveys/view/components/spatial-data/SurveySpatialData.tsx b/app/src/features/surveys/view/components/spatial-data/SurveySpatialData.tsx deleted file mode 100644 index f4722d6ada..0000000000 --- a/app/src/features/surveys/view/components/spatial-data/SurveySpatialData.tsx +++ /dev/null @@ -1,384 +0,0 @@ -import { mdiBroadcast, mdiEye } from '@mdi/js'; -import Box from '@mui/material/Box'; -import Paper from '@mui/material/Paper'; -import { IStaticLayer, IStaticLayerFeature } from 'components/map/components/StaticLayers'; -import { DATE_FORMAT } from 'constants/dateTimeFormats'; -import { SURVEY_MAP_LAYER_COLOURS } from 'constants/spatial'; -import { CodesContext } from 'contexts/codesContext'; -import { SurveyContext } from 'contexts/surveyContext'; -import { TelemetryDataContext } from 'contexts/telemetryDataContext'; -import dayjs from 'dayjs'; -import { Position } from 'geojson'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { useObservationsContext, useTaxonomyContext } from 'hooks/useContext'; -import useDataLoader from 'hooks/useDataLoader'; -import { ITelemetry } from 'hooks/useTelemetryApi'; -import { ISimpleCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; -import { useContext, useEffect, useMemo, useState } from 'react'; -import { getCodesName, getFormattedDate } from 'utils/Utils'; -import { IAnimalDeployment } from '../../survey-animals/telemetry-device/device'; -import SurveyMap, { ISurveyMapPoint, ISurveyMapPointMetadata, ISurveyMapSupplementaryLayer } from '../../SurveyMap'; -import SurveyMapPopup from '../../SurveyMapPopup'; -import SurveyMapTooltip from '../../SurveyMapTooltip'; -import SurveySpatialObservationDataTable from './SurveySpatialObservationDataTable'; -import SurveySpatialTelemetryDataTable from './SurveySpatialTelemetryDataTable'; -import SurveySpatialToolbar, { SurveySpatialDatasetViewEnum } from './SurveySpatialToolbar'; - -const SurveySpatialData = () => { - const [activeView, setActiveView] = useState(SurveySpatialDatasetViewEnum.OBSERVATIONS); - - const observationsContext = useObservationsContext(); - const telemetryContext = useContext(TelemetryDataContext); - const taxonomyContext = useTaxonomyContext(); - const surveyContext = useContext(SurveyContext); - const codesContext = useContext(CodesContext); - const { projectId, surveyId } = useContext(SurveyContext); - - const biohubApi = useBiohubApi(); - - const [mapPointMetadata, setMapPointMetadata] = useState>({}); - - // Fetch observations spatial data - const observationsGeometryDataLoader = useDataLoader(() => - biohubApi.observation.getObservationsGeometry(projectId, surveyId) - ); - observationsGeometryDataLoader.load(); - - // Fetch study area data - const studyAreaLocations = useMemo( - () => surveyContext.surveyDataLoader.data?.surveyData.locations ?? [], - [surveyContext.surveyDataLoader.data] - ); - - const sampleSites = useMemo( - () => surveyContext.sampleSiteDataLoader.data?.sampleSites ?? [], - [surveyContext.sampleSiteDataLoader.data] - ); - - useEffect(() => { - if (surveyContext.deploymentDataLoader.data) { - const deploymentIds = surveyContext.deploymentDataLoader.data.map((item) => item.deployment_id); - telemetryContext.telemetryDataLoader.refresh(deploymentIds); - } - // Should not re-run this effect on `telemetryContext.telemetryDataLoader.refresh` changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [surveyContext.deploymentDataLoader.data]); - - // Fetch/cache all taxonomic data for the observations - useEffect(() => { - const cacheTaxonomicData = async () => { - if (observationsContext.observationsDataLoader.data) { - // fetch all unique itis_tsn's from observations to find taxonomic names - const taxonomicIds = [ - ...new Set(observationsContext.observationsDataLoader.data.surveyObservations.map((item) => item.itis_tsn)) - ].filter((tsn): tsn is number => tsn !== null); - await taxonomyContext.cacheSpeciesTaxonomyByIds(taxonomicIds); - } - }; - - cacheTaxonomicData(); - // Should not re-run this effect on `taxonomyContext` changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [observationsContext.observationsDataLoader.data]); - - /** - * Because Telemetry data is client-side paginated, we can collect all spatial points from - * traversing the array of telemetry data. - */ - const telemetryPoints: ISurveyMapPoint[] = useMemo(() => { - const deployments: IAnimalDeployment[] = surveyContext.deploymentDataLoader.data ?? []; - const critters: ISimpleCritterWithInternalId[] = surveyContext.critterDataLoader.data ?? []; - const telemetry: ITelemetry[] = telemetryContext.telemetryDataLoader.data ?? []; - - return ( - telemetry - .filter((telemetry) => telemetry.latitude !== undefined && telemetry.longitude !== undefined) - - // Combine all critter and deployments data into a flat list - .reduce( - ( - acc: { deployment: IAnimalDeployment; critter: ISimpleCritterWithInternalId; telemetry: ITelemetry }[], - telemetry: ITelemetry - ) => { - const deployment = deployments.find( - (animalDeployment) => animalDeployment.deployment_id === telemetry.deployment_id - ); - const critter = critters.find((detailedCritter) => detailedCritter.critter_id === deployment?.critter_id); - if (critter && deployment) { - acc.push({ deployment, critter, telemetry }); - } - - return acc; - }, - [] - ) - .map(({ telemetry, deployment, critter }) => { - return { - feature: { - type: 'Feature', - properties: {}, - geometry: { - type: 'Point', - coordinates: [telemetry.longitude, telemetry.latitude] as Position - } - }, - key: `telemetry-${telemetry.id}`, - onLoadMetadata: async (): Promise => { - return Promise.resolve([ - { label: 'Device ID', value: String(deployment.device_id) }, - { label: 'Alias', value: critter.animal_id ?? '' }, - { - label: 'Location', - value: [telemetry.latitude, telemetry.longitude] - .filter((coord): coord is number => coord !== null) - .map((coord) => coord.toFixed(6)) - .join(', ') - }, - { label: 'Date', value: dayjs(telemetry.acquisition_date).toISOString() } - ]); - } - }; - }) - ); - }, [ - surveyContext.critterDataLoader.data, - surveyContext.deploymentDataLoader.data, - telemetryContext.telemetryDataLoader.data - ]); - - /** - * Because Observations data is server-side paginated, we must collect all spatial points from - * a dedicated endpoint. - */ - const observationPoints: ISurveyMapPoint[] = useMemo(() => { - return (observationsGeometryDataLoader.data?.surveyObservationsGeometry ?? []).map((observation) => { - const point: ISurveyMapPoint = { - feature: { - type: 'Feature', - properties: {}, - geometry: observation.geometry - }, - key: `observation-${observation.survey_observation_id}`, - onLoadMetadata: async (): Promise => { - const response = await biohubApi.observation.getObservationRecord( - projectId, - surveyId, - observation.survey_observation_id - ); - - return [ - { label: 'Taxon ID', value: String(response.itis_tsn) }, - { label: 'Count', value: String(response.count) }, - { - label: 'Coords', - value: [response.latitude, response.longitude] - .filter((coord): coord is number => coord !== null) - .map((coord) => coord.toFixed(6)) - .join(', ') - }, - { - label: 'Date', - value: getFormattedDate( - response.observation_time ? DATE_FORMAT.ShortMediumDateTimeFormat : DATE_FORMAT.ShortMediumDateFormat, - `${response.observation_date} ${response.observation_time}` - ) - } - ]; - } - }; - - return point; - }); - }, [biohubApi.observation, observationsGeometryDataLoader.data?.surveyObservationsGeometry, projectId, surveyId]); - - let isLoading = false; - if (activeView === SurveySpatialDatasetViewEnum.OBSERVATIONS) { - isLoading = - codesContext.codesDataLoader.isLoading ?? - surveyContext.sampleSiteDataLoader.isLoading ?? - observationsContext.observationsDataLoader.isLoading; - } - - if (activeView === SurveySpatialDatasetViewEnum.TELEMETRY) { - isLoading = - codesContext.codesDataLoader.isLoading ?? - surveyContext.deploymentDataLoader.isLoading ?? - surveyContext.critterDataLoader.isLoading; - } - - const supplementaryLayers: ISurveyMapSupplementaryLayer[] = useMemo(() => { - switch (activeView) { - case SurveySpatialDatasetViewEnum.OBSERVATIONS: - return [ - { - layerName: 'Observations', - layerColors: { - fillColor: SURVEY_MAP_LAYER_COLOURS.OBSERVATIONS_COLOUR, - color: SURVEY_MAP_LAYER_COLOURS.OBSERVATIONS_COLOUR - }, - popupRecordTitle: 'Observation Record', - mapPoints: observationPoints - } - ]; - case SurveySpatialDatasetViewEnum.TELEMETRY: - return [ - { - layerName: 'Telemetry', - layerColors: { - fillColor: SURVEY_MAP_LAYER_COLOURS.TELEMETRY_COLOUR, - color: SURVEY_MAP_LAYER_COLOURS.TELEMETRY_COLOUR, - opacity: 0.5 - }, - popupRecordTitle: 'Telemetry Record', - mapPoints: telemetryPoints - } - ]; - case SurveySpatialDatasetViewEnum.MARKED_ANIMALS: - default: - return []; - } - }, [activeView, observationPoints, telemetryPoints]); - - const staticLayers: IStaticLayer[] = [ - { - layerName: 'Study Areas', - layerColors: { - color: SURVEY_MAP_LAYER_COLOURS.STUDY_AREA_COLOUR, - fillColor: SURVEY_MAP_LAYER_COLOURS.STUDY_AREA_COLOUR - }, - features: studyAreaLocations.flatMap((location) => { - return location.geojson.map((feature, index) => { - return { - key: `${location.survey_location_id}-${index}`, - geoJSON: feature, - popup: ( - - ), - tooltip: - }; - }); - }) - }, - { - layerName: 'Sample Sites', - layerColors: { - color: SURVEY_MAP_LAYER_COLOURS.SAMPLING_SITE_COLOUR, - fillColor: SURVEY_MAP_LAYER_COLOURS.SAMPLING_SITE_COLOUR - }, - features: sampleSites.map((sampleSite, index) => { - return { - key: `${sampleSite.survey_sample_site_id}-${index}`, - geoJSON: sampleSite.geojson, - popup: ( - - getCodesName( - codesContext.codesDataLoader.data, - 'sample_methods', - method.technique.method_technique_id - ) ?? '' - ) - .filter(Boolean) - .join(', ') - } - ]} - /> - ), - tooltip: - }; - }) - }, - ...supplementaryLayers.map((supplementaryLayer) => { - return { - layerName: supplementaryLayer.layerName, - layerColors: { - fillColor: supplementaryLayer.layerColors?.fillColor ?? SURVEY_MAP_LAYER_COLOURS.DEFAULT_COLOUR, - color: supplementaryLayer.layerColors?.color ?? SURVEY_MAP_LAYER_COLOURS.DEFAULT_COLOUR, - fillOpacity: supplementaryLayer.layerColors?.opacity ?? 1 - }, - features: supplementaryLayer.mapPoints.map((mapPoint: ISurveyMapPoint): IStaticLayerFeature => { - const isLoading = !mapPointMetadata[mapPoint.key]; - - return { - key: mapPoint.key, - geoJSON: mapPoint.feature, - GeoJSONProps: { - onEachFeature: (_, layer) => { - layer.on({ - popupopen: () => { - if (mapPointMetadata[mapPoint.key]) { - return; - } - mapPoint.onLoadMetadata().then((metadata) => { - setMapPointMetadata((prev) => ({ ...prev, [mapPoint.key]: metadata })); - }); - } - }); - } - }, - popup: ( - - ), - tooltip: - }; - }) - }; - }) - ]; - - return ( - - - - - - - - - {activeView === SurveySpatialDatasetViewEnum.OBSERVATIONS && ( - - )} - - {activeView === SurveySpatialDatasetViewEnum.TELEMETRY && ( - - )} - - - ); -}; - -export default SurveySpatialData; diff --git a/app/src/features/surveys/view/components/spatial-data/SurveySpatialTelemetryDataTable.tsx b/app/src/features/surveys/view/components/spatial-data/SurveySpatialTelemetryDataTable.tsx deleted file mode 100644 index 845dd6eded..0000000000 --- a/app/src/features/surveys/view/components/spatial-data/SurveySpatialTelemetryDataTable.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import grey from '@mui/material/colors/grey'; -import Skeleton from '@mui/material/Skeleton'; -import Stack from '@mui/material/Stack'; -import { GridColDef } from '@mui/x-data-grid'; -import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; -import { SurveyContext } from 'contexts/surveyContext'; -import dayjs from 'dayjs'; -import { useContext, useMemo } from 'react'; - -// Set height so we the skeleton loader will match table rows -const rowHeight = 52; - -interface ITelemetryData { - id: number; - critter_id: string | null; - device_id: number; - start: string; - end: string; -} - -interface ISurveySpatialTelemetryDataTableProps { - isLoading: boolean; -} - -// Skeleton Loader template -const SkeletonRow = () => ( - - - - - - - - - - - -); - -const SurveySpatialTelemetryDataTable = (props: ISurveySpatialTelemetryDataTableProps) => { - const surveyContext = useContext(SurveyContext); - - const tableRows = useMemo(() => { - return surveyContext.critterDeployments.map((item) => ({ - // critters in this table may use multiple devices accross multiple timespans - id: `${item.critter.survey_critter_id}-${item.deployment.device_id}-${item.deployment.attachment_start}`, - alias: item.critter.animal_id, - device_id: item.deployment.device_id, - start: dayjs(item.deployment.attachment_start).format('YYYY-MM-DD'), - end: item.deployment.attachment_end ? dayjs(item.deployment.attachment_end).format('YYYY-MM-DD') : 'Still Active' - })); - }, [surveyContext.critterDeployments]); - - const columns: GridColDef[] = [ - { - field: 'alias', - headerName: 'Alias', - flex: 1 - }, - { - field: 'device_id', - headerName: 'Device ID', - flex: 1 - }, - { - field: 'start', - headerName: 'Start', - flex: 1 - }, - { - field: 'end', - headerName: 'End', - flex: 1 - } - ]; - - return ( - <> - {props.isLoading ? ( - - - - - - ) : ( - row.id} - columns={columns} - initialState={{ - pagination: { - paginationModel: { page: 1, pageSize: 5 } - } - }} - pageSizeOptions={[5]} - rowSelection={false} - checkboxSelection={false} - disableRowSelectionOnClick - disableColumnSelector - disableColumnFilter - disableColumnMenu - disableVirtualization - sortingOrder={['asc', 'desc']} - data-testid="survey-spatial-telemetry-data-table" - /> - )} - - ); -}; - -export default SurveySpatialTelemetryDataTable; diff --git a/app/src/features/surveys/view/survey-animals/AnimalList.tsx b/app/src/features/surveys/view/survey-animals/AnimalList.tsx deleted file mode 100644 index 5d66936179..0000000000 --- a/app/src/features/surveys/view/survey-animals/AnimalList.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import { - mdiChevronDown, - mdiFamilyTree, - mdiFormatListGroup, - mdiInformationOutline, - mdiPlus, - mdiRuler, - mdiSkullOutline, - mdiSpiderWeb, - mdiTagOutline -} from '@mdi/js'; -import Icon from '@mdi/react'; -import Accordion from '@mui/material/Accordion'; -import AccordionDetails from '@mui/material/AccordionDetails'; -import AccordionSummary from '@mui/material/AccordionSummary'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import grey from '@mui/material/colors/grey'; -import Divider from '@mui/material/Divider'; -import List from '@mui/material/List'; -import ListItemButton from '@mui/material/ListItemButton'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import ListItemText from '@mui/material/ListItemText'; -import Paper from '@mui/material/Paper'; -import Skeleton from '@mui/material/Skeleton'; -import Stack from '@mui/material/Stack'; -import Toolbar from '@mui/material/Toolbar'; -import Typography from '@mui/material/Typography'; -import { useQuery } from 'hooks/useQuery'; -import { ICritterDetailedResponse } from 'interfaces/useCritterApi.interface'; -import { ISimpleCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; -import { useHistory } from 'react-router-dom'; -import { ANIMAL_SECTION } from './animal'; - -interface IAnimalListProps { - isLoading?: boolean; - surveyCritters?: ISimpleCritterWithInternalId[]; - selectedSection: ANIMAL_SECTION; - onSelectSection: (section: ANIMAL_SECTION) => void; - refreshCritter: (critter_id: string) => Promise; - onAddButton: () => void; -} - -const ListPlaceholder = (props: { displaySkeleton: boolean }) => - props.displaySkeleton ? ( - - - - - ) : ( - - No Animals - - ); - -const AnimalList = (props: IAnimalListProps) => { - const { isLoading, selectedSection, onSelectSection, refreshCritter, surveyCritters, onAddButton } = props; - - const history = useHistory(); - const { critter_id } = useQuery(); - - const survey_critter_id = Number(critter_id); - - const getSectionIcon = (section: ANIMAL_SECTION) => { - switch (section) { - case ANIMAL_SECTION.GENERAL: - return mdiInformationOutline; - case ANIMAL_SECTION.COLLECTION_UNITS: - return mdiFormatListGroup; - case ANIMAL_SECTION.MARKINGS: - return mdiTagOutline; - case ANIMAL_SECTION.MEASUREMENTS: - return mdiRuler; - case ANIMAL_SECTION.CAPTURES: - return mdiSpiderWeb; - case ANIMAL_SECTION.MORTALITY: - return mdiSkullOutline; - case ANIMAL_SECTION.FAMILY: - return mdiFamilyTree; - default: - return mdiInformationOutline; - } - }; - - const handleCritterSelect = async (critter: ISimpleCritterWithInternalId) => { - if (critter.survey_critter_id === Number(survey_critter_id)) { - history.replace(history.location.pathname); - } else { - refreshCritter(critter.critter_id); - history.push(`?critter_id=${critter.survey_critter_id}`); - } - onSelectSection(ANIMAL_SECTION.GENERAL); - }; - - return ( - - - - Animals - - - - - - - {!surveyCritters?.length ? ( - - ) : ( - surveyCritters.map((critter) => ( - - - } - onClick={() => handleCritterSelect(critter)} - aria-controls="panel1bh-content" - sx={{ - flex: '1 1 auto', - gap: '16px', - height: '70px', - px: 2, - overflow: 'hidden', - '& .MuiAccordionSummary-content': { - flex: '1 1 auto', - overflow: 'hidden', - whiteSpace: 'nowrap' - } - }}> - - - {critter.animal_id} - - - {critter.itis_scientific_name} | {critter.sex} - - - - - - - {(Object.values(ANIMAL_SECTION) as ANIMAL_SECTION[]).map((section) => ( - { - onSelectSection(section); - }}> - - - - {section} - - ))} - - - - )) - )} - - - - ); -}; - -export default AnimalList; diff --git a/app/src/features/surveys/view/survey-animals/AnimalSection.test.tsx b/app/src/features/surveys/view/survey-animals/AnimalSection.test.tsx deleted file mode 100644 index f3ac7781ec..0000000000 --- a/app/src/features/surveys/view/survey-animals/AnimalSection.test.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { render } from '@testing-library/react'; -import { AuthStateContext } from 'contexts/authStateContext'; -import { ConfigContext, IConfig } from 'contexts/configContext'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; -import { AuthProvider, AuthProviderProps } from 'react-oidc-context'; -import { getMockAuthState, SystemAdminAuthState } from 'test-helpers/auth-helpers'; -import { cleanup, waitFor } from 'test-helpers/test-utils'; -import { ANIMAL_SECTION } from './animal'; -import { AnimalSection } from './AnimalSection'; -jest.mock('../../../../hooks/useCritterbaseApi'); -const mockCritterbaseApi = useCritterbaseApi as jest.Mock; - -const mockRefreshCritter = jest.fn(); - -const authState = getMockAuthState({ base: SystemAdminAuthState }); - -const authConfig: AuthProviderProps = { - authority: 'authority', - client_id: 'client', - redirect_uri: 'redirect' -}; - -const animalSection = (section: ANIMAL_SECTION, critter?: IDetailedCritterWithInternalId) => ( - - - - - - - -); - -describe('AnimalSection', () => { - beforeEach(() => { - mockCritterbaseApi.mockImplementation(() => ({ - family: { - getAllFamilies: jest.fn() - } - })); - }); - afterEach(() => { - cleanup(); - }); - it('should mount with empty state with no critter', async () => { - const screen = render(animalSection(ANIMAL_SECTION.GENERAL)); - await waitFor(() => { - const header = screen.getByRole('heading', { name: /no animal selected/i }); - expect(header).toBeInTheDocument(); - }); - }); - - it('should render the general section', async () => { - const screen = render( - animalSection(ANIMAL_SECTION.GENERAL, { - critter_id: 'blah', - survey_critter_id: 1 - } as IDetailedCritterWithInternalId) - ); - await waitFor(() => { - const header = screen.getByRole('heading', { name: ANIMAL_SECTION.GENERAL }); - expect(header).toBeInTheDocument(); - }); - }); - - it('should render the collection units section', async () => { - const screen = render( - animalSection(ANIMAL_SECTION.COLLECTION_UNITS, { - critter_id: 'blah', - survey_critter_id: 1, - collection_units: [] - } as unknown as IDetailedCritterWithInternalId) - ); - await waitFor(() => { - const header = screen.getByRole('heading', { name: ANIMAL_SECTION.COLLECTION_UNITS }); - expect(header).toBeInTheDocument(); - }); - }); - - it('should render the markings section', async () => { - const screen = render( - animalSection(ANIMAL_SECTION.MARKINGS, { - critter_id: 'blah', - survey_critter_id: 1, - markings: [] - } as unknown as IDetailedCritterWithInternalId) - ); - await waitFor(() => { - const header = screen.getByRole('heading', { name: ANIMAL_SECTION.MARKINGS }); - expect(header).toBeInTheDocument(); - }); - }); - - it('should render the measurements section', async () => { - const screen = render( - animalSection(ANIMAL_SECTION.MEASUREMENTS, { - critter_id: 'blah', - survey_critter_id: 1, - measurements: { qualitative: [], quantitative: [] } - } as unknown as IDetailedCritterWithInternalId) - ); - await waitFor(() => { - const header = screen.getByRole('heading', { name: ANIMAL_SECTION.MEASUREMENTS }); - expect(header).toBeInTheDocument(); - }); - }); - - it('should render the captures section', async () => { - const screen = render( - animalSection(ANIMAL_SECTION.CAPTURES, { - critter_id: 'blah', - survey_critter_id: 1, - captures: [] - } as unknown as IDetailedCritterWithInternalId) - ); - await waitFor(() => { - const header = screen.getByRole('heading', { name: ANIMAL_SECTION.CAPTURES }); - expect(header).toBeInTheDocument(); - }); - }); - - it('should render the mortality section', async () => { - const screen = render( - animalSection(ANIMAL_SECTION.MORTALITY, { - critter_id: 'blah', - survey_critter_id: 1, - mortality: [] - } as unknown as IDetailedCritterWithInternalId) - ); - await waitFor(() => { - const header = screen.getByRole('heading', { name: ANIMAL_SECTION.MORTALITY }); - expect(header).toBeInTheDocument(); - }); - }); - - it('should render the family section', async () => { - const screen = render( - animalSection(ANIMAL_SECTION.FAMILY, { - critter_id: 'blah', - survey_critter_id: 1, - family_parent: [], - family_child: [] - } as unknown as IDetailedCritterWithInternalId) - ); - await waitFor(() => { - const header = screen.getByRole('heading', { name: ANIMAL_SECTION.FAMILY }); - expect(header).toBeInTheDocument(); - }); - }); -}); diff --git a/app/src/features/surveys/view/survey-animals/AnimalSection.tsx b/app/src/features/surveys/view/survey-animals/AnimalSection.tsx deleted file mode 100644 index 7e1e00c39b..0000000000 --- a/app/src/features/surveys/view/survey-animals/AnimalSection.tsx +++ /dev/null @@ -1,410 +0,0 @@ -import { mdiPlus } from '@mdi/js'; -import Icon from '@mdi/react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Collapse from '@mui/material/Collapse'; -import grey from '@mui/material/colors/grey'; -import Typography from '@mui/material/Typography'; -import { SurveyAnimalsI18N } from 'constants/i18n'; -import dayjs from 'dayjs'; -import utc from 'dayjs/plugin/utc'; -import { EditDeleteStubCard } from 'features/surveys/components/EditDeleteStubCard'; -import { useDialogContext } from 'hooks/useContext'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import { ICritterDetailedResponse, IFamilyChildResponse } from 'interfaces/useCritterApi.interface'; -import { useState } from 'react'; -import { TransitionGroup } from 'react-transition-group'; -import { AnimalRelationship, ANIMAL_FORM_MODE, ANIMAL_SECTION } from './animal'; -import { AnimalSectionWrapper } from './AnimalSectionWrapper'; -import CaptureAnimalForm from './form-sections/CaptureAnimalForm'; -import CollectionUnitAnimalForm from './form-sections/CollectionUnitAnimalForm'; -import { FamilyAnimalForm } from './form-sections/FamilyAnimalForm'; -import GeneralAnimalForm from './form-sections/GeneralAnimalForm'; -import { MarkingAnimalForm } from './form-sections/MarkingAnimalForm'; -import MeasurementAnimalForm from './form-sections/MeasurementAnimalForm'; -import MortalityAnimalForm from './form-sections/MortalityAnimalForm'; -import GeneralAnimalSummary from './GeneralAnimalSummary'; - -dayjs.extend(utc); - -type SubHeaderData = Record; -type DeleteFn = (...args: any[]) => Promise; - -interface IAnimalSectionProps { - /** - * Detailed Critter from Critterbase. - * In most cases the Critter will be defined with the exception of adding new. - */ - critter?: ICritterDetailedResponse; - /** - * Callback to refresh the detailed Critter. - * Children with the transition component are dependent on the Critter updating to trigger the transitions. - */ - refreshCritter: (critter_id: string) => Promise; - /** - * The selected section. - * - * example: 'Captures' | 'Markings'. - */ - section: ANIMAL_SECTION; -} - -/** - * This component acts as a switch for the animal form sections. - * - * Goal was to make the form sections share common props and also make it flexible - * handling the different requirements / needs of the individual sections. - * - * @param {IAnimalSectionProps} props - * @returns {*} - */ -export const AnimalSection = (props: IAnimalSectionProps) => { - const critterbaseApi = useCritterbaseApi(); - const dialog = useDialogContext(); - - const [formObject, setFormObject] = useState(undefined); - const [openForm, setOpenForm] = useState(false); - - const formatDate = (dt: Date) => dayjs(dt).utc().format('MMMM D, YYYY'); - - const handleOpenAddForm = () => { - setFormObject(undefined); - setOpenForm(true); - }; - - const handleOpenEditForm = (editObject: any) => { - setFormObject(editObject); - setOpenForm(true); - }; - - const refreshDetailedCritter = async () => { - if (props.critter) { - return props.refreshCritter(props.critter.critter_id); - } - }; - - const handleCloseForm = () => { - setFormObject(undefined); - setOpenForm(false); - refreshDetailedCritter(); - }; - - const handleDelete = async (name: string, deleteService: T, ...args: Parameters) => { - const closeConfirmDialog = () => dialog.setYesNoDialog({ open: false }); - - dialog.setYesNoDialog({ - dialogTitle: `Delete ${name[0].toUpperCase() + name.slice(1)}`, - dialogText: 'Are you sure you want to delete this record?', - open: true, - onYes: () => { - const handleConfirmDelete = async () => { - closeConfirmDialog(); - try { - await deleteService(...args); - await refreshDetailedCritter(); - dialog.setSnackbar({ open: true, snackbarMessage: `Successfully deleted ${name}` }); - } catch (err) { - dialog.setSnackbar({ open: true, snackbarMessage: `Failed to delete ${name}` }); - } - }; - handleConfirmDelete(); - }, - onNo: () => closeConfirmDialog(), - onClose: () => closeConfirmDialog() - }); - }; - - /** - * Formats the data card sub header to a unified format. - * - * example: 'marking: ear tag | colour: blue' - * - * @param {SubHeaderData} subHeaderData - Key value pairs. - * @returns {string} Formatted sub-header. - */ - const formatSubHeader = (subHeaderData: SubHeaderData) => { - const formatArr: string[] = []; - const entries = Object.entries(subHeaderData); - entries.forEach(([key, value]) => { - if (value == null || value === '') { - return; - } - formatArr.push(`${key}: ${value}`); - }); - return formatArr.join(' | '); - }; - - const getAddButton = (label: string) => ( - - ); - - /** - * If the critter is not defined, render the empty state. - * - */ - if (!props.critter) { - return ( - - - - No Animal Selected - - - - ); - } - - /** - * Shared animal form props. - * - */ - const SECTION_FORM_PROPS = { - formMode: formObject ? ANIMAL_FORM_MODE.EDIT : ANIMAL_FORM_MODE.ADD, - formObject: formObject, - critter: props.critter, - open: openForm, - handleClose: handleCloseForm - } as const; - - /** - * Switch statements for the different form sections. - * - */ - if (props.section === ANIMAL_SECTION.GENERAL) { - return ( - } - infoText={SurveyAnimalsI18N.animalGeneralHelp} - section={props.section} - critter={props.critter}> - handleOpenEditForm(props.critter)} /> - - ); - } - - if (props.section === ANIMAL_SECTION.COLLECTION_UNITS) { - return ( - } - addBtn={getAddButton(SurveyAnimalsI18N.animalCollectionUnitAddBtn)} - infoText={SurveyAnimalsI18N.animalCollectionUnitHelp} - section={props.section} - critter={props.critter}> - - {props.critter.collection_units.map((unit) => ( - - handleOpenEditForm(unit)} - onClickDelete={async () => { - handleDelete( - 'ecological unit', - critterbaseApi.collectionUnit.deleteCritterCollectionUnit, - unit.critter_collection_unit_id - ); - }} - /> - - ))} - - - ); - } - - if (props.section === ANIMAL_SECTION.MARKINGS) { - return ( - } - addBtn={getAddButton(SurveyAnimalsI18N.animalMarkingAddBtn)} - infoText={SurveyAnimalsI18N.animalMarkingHelp} - section={props.section} - critter={props.critter}> - - {props.critter.markings.map((marking) => ( - - handleOpenEditForm(marking)} - onClickDelete={async () => { - handleDelete('marking', critterbaseApi.marking.deleteMarking, marking.marking_id); - }} - /> - - ))} - - - ); - } - - if (props.section === ANIMAL_SECTION.MEASUREMENTS) { - return ( - } - infoText={SurveyAnimalsI18N.animalMeasurementHelp} - addBtn={getAddButton(SurveyAnimalsI18N.animalMeasurementAddBtn)} - section={props.section} - critter={props.critter}> - - {props.critter.measurements.quantitative.map((measurement) => ( - - handleOpenEditForm(measurement)} - onClickDelete={async () => { - handleDelete( - 'measurement', - critterbaseApi.measurement.deleteQuantitativeMeasurement, - measurement.measurement_quantitative_id - ); - }} - /> - - ))} - {props.critter.measurements.qualitative.map((measurement) => ( - - handleOpenEditForm(measurement)} - onClickDelete={async () => { - handleDelete( - 'measurement', - critterbaseApi.measurement.deleteQualitativeMeasurement, - measurement.measurement_qualitative_id - ); - }} - /> - - ))} - - - ); - } - - if (props.section === ANIMAL_SECTION.MORTALITY) { - return ( - } - infoText={SurveyAnimalsI18N.animalMortalityHelp} - addBtn={ - props.critter.mortality.length === 0 ? getAddButton(SurveyAnimalsI18N.animalMortalityAddBtn) : undefined - } - section={props.section} - critter={props.critter}> - - {props.critter.mortality.map((mortality) => ( - - handleOpenEditForm(mortality)} - onClickDelete={async () => { - handleDelete('mortality', critterbaseApi.mortality.deleteMortality, mortality.mortality_id); - }} - /> - - ))} - - - ); - } - - if (props.section === ANIMAL_SECTION.FAMILY) { - return ( - } - infoText={SurveyAnimalsI18N.animalFamilyHelp} - addBtn={getAddButton(SurveyAnimalsI18N.animalFamilyAddBtn)} - section={props.section} - critter={props.critter}> - - {[...props.critter.family_child, ...props.critter.family_parent].map((family) => { - const isChild = (family as IFamilyChildResponse)?.child_critter_id; - return ( - - handleOpenEditForm({ ...family, critter_id: props.critter?.critter_id })} - onClickDelete={async () => { - handleDelete('family relationship', critterbaseApi.family.deleteRelationship, { - relationship: isChild ? AnimalRelationship.CHILD : AnimalRelationship.PARENT, - family_id: family.family_id, - critter_id: props.critter?.critter_id ?? '' - }); - }} - /> - - ); - })} - - - ); - } - - if (props.section === ANIMAL_SECTION.CAPTURES) { - return ( - } - addBtn={getAddButton(SurveyAnimalsI18N.animalCaptureAddBtn)} - infoText={SurveyAnimalsI18N.animalCaptureHelp} - section={props.section} - critter={props.critter}> - - {props.critter.captures.map((capture) => ( - - handleOpenEditForm(capture)} - onClickDelete={async () => { - handleDelete('capture', critterbaseApi.capture.deleteCapture, capture.capture_id); - }} - /> - - ))} - - - ); - } - - return null; -}; diff --git a/app/src/features/surveys/view/survey-animals/AnimalSectionWrapper.tsx b/app/src/features/surveys/view/survey-animals/AnimalSectionWrapper.tsx deleted file mode 100644 index b490c5ae6e..0000000000 --- a/app/src/features/surveys/view/survey-animals/AnimalSectionWrapper.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import Box from '@mui/material/Box'; -import grey from '@mui/material/colors/grey'; -import Divider from '@mui/material/Divider'; -import Paper from '@mui/material/Paper'; -import Stack from '@mui/material/Stack'; -import Toolbar from '@mui/material/Toolbar'; -import Typography from '@mui/material/Typography'; -import { ICritterDetailedResponse } from 'interfaces/useCritterApi.interface'; -import { PropsWithChildren } from 'react'; -import { ANIMAL_SECTION } from './animal'; - -interface IAnimalSectionWrapperProps extends PropsWithChildren { - form?: JSX.Element; - section?: ANIMAL_SECTION; - infoText?: string; - critter?: ICritterDetailedResponse; - addBtn?: JSX.Element; -} -/** - * Wrapper for the selected section main content. - * - * This component renders beside the AnimalList navbar. - * In most cases it displays the currently selected critter + the attribute data cards. - * - * Note: All props can be undefined to easily handle the empty state. - * - * @param {IAnimalSectionWrapperProps} props - * @returns {*} - */ -export const AnimalSectionWrapper = (props: IAnimalSectionWrapperProps) => { - return ( - <> - {props.form} - - - - {props.critter ? `Animal Details > ${props.critter?.animal_id}` : 'No animal selected'} - - - - - {!props?.critter ? ( - - - No Animal Selected - - - ) : ( - - - - - {props.section} - - {props.addBtn} - - - - {props.infoText} - - {props.children} - - - )} - - - ); -}; diff --git a/app/src/features/surveys/view/survey-animals/GeneralAnimalSummary.tsx b/app/src/features/surveys/view/survey-animals/GeneralAnimalSummary.tsx deleted file mode 100644 index f2e6b57a61..0000000000 --- a/app/src/features/surveys/view/survey-animals/GeneralAnimalSummary.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { mdiContentCopy, mdiPencilOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import Box from '@mui/material/Box'; -import Divider from '@mui/material/Divider'; -import IconButton from '@mui/material/IconButton'; -import Paper from '@mui/material/Paper'; -import Toolbar from '@mui/material/Toolbar'; -import Typography from '@mui/material/Typography'; -import { DialogContext } from 'contexts/dialogContext'; -import { ICritterDetailedResponse } from 'interfaces/useCritterApi.interface'; -import { useContext } from 'react'; -import { setMessageSnackbar } from 'utils/Utils'; -import { DetailsWrapper } from '../SurveyDetails'; - -interface IGeneralDetail { - title: string; - value?: string | null; - valueEndIcon?: JSX.Element; -} - -interface GeneralAnimalSummaryProps { - /* - * Callback to be fired when edit action selected - */ - handleEdit: () => void; - critter: ICritterDetailedResponse; -} - -const GeneralAnimalSummary = (props: GeneralAnimalSummaryProps) => { - const dialogContext = useContext(DialogContext); - - const animalGeneralDetails: Array = [ - { title: 'Alias', value: props.critter?.animal_id }, - { title: 'Taxon', value: props.critter.itis_scientific_name }, - { title: 'Sex', value: props.critter.sex }, - { title: 'Wildlife Health ID', value: props.critter?.wlh_id }, - { - title: 'Critterbase ID', - value: props?.critter?.critter_id, - valueEndIcon: ( - { - navigator.clipboard.writeText(props?.critter?.critter_id ?? ''); - setMessageSnackbar('Copied Critter ID', dialogContext); - }}> - - - ) - } - ]; - - return ( - - - - - Animal Summary - - - - - - - - - Details - - {animalGeneralDetails.map((details) => - details.value !== undefined ? ( - - {details.title} - - {details.value} - {details.valueEndIcon} - - - ) : null - )} - - - - - - ); -}; - -export default GeneralAnimalSummary; diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsPage.test.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsPage.test.tsx deleted file mode 100644 index 1dc65f98b1..0000000000 --- a/app/src/features/surveys/view/survey-animals/SurveyAnimalsPage.test.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { AuthStateContext } from 'contexts/authStateContext'; -import { IProjectAuthStateContext, ProjectAuthStateContext } from 'contexts/projectAuthStateContext'; -import { IProjectContext, ProjectContext } from 'contexts/projectContext'; -import { ISurveyContext, SurveyContext } from 'contexts/surveyContext'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import { DataLoader } from 'hooks/useDataLoader'; -import { useTelemetryApi } from 'hooks/useTelemetryApi'; -import { BrowserRouter } from 'react-router-dom'; -import { getMockAuthState, SystemAdminAuthState } from 'test-helpers/auth-helpers'; -import { cleanup, fireEvent, render, waitFor } from 'test-helpers/test-utils'; -import { SurveyAnimalsPage } from './SurveyAnimalsPage'; - -jest.mock('hooks/useQuery', () => ({ useQuery: () => ({ critter_id: 0 }) })); -jest.mock('../../../../hooks/useBioHubApi.ts'); -jest.mock('../../../../hooks/useTelemetryApi'); -jest.mock('../../../../hooks/useCritterbaseApi'); -const mockBiohubApi = useBiohubApi as jest.Mock; -const mockTelemetryApi = useTelemetryApi as jest.Mock; -const mockCritterbaseApi = useCritterbaseApi as jest.Mock; - -const mockUseBiohub = { - survey: { - getSurveyCritters: jest.fn(), - getDeploymentsInSurvey: jest.fn() - }, - taxonomy: { - getSpeciesFromIds: jest.fn() - } -}; - -const mockUseTelemetry = { - devices: { - getDeviceDetails: jest.fn() - } -}; - -const mockUseCritterbase = { - critters: { - getDetailedCritter: jest.fn() - }, - family: { - getAllFamilies: jest.fn() - }, - lookup: { - getTaxonMeasurements: jest.fn() - } -}; -const mockSurveyContext: ISurveyContext = { - artifactDataLoader: { - data: null, - load: jest.fn() - } as unknown as DataLoader, - surveyId: 1, - projectId: 1, - surveyDataLoader: { - data: { surveyData: { survey_details: { survey_name: 'name' } } }, - load: jest.fn() - } as unknown as DataLoader -} as unknown as ISurveyContext; - -const mockProjectAuthStateContext: IProjectAuthStateContext = { - getProjectParticipant: () => null, - hasProjectRole: () => true, - hasProjectPermission: () => true, - hasSystemRole: () => true, - getProjectId: () => 1, - hasLoadedParticipantInfo: true -}; - -const mockProjectContext: IProjectContext = { - artifactDataLoader: { - data: null, - load: jest.fn() - } as unknown as DataLoader, - projectId: 1, - projectDataLoader: { - data: { projectData: { project: { project_name: 'name' } } }, - load: jest.fn() - } as unknown as DataLoader -} as unknown as IProjectContext; - -const authState = getMockAuthState({ base: SystemAdminAuthState }); - -const page = ( - - - - - - - - - - - -); - -describe('SurveyAnimalsPage', () => { - beforeEach(async () => { - mockBiohubApi.mockImplementation(() => mockUseBiohub); - mockTelemetryApi.mockImplementation(() => mockUseTelemetry); - mockCritterbaseApi.mockImplementation(() => mockUseCritterbase); - mockUseBiohub.survey.getDeploymentsInSurvey.mockClear(); - mockUseBiohub.survey.getSurveyCritters.mockClear(); - mockUseTelemetry.devices.getDeviceDetails.mockClear(); - mockUseCritterbase.family.getAllFamilies.mockClear(); - mockUseCritterbase.lookup.getTaxonMeasurements.mockClear(); - }); - - afterEach(() => { - cleanup(); - }); - - it('should render the add critter dialog', async () => { - const screen = render(page); - - await waitFor(() => { - const addAnimalBtn = screen.getByRole('button', { name: 'Add' }); - expect(addAnimalBtn).toBeInTheDocument(); - }); - - await waitFor(() => { - fireEvent.click(screen.getByText('Add')); - }); - - await waitFor(() => { - expect(screen.getByText('Add Critter')).toBeInTheDocument(); - }); - - await waitFor(() => { - fireEvent.click(screen.getByText('Cancel')); - }); - }); - - it('should be able to select critter from navbar', async () => { - mockUseBiohub.survey.getSurveyCritters.mockResolvedValueOnce([ - { - critter_id: 'critter_uuid', - animal_id: 'test-critter-alias', - wlh_id: '123-45', - survey_critter_id: 1, - taxon: 'a', - created_at: 'a' - } - ]); - const screen = render(page); - await waitFor(() => { - const addAnimalBtn = screen.getByRole('button', { name: 'Add' }); - expect(addAnimalBtn).toBeInTheDocument(); - }); - - await waitFor(() => { - expect(screen.getByText('test-critter-alias')).toBeInTheDocument(); - }); - - await waitFor(() => { - fireEvent.click(screen.getByText('test-critter-alias')); - }); - - await waitFor(() => { - expect(screen.getByText('General')).toBeInTheDocument(); - }); - }); -}); diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsPage.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsPage.tsx deleted file mode 100644 index ecb8dd7392..0000000000 --- a/app/src/features/surveys/view/survey-animals/SurveyAnimalsPage.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { SurveyContext } from 'contexts/surveyContext'; -import { SurveySectionFullPageLayout } from 'features/surveys/components/layout/SurveySectionFullPageLayout'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import useDataLoader from 'hooks/useDataLoader'; -import { useQuery } from 'hooks/useQuery'; -import { useContext, useEffect, useState } from 'react'; -import { ANIMAL_FORM_MODE, ANIMAL_SECTION } from './animal'; -import AnimalList from './AnimalList'; -import { AnimalSection } from './AnimalSection'; -import GeneralAnimalForm from './form-sections/GeneralAnimalForm'; - -export const SurveyAnimalsPage = () => { - const biohubApi = useBiohubApi(); - const critterbaseApi = useCritterbaseApi(); - - const { surveyId, projectId } = useContext(SurveyContext); - - const { critter_id } = useQuery(); - - const survey_critter_id = Number(critter_id); - - const [selectedSection, setSelectedSection] = useState(ANIMAL_SECTION.GENERAL); - const [openAddCritter, setOpenAddCritter] = useState(false); - - const { - data: surveyCritters, - load: loadCritters, - refresh: refreshSurveyCritters, - isLoading: crittersLoading - } = useDataLoader(() => biohubApi.survey.getSurveyCritters(projectId, surveyId)); - - const { data: detailedCritter, refresh: refreshCritter } = useDataLoader(critterbaseApi.critters.getDetailedCritter); - - loadCritters(); - - useEffect(() => { - const getDetailedCritterOnMount = async () => { - if (detailedCritter) { - return; - } - const focusCritter = surveyCritters?.find((critter) => critter.survey_critter_id === Number(survey_critter_id)); - if (!focusCritter) { - return; - } - await refreshCritter(focusCritter.critter_id); - }; - getDetailedCritterOnMount(); - }, [surveyCritters, survey_critter_id, critterbaseApi.critters, detailedCritter, refreshCritter]); - - return ( - <> - { - setOpenAddCritter(false); - refreshSurveyCritters(); - }} - /> - setOpenAddCritter(true)} - refreshCritter={refreshCritter} - surveyCritters={surveyCritters} - isLoading={crittersLoading} - selectedSection={selectedSection} - onSelectSection={(section) => setSelectedSection(section)} - /> - } - mainComponent={ - - } - /> - - ); -}; diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx deleted file mode 100644 index e532c67a3b..0000000000 --- a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { GridColDef } from '@mui/x-data-grid'; -import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; -import { ProjectRoleGuard } from 'components/security/Guards'; -import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; -import { default as dayjs } from 'dayjs'; -import { ISimpleCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; -import SurveyAnimalsTableActions from './SurveyAnimalsTableActions'; -import { IAnimalDeployment } from './telemetry-device/device'; - -interface ISurveyAnimalsTableEntry { - survey_critter_id: number; - critter_id: string; - animal_id: string | null; - itis_scientific_name: string; - deployments?: IAnimalDeployment[]; -} - -interface ISurveyAnimalsTableProps { - animalData: ISimpleCritterWithInternalId[]; - deviceData?: IAnimalDeployment[]; - onMenuOpen: (critter_id: number) => void; - onRemoveCritter: (critter_id: number) => void; - onEditCritter: (critter_id: number) => void; - onMapOpen: () => void; -} - -export const SurveyAnimalsTable = ({ - animalData, - deviceData, - onMenuOpen, - onRemoveCritter, - onEditCritter, - onMapOpen -}: ISurveyAnimalsTableProps): JSX.Element => { - const animalDeviceData: ISurveyAnimalsTableEntry[] = deviceData - ? [...animalData] // spreading this prevents this error "TypeError: Cannot assign to read only property '0' of object '[object Array]' in typescript" - .map((animal) => { - const deployments = deviceData.filter((device) => device.critter_id === animal.critter_id); - return { - ...animal, - deployments: deployments - }; - }) - : animalData; - - const columns: GridColDef[] = [ - { - field: 'itis_scientific_name', - headerName: 'Species', - flex: 1 - }, - { - field: 'animal_id', - headerName: 'Alias', - flex: 1 - }, - { - field: 'wlh_id', - headerName: 'WLH ID', - flex: 1, - renderCell: (params) => <>{params.value ? params.value : 'None'} - }, - { - field: 'current_devices', - headerName: 'Current Devices', - flex: 1, - valueGetter: (params) => { - const currentDeploys = params.row.deployments?.filter( - (device: IAnimalDeployment) => !device.attachment_end || dayjs(device.attachment_end).isAfter(dayjs()) - ); - return currentDeploys?.length - ? currentDeploys.map((device: IAnimalDeployment) => device.device_id).join(', ') - : 'No Devices'; - } - }, - { - field: 'previous_devices', - headerName: 'Previous Devices', - flex: 1, - valueGetter: (params) => { - const previousDeploys = params.row.deployments?.filter( - (device: IAnimalDeployment) => device.attachment_end && dayjs(device.attachment_end).isBefore(dayjs()) - ); - return previousDeploys?.length - ? previousDeploys.map((device: IAnimalDeployment) => device.device_id).join(', ') - : 'No Devices'; - } - }, - { - field: 'actions', - type: 'actions', - sortable: false, - flex: 1, - align: 'right', - maxWidth: 50, - renderCell: (params) => ( - - - - ) - } - ]; - - return ( - row.critter_id} - columns={columns} - pageSizeOptions={[5]} - rowSelection={false} - checkboxSelection={false} - hideFooter - disableRowSelectionOnClick - disableColumnSelector - disableColumnFilter - disableColumnMenu - disableVirtualization - sortingOrder={['asc', 'desc']} - data-testid="survey-animal-table" - /> - ); -}; diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.test.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.test.tsx deleted file mode 100644 index 36f5a54bb5..0000000000 --- a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.test.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { fireEvent, render, waitFor } from 'test-helpers/test-utils'; -import SurveyAnimalsTableActions from './SurveyAnimalsTableActions'; - -describe('SurveyAnimalsTableActions', () => { - const onAddDevice = jest.fn(); - const onRemoveCritter = jest.fn(); - const onEditCritter = jest.fn(); - - it('all buttons should be clickable', async () => { - const { getByTestId } = render( - {}} - onEditCritter={onEditCritter} - onRemoveCritter={onRemoveCritter} - onMenuOpen={() => {}} - onMapOpen={() => {}} - /> - ); - - fireEvent.click(getByTestId('animal actions')); - - await waitFor(() => { - expect(getByTestId('animal-table-row-add-device')).toBeInTheDocument(); - expect(getByTestId('animal-table-row-remove-critter')).toBeInTheDocument(); - }); - - fireEvent.click(getByTestId('animal-table-row-add-device')); - expect(onAddDevice.mock.calls.length).toBe(1); - - fireEvent.click(getByTestId('animal-table-row-remove-critter')); - expect(onRemoveCritter.mock.calls.length).toBe(1); - - fireEvent.click(getByTestId('animal-table-row-edit-critter')); - expect(onEditCritter.mock.calls.length).toBe(1); - }); -}); diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx deleted file mode 100644 index 240c000bd7..0000000000 --- a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { mdiDotsVertical, mdiMapMarker, mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import IconButton from '@mui/material/IconButton'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import Menu from '@mui/material/Menu'; -import MenuItem from '@mui/material/MenuItem'; -import Typography from '@mui/material/Typography'; -import React, { useState } from 'react'; -import { IAnimalDeployment } from './telemetry-device/device'; - -type ICritterFn = (critter_id: number) => void; - -export interface ITableActionsMenuProps { - critter_id: number; - devices?: IAnimalDeployment[]; - onMenuOpen: ICritterFn; - onAddDevice?: ICritterFn; - onEditDevice?: ICritterFn; - onEditCritter: ICritterFn; - onRemoveCritter: ICritterFn; - onMapOpen: () => void; -} - -const SurveyAnimalsTableActions = (props: ITableActionsMenuProps) => { - const [anchorEl, setAnchorEl] = useState(null); - const open = Boolean(anchorEl); - - const handleClick = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - props.onMenuOpen(props.critter_id); - }; - - const handleClose = () => { - setAnchorEl(null); - }; - - return ( - <> - - - - - {props.onAddDevice ? ( - { - handleClose(); - props.onAddDevice?.(props.critter_id); - }} - data-testid="animal-table-row-add-device"> - - - - Add Telemetry Device - - ) : null} - - {props.devices?.length && props.onEditDevice ? ( - { - handleClose(); - props.onEditDevice?.(props.critter_id); - }} - data-testid="animal-table-row-edit-timespan"> - - - - Edit Telemetry Devices - - ) : null} - - { - handleClose(); - props.onEditCritter(props.critter_id); - }} - data-testid="animal-table-row-edit-critter"> - - - - Edit Animal - - - {!props.devices?.length && ( - { - handleClose(); - props.onRemoveCritter(props.critter_id); - }} - data-testid="animal-table-row-remove-critter"> - - - - Remove Animal - - )} - - {props.devices?.length ? ( - { - handleClose(); - props.onMapOpen(); - }} - data-testid="animal-table-row-view-telemetry"> - - - - View Telemetry - - ) : null} - - - ); -}; - -export default SurveyAnimalsTableActions; diff --git a/app/src/features/surveys/view/survey-animals/TextInputToggle.tsx b/app/src/features/surveys/view/survey-animals/TextInputToggle.tsx deleted file mode 100644 index 2f8aa599ca..0000000000 --- a/app/src/features/surveys/view/survey-animals/TextInputToggle.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { mdiPlus } from '@mdi/js'; -import Icon from '@mdi/react'; -import Button from '@mui/material/Button'; -import { ReactNode, useState } from 'react'; - -interface TextInputToggleProps { - label: string; - children: ReactNode; - //Additional props if you want the parent component to have control of the toggle state - toggleProps?: { - handleToggle: () => void; - toggleState: boolean; - }; -} - -/** - * Renders a toggle input. Button with label -> any component - * Used in animal form for toggle comment inputs - * - * @param {TextInputToggleProps} - * @return {*} - */ - -const TextInputToggle = ({ children, label, toggleProps }: TextInputToggleProps) => { - const [showInput, setShowInput] = useState(false); - const toggleInput = () => { - setShowInput((s) => !s); - }; - - const canShowInput = toggleProps?.toggleState === undefined ? showInput : toggleProps?.toggleState; - - return ( -
- {!canShowInput ? ( - - ) : ( - children - )} -
- ); -}; - -export default TextInputToggle; diff --git a/app/src/features/surveys/view/survey-animals/animal.ts b/app/src/features/surveys/view/survey-animals/animal.ts index ff6d0822d3..2e8d5fd962 100644 --- a/app/src/features/surveys/view/survey-animals/animal.ts +++ b/app/src/features/surveys/view/survey-animals/animal.ts @@ -1,7 +1,6 @@ import { DATE_LIMIT } from 'constants/dateTimeFormats'; import { default as dayjs } from 'dayjs'; import { - ICritterDetailedResponse, IQualitativeMeasurementCreate, IQualitativeMeasurementUpdate, IQuantitativeMeasurementCreate, @@ -9,12 +8,11 @@ import { } from 'interfaces/useCritterApi.interface'; import { PROJECTION_MODE } from 'utils/mapProjectionHelpers'; import yup from 'utils/YupSchema'; -import { AnyObjectSchema, InferType } from 'yup'; /** * Critterbase related enums. - * */ + export enum ANIMAL_FORM_MODE { ADD = 'add', EDIT = 'edit' @@ -32,77 +30,7 @@ export enum AnimalRelationship { PARENT = 'parents' } -export enum ANIMAL_SECTION { - GENERAL = 'General', - COLLECTION_UNITS = 'Ecological Units', - MARKINGS = 'Markings', - MEASUREMENTS = 'Measurements', - CAPTURES = 'Captures', - MORTALITY = 'Mortality', - FAMILY = 'Family' -} - -/** - * Shared props for animal section forms. - * example: MarkingAnimalForm - * - */ - -export type AnimalFormProps = - | { - /** - * When formMode 'ADD' formObject is undefined. - */ - formObject?: never; - /** - * The form mode -> Add / EDIT. - */ - formMode: ANIMAL_FORM_MODE.ADD; - /** - * The dialog open state. - */ - open: boolean; - /** - * Callback when dialog closes. - */ - handleClose: () => void; - /** - * Critterbase detailed critter object. - */ - critter: ICritterDetailedResponse; - } - | { - /** - * When formMode 'EDIT' formObject is defined. - */ - formObject: T; - formMode: ANIMAL_FORM_MODE.EDIT; - open: boolean; - handleClose: () => void; - critter: ICritterDetailedResponse; - }; - -/** - * Checks if property in schema is required. Used to keep required fields in sync with schema. - * ie: { required: true } -> { required: isReq(Schema, 'property_name') } - * - * @template T - AnyObjectSchema - * @param {T} schema - Yup Schema. - * @param {keyof T['fields']} key - Property of yup schema. - * @returns {boolean} indicator if required in schema. - */ -export const isRequiredInSchema = (schema: T, key: keyof T['fields']): boolean => { - return Boolean(schema.fields[key].exclusiveTests.required); -}; - -export const glt = (num: number, greater = true) => `Must be ${greater ? 'greater' : 'less'} than or equal to ${num}`; - -const req = 'Required'; -const mustBeNum = 'Must be a number'; -const numSchema = yup.number().typeError(mustBeNum); - -const latSchema = yup.number().min(-90, glt(-90)).max(90, glt(90, false)).typeError(mustBeNum); -const lonSchema = yup.number().min(-180, glt(-180)).max(180, glt(180, false)).typeError(mustBeNum); +const glt = (num: number, greater = true) => `Must be ${greater ? 'greater' : 'less'} than or equal to ${num}`; const dateSchema = yup .date() @@ -112,7 +40,6 @@ const dateSchema = yup /** * Critterbase create schemas. - * */ export const LocationSchema = yup.object().shape({ @@ -127,35 +54,35 @@ export const LocationSchema = yup.object().shape({ .number() .when('projection_mode', { is: (projection_mode: PROJECTION_MODE) => projection_mode === PROJECTION_MODE.WGS, - then: latSchema + then: yup.number().min(-90, glt(-90)).max(90, glt(90, false)).typeError('Must be a number') }) .when('projection_mode', { is: (projection_mode: PROJECTION_MODE) => projection_mode === PROJECTION_MODE.UTM, then: yup.number() }) - .required(req), + .required('Required'), longitude: yup .number() .when('projection_mode', { is: (projection_mode: PROJECTION_MODE) => projection_mode === PROJECTION_MODE.WGS, - then: lonSchema + then: yup.number().min(-180, glt(-180)).max(180, glt(180, false)).typeError('Must be a number') }) .when('projection_mode', { is: (projection_mode: PROJECTION_MODE) => projection_mode === PROJECTION_MODE.UTM, then: yup.number() }) - .required(req), - coordinate_uncertainty: yup.number().required(req), + .required('Required'), + coordinate_uncertainty: yup.number().required('Required'), coordinate_uncertainty_unit: yup.string() }); export const CreateCritterCaptureSchema = yup.object({ capture_id: yup.string().optional(), - critter_id: yup.string().required(req), + critter_id: yup.string().required('Required'), capture_location: LocationSchema.required(), release_location: LocationSchema.optional().default(undefined), capture_comment: yup.string().optional(), - capture_date: yup.string().required(req), + capture_date: yup.string().required('Required'), capture_time: yup.string().optional().nullable(), release_date: yup.string().optional().nullable(), release_time: yup.string().optional().nullable(), @@ -164,16 +91,16 @@ export const CreateCritterCaptureSchema = yup.object({ export const CreateCritterSchema = yup.object({ critter_id: yup.string().optional(), - itis_tsn: yup.number().required(req), - animal_id: yup.string().required(req), + itis_tsn: yup.number().required('Required'), + animal_id: yup.string().required('Required'), wlh_id: yup.string().optional().nullable(), - sex: yup.mixed().oneOf(Object.values(AnimalSex)).required(req), + sex: yup.mixed().oneOf(Object.values(AnimalSex)).required('Required'), critter_comment: yup.string().optional().nullable() }); export const CreateCritterMarkingSchema = yup.object({ marking_id: yup.string().optional(), - critter_id: yup.string().required(req), + critter_id: yup.string().required('Required'), marking_type_id: yup.string().required('Marking type is required'), taxon_marking_body_location_id: yup.string().required('Body location required'), primary_colour_id: yup.string().optional().nullable(), @@ -182,12 +109,12 @@ export const CreateCritterMarkingSchema = yup.object({ }); export const CreateCritterMeasurementSchema = yup.object({ - critter_id: yup.string().required().required(req), + critter_id: yup.string().required().required('Required'), measurement_qualitative_id: yup.string().optional().nullable(), measurement_quantitative_id: yup.string().optional().nullable(), taxon_measurement_id: yup.string().required('Type is required'), qualitative_option_id: yup.string().optional().nullable(), - value: numSchema.required(req).optional().nullable(), + value: yup.number().typeError('Must be a number').required('Required').optional().nullable(), measured_timestamp: dateSchema.required('Date is required').optional().nullable(), measurement_comment: yup.string().optional().nullable(), capture_id: yup.string().optional().nullable(), @@ -196,13 +123,13 @@ export const CreateCritterMeasurementSchema = yup.object({ export const CreateCritterCollectionUnitSchema = yup.object({ critter_collection_unit_id: yup.string().optional(), - critter_id: yup.string().required(req), + critter_id: yup.string().required('Required'), collection_unit_id: yup.string().required('Name is required'), collection_category_id: yup.string().required('Category is required') }); export const CreateCritterMortalitySchema = yup.object({ - critter_id: yup.string().required(req), + critter_id: yup.string().required('Required'), mortality_id: yup.string().optional().nullable(), location: LocationSchema.required(), mortality_timestamp: dateSchema.required('Mortality Date is required'), @@ -216,24 +143,24 @@ export const CreateCritterMortalitySchema = yup.object({ }); export const CreateCritterFamilySchema = yup.object({ - critter_id: yup.string().uuid().required(), + critterbase_critter_id: yup.string().uuid().required(), family_id: yup.string().optional(), family_label: yup.string().optional(), - relationship: yup.mixed().oneOf(Object.values(AnimalRelationship)).required(req) + relationship: yup.mixed().oneOf(Object.values(AnimalRelationship)).required('Required') }); /** * Critterbase schema infered types. * */ -export type ICreateCritter = InferType; - -export type ICreateCritterMarking = InferType; -export type ICreateCritterMeasurement = InferType; -export type ICreateCritterCollectionUnit = InferType & { key?: string }; -export type ICreateCritterCapture = InferType; -export type ICreateCritterFamily = InferType; -export type ICreateCritterMortality = InferType; +export type ICreateCritter = yup.InferType; + +export type ICreateCritterMarking = yup.InferType; +export type ICreateCritterMeasurement = yup.InferType; +export type ICreateCritterCollectionUnit = yup.InferType & { key?: string }; +export type ICreateCritterCapture = yup.InferType; +export type ICreateCritterFamily = yup.InferType; +export type ICreateCritterMortality = yup.InferType; /** * Adding data to a critters in bulk diff --git a/app/src/features/surveys/view/survey-animals/form-sections/CaptureAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/CaptureAnimalForm.tsx deleted file mode 100644 index 2f7b683b05..0000000000 --- a/app/src/features/surveys/view/survey-animals/form-sections/CaptureAnimalForm.tsx +++ /dev/null @@ -1,329 +0,0 @@ -import Box from '@mui/material/Box'; -import Checkbox from '@mui/material/Checkbox'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import Grid from '@mui/material/Grid'; -import Stack from '@mui/material/Stack'; -import Switch from '@mui/material/Switch'; -import Typography from '@mui/material/Typography'; -import EditDialog from 'components/dialog/EditDialog'; -import CustomTextField from 'components/fields/CustomTextField'; -import SingleDateField from 'components/fields/SingleDateField'; -import { SurveyAnimalsI18N } from 'constants/i18n'; -import { Field, useFormikContext } from 'formik'; -import { useDialogContext } from 'hooks/useContext'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import { ICaptureResponse } from 'interfaces/useCritterApi.interface'; -import { useState } from 'react'; -import { getLatLngAsUtm, getUtmAsLatLng, PROJECTION_MODE } from 'utils/mapProjectionHelpers'; -import { - AnimalFormProps, - ANIMAL_FORM_MODE, - CreateCritterCaptureSchema, - ICreateCritterCapture, - isRequiredInSchema -} from '../animal'; -import FormLocationPreview from './LocationEntryForm'; - -/** - * This component renders a 'critter capture' create / edit dialog. - * - * Ties into the LocationEntryForm to display capture / release details on map. - * Handles additional conversion of UTM <--> WGS coordinates during edit and submission. - * - * @param {AnimalFormProps} props - Generic AnimalFormProps. - * @returns {*} - */ -export const CaptureAnimalForm = (props: AnimalFormProps) => { - const critterbaseApi = useCritterbaseApi(); - const dialog = useDialogContext(); - - const [loading, setLoading] = useState(false); - const [projectionMode, setProjectionMode] = useState(PROJECTION_MODE.WGS); - - const handleSave = async (values: ICreateCritterCapture) => { - setLoading(true); - try { - if (projectionMode === PROJECTION_MODE.UTM) { - if (values.release_location) { - const [latitude, longitude] = getUtmAsLatLng( - values.release_location.latitude, - values.release_location.longitude - ); - values = { ...values, release_location: { ...values.release_location, latitude, longitude } }; - } - const [latitude, longitude] = getUtmAsLatLng( - values.capture_location.latitude, - values.capture_location.longitude - ); - values = { ...values, capture_location: { ...values.capture_location, latitude, longitude } }; - } - if (props.formMode === ANIMAL_FORM_MODE.ADD) { - await critterbaseApi.capture.createCapture(values); - dialog.setSnackbar({ open: true, snackbarMessage: `Successfully created capture.` }); - } - if (props.formMode === ANIMAL_FORM_MODE.EDIT) { - await critterbaseApi.capture.updateCapture(values); - dialog.setSnackbar({ open: true, snackbarMessage: `Successfully edited capture.` }); - } - } catch (err) { - dialog.setSnackbar({ open: true, snackbarMessage: `Critter capture request failed.` }); - } finally { - props.handleClose(); - setLoading(false); - } - }; - - return ( - - ) - }} - /> - ); -}; - -type CaptureFormProps = Pick, 'formMode'> & { - projectionMode: PROJECTION_MODE; - handleProjection: (projection: PROJECTION_MODE) => void; -}; - -const CaptureFormFields = (props: CaptureFormProps) => { - const { values, setValues, setFieldValue } = useFormikContext(); - - const [showRelease, setShowRelease] = useState(values.release_location); - - const isUtmProjection = props.projectionMode === PROJECTION_MODE.UTM; - - const disableUtmToggle = - !values.capture_location.latitude || - !values.capture_location.longitude || - (showRelease && !values?.release_location?.latitude) || - !values?.release_location?.longitude; - - const handleShowRelease = () => { - /** - * If release is currently showing wipe existing values in release_location. - * - */ - if (showRelease) { - setFieldValue('release_location', undefined); - setShowRelease(false); - return; - } - setValues({ - ...values, - release_location: { latitude: '', longitude: '', coordinate_uncertainty: '', coordinate_uncertainty_unit: 'm' } - }); - setShowRelease(true); - }; - - const handleProjectionChange = () => { - const switchProjection = isUtmProjection ? PROJECTION_MODE.WGS : PROJECTION_MODE.UTM; - - /** - * These projection conversions are expecting non null values for lat/lng. - * UI currently hides the UTM toggle when these values are not defined in the form. - * - */ - const [captureLat, captureLon] = !isUtmProjection - ? getLatLngAsUtm(values.capture_location.latitude, values.capture_location.longitude) - : getUtmAsLatLng(values.capture_location.latitude, values.capture_location.longitude); - - const [releaseLat, releaseLon] = !isUtmProjection - ? getLatLngAsUtm(values.release_location.latitude, values.release_location.longitude) - : getUtmAsLatLng(values.release_location.latitude, values.release_location.longitude); - - setValues({ - ...values, - capture_location: { - ...values.capture_location, - projection_mode: switchProjection, - latitude: captureLat, - longitude: captureLon - }, - release_location: { - ...values.release_location, - projection_mode: switchProjection, - latitude: releaseLat, - longitude: releaseLon - } - }); - - props.handleProjection(switchProjection); - }; - - return ( - - - - Event Dates - } - label="UTM" - /> - - - - - - - - - - - - - Capture Location - - - - - - - - - - - {props.formMode === ANIMAL_FORM_MODE.ADD ? ( - - } - label={SurveyAnimalsI18N.animalCaptureReleaseRadio} - /> - - ) : null} - - - - {showRelease ? ( - - Release Location - - - - - - - - - - - - - ) : null} - - - - Additional Information - - - - ); -}; - -export default CaptureAnimalForm; diff --git a/app/src/features/surveys/view/survey-animals/form-sections/CollectionUnitAnimalForm.test.tsx b/app/src/features/surveys/view/survey-animals/form-sections/CollectionUnitAnimalForm.test.tsx deleted file mode 100644 index 67280fb4ff..0000000000 --- a/app/src/features/surveys/view/survey-animals/form-sections/CollectionUnitAnimalForm.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import { ICritterDetailedResponse } from 'interfaces/useCritterApi.interface'; -import { render, waitFor } from 'test-helpers/test-utils'; -import { ANIMAL_FORM_MODE } from '../animal'; -import CollectionUnitAnimalForm from './CollectionUnitAnimalForm'; - -jest.mock('hooks/useCritterbaseApi'); - -const mockUseCritterbaseApi = useCritterbaseApi as jest.Mock; - -const mockHandleClose = jest.fn(); - -const mockUseCritterbase = { - lookup: { - getSelectOptions: jest.fn() - } -}; - -describe('CollectionUnitForm', () => { - beforeEach(() => { - mockUseCritterbaseApi.mockImplementation(() => mockUseCritterbase); - mockUseCritterbase.lookup.getSelectOptions.mockClear(); - }); - it('should display two form inputs for category and name', async () => { - mockUseCritterbase.lookup.getSelectOptions.mockResolvedValueOnce([ - { id: 'a', value: 'a', label: 'category_label' } - ]); - - const { getByText } = render( - - ); - - await waitFor(() => { - expect(getByText('Category')).toBeInTheDocument(); - expect(getByText('Name')).toBeInTheDocument(); - }); - }); -}); diff --git a/app/src/features/surveys/view/survey-animals/form-sections/CollectionUnitAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/CollectionUnitAnimalForm.tsx deleted file mode 100644 index 71a90f86f8..0000000000 --- a/app/src/features/surveys/view/survey-animals/form-sections/CollectionUnitAnimalForm.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import Grid from '@mui/material/Grid'; -import EditDialog from 'components/dialog/EditDialog'; -import CbSelectField from 'components/fields/CbSelectField'; -import { Field, FieldProps } from 'formik'; -import { useDialogContext } from 'hooks/useContext'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import { ICritterCollectionUnitResponse } from 'interfaces/useCritterApi.interface'; -import { get } from 'lodash-es'; -import { useState } from 'react'; -import { - AnimalFormProps, - ANIMAL_FORM_MODE, - CreateCritterCollectionUnitSchema, - ICreateCritterCollectionUnit, - isRequiredInSchema -} from '../animal'; - -/** - * This component renders a 'critter collection unit' create / edit dialog. - * - * @param {AnimalFormProps} props - Generic AnimalFormProps. - * @returns {*} - */ -export const CollectionUnitAnimalForm = (props: AnimalFormProps) => { - const critterbaseApi = useCritterbaseApi(); - const dialog = useDialogContext(); - //Animals may have multiple collection units, but only one instance of each category. - //We use this and pass to the select component to ensure categories already used in the form can't be selected again. - const disabledCategories = props.critter.collection_units.reduce((acc: Record, curr) => { - if (curr.collection_category_id) { - acc[curr.collection_category_id] = true; - } - return acc; - }, {}); - - const [loading, setLoading] = useState(false); - - const handleSave = async (values: ICreateCritterCollectionUnit) => { - setLoading(true); - try { - if (props.formMode === ANIMAL_FORM_MODE.ADD) { - await critterbaseApi.collectionUnit.createCritterCollectionUnit(values); - dialog.setSnackbar({ open: true, snackbarMessage: `Successfully created ecological unit.` }); - } - if (props.formMode === ANIMAL_FORM_MODE.EDIT) { - await critterbaseApi.collectionUnit.updateCritterCollectionUnit(values); - dialog.setSnackbar({ open: true, snackbarMessage: `Successfully edited ecological unit.` }); - } - } catch (err) { - dialog.setSnackbar({ open: true, snackbarMessage: `Critter ecological unit request failed.` }); - } finally { - props.handleClose(); - setLoading(false); - } - }; - - return ( - - - - - - - {({ form }: FieldProps) => ( - - )} - - -
- ) - }} - /> - ); -}; - -export default CollectionUnitAnimalForm; diff --git a/app/src/features/surveys/view/survey-animals/form-sections/FamilyAnimalForm.test.tsx b/app/src/features/surveys/view/survey-animals/form-sections/FamilyAnimalForm.test.tsx deleted file mode 100644 index 533f37f741..0000000000 --- a/app/src/features/surveys/view/survey-animals/form-sections/FamilyAnimalForm.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import { ICritterDetailedResponse } from 'interfaces/useCritterApi.interface'; -import { render, waitFor } from 'test-helpers/test-utils'; -import { ANIMAL_FORM_MODE } from '../animal'; -import FamilyAnimalForm from './FamilyAnimalForm'; - -jest.mock('hooks/useCritterbaseApi'); - -const mockUseCritterbaseApi = useCritterbaseApi as jest.Mock; -const mockHandleClose = jest.fn(); - -const mockUseCritterbase = { - lookup: { - getSelectOptions: jest.fn() - }, - family: { - getAllFamilies: jest.fn() - } -}; - -describe('FamilyAnimalForm', () => { - beforeEach(() => { - mockUseCritterbaseApi.mockImplementation(() => mockUseCritterbase); - mockUseCritterbase.lookup.getSelectOptions.mockClear(); - mockUseCritterbase.family.getAllFamilies.mockClear(); - }); - it('should display both inputs for family form section', async () => { - mockUseCritterbase.lookup.getSelectOptions.mockResolvedValueOnce([{ id: 'a', value: 'a', label: 'family_1' }]); - mockUseCritterbase.family.getAllFamilies.mockResolvedValueOnce([{ family_id: 'a', family_label: 'family_1' }]); - const { getByText } = render( - - ); - - await waitFor(() => { - expect(getByText(/add family relationship/i)).toBeInTheDocument(); - expect(getByText(/child in/i)).toBeInTheDocument(); - }); - }); -}); diff --git a/app/src/features/surveys/view/survey-animals/form-sections/FamilyAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/FamilyAnimalForm.tsx deleted file mode 100644 index 00f32bc50b..0000000000 --- a/app/src/features/surveys/view/survey-animals/form-sections/FamilyAnimalForm.tsx +++ /dev/null @@ -1,269 +0,0 @@ -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Checkbox from '@mui/material/Checkbox'; -import { grey } from '@mui/material/colors'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import Grid from '@mui/material/Grid'; -import MenuItem from '@mui/material/MenuItem'; -import Paper from '@mui/material/Paper'; -import Typography from '@mui/material/Typography'; -import ComponentDialog from 'components/dialog/ComponentDialog'; -import EditDialog from 'components/dialog/EditDialog'; -import { CbSelectWrapper } from 'components/fields/CbSelectFieldWrapper'; -import CustomTextField from 'components/fields/CustomTextField'; -import { useDialogContext } from 'hooks/useContext'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import useDataLoader from 'hooks/useDataLoader'; -import { IFamilyChildResponse, IFamilyParentResponse } from 'interfaces/useCritterApi.interface'; -import { useState } from 'react'; -import { - AnimalFormProps, - AnimalRelationship, - ANIMAL_FORM_MODE, - CreateCritterFamilySchema, - ICreateCritterFamily, - isRequiredInSchema -} from '../animal'; - -/** - * This component renders a 'critter family relationship' create / edit dialog. - * - * @param {AnimalFormProps} props - Generic AnimalFormProps. - * @returns {*} - */ -export const FamilyAnimalForm = (props: AnimalFormProps) => { - const critterbaseApi = useCritterbaseApi(); - const dialog = useDialogContext(); - - const [showFamilyStructure, setShowFamilyStructure] = useState(false); - const [createNewFamily, setCreateNewFamily] = useState(false); - const [loading, setLoading] = useState(false); - - const { data: familyHierarchy, load: loadHierarchy } = useDataLoader(critterbaseApi.family.getImmediateFamily); - const { - data: allFamilies, - load: loadFamilies, - refresh: refreshFamilies - } = useDataLoader(critterbaseApi.family.getAllFamilies); - - loadFamilies(); - - const initialValues = { - critter_id: props.critter.critter_id, - family_id: props?.formObject?.family_id, - family_label: props?.formObject?.family_label ?? '', - relationship: (props?.formObject as IFamilyParentResponse)?.parent_critter_id - ? AnimalRelationship.PARENT - : AnimalRelationship.CHILD - }; - - const handleSave = async (values: ICreateCritterFamily) => { - setLoading(true); - try { - if (props.formMode === ANIMAL_FORM_MODE.ADD) { - if (values.family_label) { - const family = await critterbaseApi.family.createFamily(values.family_label); - values.family_id = family.family_id; - } - await critterbaseApi.family.createFamilyRelationship(values); - - dialog.setSnackbar({ open: true, snackbarMessage: `Successfully created family relationship.` }); - } - if (props.formMode === ANIMAL_FORM_MODE.EDIT) { - if (!values.family_id || !initialValues.family_id) { - throw new Error(`family_id should be defined`); - } - - if (values.family_label) { - await critterbaseApi.family.editFamily(initialValues.family_id, values.family_label); - } - - await critterbaseApi.family.deleteRelationship({ - family_id: initialValues.family_id, - critter_id: initialValues.critter_id, - relationship: initialValues.relationship - }); - - await critterbaseApi.family.createFamilyRelationship({ - family_id: values.family_id, - critter_id: values.critter_id, - relationship: values.relationship - }); - - dialog.setSnackbar({ open: true, snackbarMessage: `Successfully edited family relationship.` }); - } - } catch (err) { - dialog.setSnackbar({ open: true, snackbarMessage: `Critter family relationship request failed.` }); - } finally { - refreshFamilies(); - props.handleClose(); - setLoading(false); - } - }; - - return ( - - - setCreateNewFamily((create) => !create)} - /> - } - label={ - props.formMode === ANIMAL_FORM_MODE.ADD - ? 'Would you like to create a new critter family?' - : 'Would you like to modify the family label?' - } - /> - - {createNewFamily ? ( - - - - ) : ( - - - {allFamilies?.map((family) => ( - - {family.family_label ? family.family_label : family.family_id} - - ))} - - - )} - - - - - Parent in - - - Child in - - - - - - {props?.formObject?.family_id ? ( - - ) : null} - - setShowFamilyStructure(false)}> - - - Family ID - - - {props?.formObject?.family_id} - - - Parents: -
    - {familyHierarchy?.parents.map((a) => ( -
  • - - - - Critter ID - - {a.critter_id} - - - - Animal ID - - {a.animal_id} - - -
  • - ))} -
-
- - Children: -
    - {familyHierarchy?.children.map((critter) => ( -
  • - - - - Critter ID - - {critter.critter_id} - - - - Animal ID - - {critter.animal_id} - - -
  • - ))} -
-
-
-
- - ) - }} - /> - ); -}; - -export default FamilyAnimalForm; diff --git a/app/src/features/surveys/view/survey-animals/form-sections/GeneralAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/GeneralAnimalForm.tsx deleted file mode 100644 index 69f4ccb5be..0000000000 --- a/app/src/features/surveys/view/survey-animals/form-sections/GeneralAnimalForm.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import Grid from '@mui/material/Grid'; -import HelpButtonTooltip from 'components/buttons/HelpButtonTooltip'; -import EditDialog from 'components/dialog/EditDialog'; -import CbSelectField from 'components/fields/CbSelectField'; -import CustomTextField from 'components/fields/CustomTextField'; -import { SpeciesAutoCompleteFormikField } from 'components/species/components/SpeciesAutoCompleteFormikField'; -import { SurveyAnimalsI18N } from 'constants/i18n'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { useDialogContext } from 'hooks/useContext'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import { ICritterDetailedResponse, ICritterSimpleResponse } from 'interfaces/useCritterApi.interface'; -import { useState } from 'react'; -import { v4 } from 'uuid'; -import { AnimalSex, ANIMAL_FORM_MODE, CreateCritterSchema, ICreateCritter, isRequiredInSchema } from '../animal'; - -export type GeneralAnimalFormProps = - | { - formObject?: never; - formMode: ANIMAL_FORM_MODE.ADD; - open: boolean; - handleClose: () => void; - critter?: never; - projectId: number; - surveyId: number; - } - | { - formObject: T; - formMode: ANIMAL_FORM_MODE.EDIT; - open: boolean; - handleClose: () => void; - critter: ICritterDetailedResponse | ICritterSimpleResponse; - projectId?: never; - surveyId?: never; - }; - -/** - * This component renders a 'critter' create / edit dialog. - * Handles the basic properties of a Critterbase critter. - * - * @param {GeneralAnimalFormProps} props - * @returns {*} - */ -const GeneralAnimalForm = (props: GeneralAnimalFormProps) => { - const critterbaseApi = useCritterbaseApi(); - const biohubApi = useBiohubApi(); - const dialog = useDialogContext(); - - const [loading, setLoading] = useState(false); - - const handleSave = async (values: ICreateCritter) => { - setLoading(true); - try { - if (props.formMode === ANIMAL_FORM_MODE.ADD) { - await biohubApi.survey.createCritterAndAddToSurvey(props.projectId, props.surveyId, values); - dialog.setSnackbar({ open: true, snackbarMessage: `Successfully created critter.` }); - } - if (props.formMode === ANIMAL_FORM_MODE.EDIT) { - await critterbaseApi.critters.updateCritter(values); - dialog.setSnackbar({ open: true, snackbarMessage: `Successfully edited critter.` }); - } - } catch (err) { - dialog.setSnackbar({ open: true, snackbarMessage: `Critter request failed.` }); - } finally { - props.handleClose(); - setLoading(false); - } - }; - - return ( - - - - - - - - - - - - - - - - - - - - - ) - }}> - ); -}; - -export default GeneralAnimalForm; diff --git a/app/src/features/surveys/view/survey-animals/form-sections/LocationEntryForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/LocationEntryForm.tsx deleted file mode 100644 index b87903628f..0000000000 --- a/app/src/features/surveys/view/survey-animals/form-sections/LocationEntryForm.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import Box from '@mui/material/Box'; -import Paper from '@mui/material/Paper'; -import Stack from '@mui/material/Stack'; -import ToggleButton from '@mui/material/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; -import Typography from '@mui/material/Typography'; -import AdditionalLayers from 'components/map/components/AdditionalLayers'; -import BaseLayerControls from 'components/map/components/BaseLayerControls'; -import { MapBaseCss } from 'components/map/components/MapBaseCss'; -import { MarkerIconColor, MarkerWithResizableRadius } from 'components/map/components/MarkerWithResizableRadius'; -import { MAP_DEFAULT_CENTER, MAP_DEFAULT_ZOOM } from 'constants/spatial'; -import { useFormikContext } from 'formik'; -import { LatLng } from 'leaflet'; -import { get } from 'lodash-es'; -import { useState } from 'react'; -import { LayersControl, MapContainer as LeafletMapContainer } from 'react-leaflet'; -import { getLatLngAsUtm, getUtmAsLatLng, PROJECTION_MODE } from 'utils/mapProjectionHelpers'; - -export interface IFormLocation { - title: string; - pingColour: MarkerIconColor; - fields: { - latitude: keyof T; - longitude: keyof T; - }; -} - -type FormLocationsPreviewProps = { - projection?: PROJECTION_MODE; - locations: IFormLocation[]; -}; - -export const FormLocationPreview = ({ - projection = PROJECTION_MODE.WGS, - locations -}: FormLocationsPreviewProps) => { - const { setFieldValue, values } = useFormikContext(); - - const [markerToggle, setMarkerToggle] = useState(null); - - const handleSetMarkerLocation = (coords: LatLng) => { - if (markerToggle === null) { - return; - } - let latitude = coords.lat; - let longitude = coords.lng; - - if (projection === PROJECTION_MODE.UTM && latitude && longitude) { - [latitude, longitude] = getLatLngAsUtm(latitude, longitude); - } - - setFieldValue(locations[markerToggle].fields.latitude as string, Number(latitude.toFixed(5))); - setFieldValue(locations[markerToggle].fields.longitude as string, Number(longitude.toFixed(5))); - - setMarkerToggle(null); - }; - - const renderMarker = (location: IFormLocation) => { - let latitude = get(values, location.fields.latitude); - let longitude = get(values, location.fields.longitude); - - if (projection === PROJECTION_MODE.UTM && latitude && longitude) { - [latitude, longitude] = getUtmAsLatLng(latitude, longitude); - } - - // Marking positions can be different than the fields if the projection is UTM. - const renderPosition = latitude && longitude ? new LatLng(latitude, longitude) : undefined; - - return ( - - ); - }; - - return ( - - - Location Preview - setMarkerToggle(value)} exclusive> - {locations.map((location, idx) => ( - - {`Set ${location.title} Location`} - - ))} - - - - - renderMarker(location))} /> - - - - - - - - ); -}; - -export default FormLocationPreview; diff --git a/app/src/features/surveys/view/survey-animals/form-sections/MarkingAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/MarkingAnimalForm.tsx deleted file mode 100644 index 5e7e37848f..0000000000 --- a/app/src/features/surveys/view/survey-animals/form-sections/MarkingAnimalForm.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import Grid from '@mui/material/Grid'; -import EditDialog from 'components/dialog/EditDialog'; -import CbSelectField from 'components/fields/CbSelectField'; -import CustomTextField from 'components/fields/CustomTextField'; -import FormikDevDebugger from 'components/formik/FormikDevDebugger'; -import { useDialogContext } from 'hooks/useContext'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import { IMarkingResponse } from 'interfaces/useCritterApi.interface'; -import { useState } from 'react'; -import { - AnimalFormProps, - ANIMAL_FORM_MODE, - CreateCritterMarkingSchema, - ICreateCritterMarking, - isRequiredInSchema -} from '../animal'; - -/** - * This component renders a 'critter marking' create / edit dialog. - * - * @param {AnimalFormProps} props - Generic AnimalFormProps. - * @returns {*} - */ -export const MarkingAnimalForm = (props: AnimalFormProps) => { - const critterbaseApi = useCritterbaseApi(); - const dialog = useDialogContext(); - - const [loading, setLoading] = useState(false); - - const handleSave = async (values: ICreateCritterMarking) => { - setLoading(true); - try { - if (props.formMode === ANIMAL_FORM_MODE.ADD) { - await critterbaseApi.marking.createMarking(values); - dialog.setSnackbar({ open: true, snackbarMessage: `Successfully created marking.` }); - } - if (props.formMode === ANIMAL_FORM_MODE.EDIT) { - await critterbaseApi.marking.updateMarking(values); - dialog.setSnackbar({ open: true, snackbarMessage: `Successfully edited marking.` }); - } - } catch (err) { - dialog.setSnackbar({ open: true, snackbarMessage: `Critter marking request failed.` }); - } finally { - props.handleClose(); - setLoading(false); - } - }; - - return ( - - - - - - - - - - - - - - - - - - - ) - }} - /> - ); -}; diff --git a/app/src/features/surveys/view/survey-animals/form-sections/MeasurementAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/MeasurementAnimalForm.tsx deleted file mode 100644 index efe9b4f4dd..0000000000 --- a/app/src/features/surveys/view/survey-animals/form-sections/MeasurementAnimalForm.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import Grid from '@mui/material/Grid'; -import MenuItem from '@mui/material/MenuItem'; -import EditDialog from 'components/dialog/EditDialog'; -import { CbSelectWrapper } from 'components/fields/CbSelectFieldWrapper'; -import CustomTextField from 'components/fields/CustomTextField'; -import SingleDateField from 'components/fields/SingleDateField'; -import { Field } from 'formik'; -import { useDialogContext } from 'hooks/useContext'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import useDataLoader from 'hooks/useDataLoader'; -import { - CBMeasurementType, - CBQualitativeMeasurementTypeDefinition, - CBQuantitativeMeasurementTypeDefinition, - IQualitativeMeasurementResponse, - IQuantitativeMeasurementResponse -} from 'interfaces/useCritterApi.interface'; -import { has, startCase } from 'lodash-es'; -import { useEffect, useMemo, useState } from 'react'; -import { - AnimalFormProps, - ANIMAL_FORM_MODE, - CreateCritterMeasurementSchema, - ICreateCritterMeasurement, - isRequiredInSchema -} from '../animal'; - -/** - * This component renders a 'critter measurement' create / edit dialog. - * - * @param {AnimalFormProps} props - Generic AnimalFormProps. - * @returns {*} - */ -export const MeasurementAnimalForm = ( - props: AnimalFormProps -) => { - const critterbaseApi = useCritterbaseApi(); - const dialog = useDialogContext(); - - const [loading, setLoading] = useState(false); - const [measurementTypeDef, setMeasurementTypeDef] = useState(); - - const { data: measurements, load: loadMeasurements } = useDataLoader(() => - critterbaseApi.xref.getTaxonMeasurements(props.critter.itis_tsn) - ); - loadMeasurements(); - - const isQualitative = has(measurementTypeDef, 'options'); - - const measurementDefs = useMemo(() => { - return measurements ? [...measurements.qualitative, ...measurements.quantitative] : []; - }, [measurements]); - - useEffect(() => { - const foundMeasurementDef = measurementDefs.find( - (measurement) => measurement.taxon_measurement_id === props.formObject?.taxon_measurement_id - ); - setMeasurementTypeDef(foundMeasurementDef); - }, [measurementDefs, props?.formObject?.taxon_measurement_id]); - - const handleSave = async (values: ICreateCritterMeasurement) => { - setLoading(true); - try { - if (isQualitative) { - delete values.measurement_quantitative_id; - delete values.value; - - props.formMode === ANIMAL_FORM_MODE.ADD - ? await critterbaseApi.measurement.createQualitativeMeasurement(values) - : await critterbaseApi.measurement.updateQualitativeMeasurement(values); - } else { - delete values.measurement_qualitative_id; - delete values.qualitative_option_id; - values = { ...values, value: Number(values.value) }; - - props.formMode === ANIMAL_FORM_MODE.ADD - ? await critterbaseApi.measurement.createQuantitativeMeasurement(values) - : await critterbaseApi.measurement.updateQuantitativeMeasurement(values); - } - dialog.setSnackbar({ open: true, snackbarMessage: `Successfully created measurement.` }); - } catch (err) { - dialog.setSnackbar({ open: true, snackbarMessage: `Critter measurement request failed.` }); - } finally { - props.handleClose(); - setLoading(false); - } - }; - - const validateQuantitativeMeasurement = async (val: '' | number) => { - const quantitativeTypeDef = measurementTypeDef as CBQuantitativeMeasurementTypeDefinition; - const min = quantitativeTypeDef?.min_value ?? 0; - const max = quantitativeTypeDef?.max_value; - const unit = quantitativeTypeDef?.unit ? quantitativeTypeDef.unit : ``; - if (val === '') { - return; - } - if (isNaN(val)) { - return `Must be a number`; - } - if (val < min) { - return `Measurement must be greater than ${min}${unit}`; - } - if (max && val > max) { - return `Measurement must be less than ${max}${unit}`; - } - }; - - return ( - - - - {measurementDefs?.map((measurementDef) => ( - setMeasurementTypeDef(measurementDef)}> - {startCase(measurementDef.measurement_name)} - - ))} - - - - {isQualitative ? ( - - {(measurementTypeDef as CBQualitativeMeasurementTypeDefinition)?.options?.map((qualitativeOption) => ( - - {startCase(qualitativeOption.option_label)} - - ))} - - ) : ( - - )} - - - - - - - - - ) - }} - /> - ); -}; - -export default MeasurementAnimalForm; diff --git a/app/src/features/surveys/view/survey-animals/form-sections/MortalityAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/MortalityAnimalForm.tsx deleted file mode 100644 index d55769a440..0000000000 --- a/app/src/features/surveys/view/survey-animals/form-sections/MortalityAnimalForm.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import Box from '@mui/material/Box'; -import Grid from '@mui/material/Grid'; -import Stack from '@mui/material/Stack'; -import Typography from '@mui/material/Typography'; -import EditDialog from 'components/dialog/EditDialog'; -import CbSelectField from 'components/fields/CbSelectField'; -import CustomTextField from 'components/fields/CustomTextField'; -import SingleDateField from 'components/fields/SingleDateField'; -import { SpeciesAutoCompleteFormikField } from 'components/species/components/SpeciesAutoCompleteFormikField'; -import { Field, useFormikContext } from 'formik'; -import { useDialogContext } from 'hooks/useContext'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import { IMortalityResponse } from 'interfaces/useCritterApi.interface'; -import { mapValues } from 'lodash-es'; -import { useState } from 'react'; -import { - AnimalFormProps, - ANIMAL_FORM_MODE, - CreateCritterMortalitySchema, - ICreateCritterMortality, - isRequiredInSchema -} from '../animal'; -import FormLocationPreview from './LocationEntryForm'; - -/** - * This component renders a 'critter mortality' create / edit dialog. - * - * @param {AnimalFormProps} props - Generic AnimalFormProps. - * @returns {*} - */ -const MortalityAnimalForm = (props: AnimalFormProps) => { - const critterbaseApi = useCritterbaseApi(); - const dialog = useDialogContext(); - - const [loading, setLoading] = useState(false); - - const handleSave = async (values: ICreateCritterMortality) => { - setLoading(true); - // Replaces empty strings with null values. - const patchedValues = mapValues(values, (value) => (value === '' ? null : value)); - - try { - if (props.formMode === ANIMAL_FORM_MODE.ADD) { - await critterbaseApi.mortality.createMortality(patchedValues); - dialog.setSnackbar({ open: true, snackbarMessage: `Successfully created mortality.` }); - } - if (props.formMode === ANIMAL_FORM_MODE.EDIT) { - await critterbaseApi.mortality.updateMortality(patchedValues); - dialog.setSnackbar({ open: true, snackbarMessage: `Successfully edited mortality.` }); - } - } catch (err) { - dialog.setSnackbar({ open: true, snackbarMessage: `Critter mortality request failed.` }); - } finally { - props.handleClose(); - setLoading(false); - } - }; - - return ( - - }} - /> - ); -}; - -/** - * This component renders the 'critter mortality' form fields. - * Nested inside MortalityAnimalForm to use the formikContext hook. - * - * @param {Pick, 'formObject'>} props - IMortalityResponse. - * @returns {*} - */ -const MortalityForm = (props: Pick, 'formObject'>) => { - const { setFieldValue } = useFormikContext(); - - const proximateTsn = props.formObject?.proximate_predated_by_itis_tsn; - const ultimateTsn = props.formObject?.ultimate_predated_by_itis_tsn; - - const [pcodTaxonDisabled, setPcodTaxonDisabled] = useState(!proximateTsn); //Controls whether you can select taxons from the PCOD Taxon dropdown. - const [ucodTaxonDisabled, setUcodTaxonDisabled] = useState(!ultimateTsn); //Controls whether you can select taxons from the UCOD Taxon dropdown. - - const handleCauseOfDeathReasonChange = (label: string, isProximateCOD: boolean) => { - const isDisabled = !label.includes('Predation'); - if (isProximateCOD) { - setPcodTaxonDisabled(isDisabled); - } else { - setUcodTaxonDisabled(isDisabled); - } - - if (isDisabled) { - setFieldValue('proximate_predated_by_itis_tsn', '', true); - } else { - setFieldValue('ultimate_predated_by_itis_tsn', '', true); - } - }; - return ( - - - Date of Event - - - - - Proximate Cause of Death - - - handleCauseOfDeathReasonChange(label, true)} - orderBy={'asc'} - label={'Reason'} - controlProps={{ - required: isRequiredInSchema(CreateCritterMortalitySchema, 'proximate_cause_of_death_id') - }} - id={`pcod-reason`} - route={'lookups/cods'} - /> - - - - - - - - - - - - Ultimate Cause of Death - - - { - handleCauseOfDeathReasonChange(label, false); - }} - label={'Reason'} - controlProps={{ - required: isRequiredInSchema(CreateCritterMortalitySchema, 'ultimate_cause_of_death_id') - }} - id={`ucod-reason`} - route={'lookups/cods'} - /> - - - - - - - - - - - - Mortality Location - - - - - - - - - - - - - - - - - Additional Details - - - - ); -}; - -export default MortalityAnimalForm; diff --git a/app/src/features/surveys/view/survey-animals/telemetry-device/DeploymentForm.tsx b/app/src/features/surveys/view/survey-animals/telemetry-device/DeploymentForm.tsx deleted file mode 100644 index c24595e6d0..0000000000 --- a/app/src/features/surveys/view/survey-animals/telemetry-device/DeploymentForm.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { mdiTrashCanOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import Grid from '@mui/material/Grid'; -import IconButton from '@mui/material/IconButton'; -import YesNoDialog from 'components/dialog/YesNoDialog'; -import SingleDateField from 'components/fields/SingleDateField'; -import { DialogContext } from 'contexts/dialogContext'; -import { SurveyContext } from 'contexts/surveyContext'; -import { Field, useFormikContext } from 'formik'; -import { IGetDeviceDetailsResponse } from 'hooks/telemetry/useDeviceApi'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { useQuery } from 'hooks/useQuery'; -import { Fragment, useContext, useState } from 'react'; -import { dateRangesOverlap, setMessageSnackbar } from 'utils/Utils'; -import { ANIMAL_FORM_MODE } from '../animal'; -import { IAnimalTelemetryDevice, IDeploymentTimespan } from './device'; - -interface DeploymentFormSectionProps { - mode: ANIMAL_FORM_MODE; - deviceDetails?: IGetDeviceDetailsResponse; -} - -export const DeploymentForm = (props: DeploymentFormSectionProps): JSX.Element => { - const { mode, deviceDetails } = props; - - const biohubApi = useBiohubApi(); - const { critter_id: survey_critter_id } = useQuery(); - const { values, validateField } = useFormikContext(); - const { surveyId, projectId } = useContext(SurveyContext); - const dialogContext = useContext(DialogContext); - - const [deploymentIDToDelete, setDeploymentIDToDelete] = useState(null); - - const device = values; - const deployments = device.deployments; - - const handleRemoveDeployment = async (deployment_id: string) => { - try { - if (survey_critter_id === undefined) { - setMessageSnackbar('No critter set!', dialogContext); - } - await biohubApi.survey.removeDeployment(projectId, surveyId, Number(survey_critter_id), deployment_id); - const indexOfDeployment = deployments?.findIndex((deployment) => deployment.deployment_id === deployment_id); - if (indexOfDeployment !== undefined) { - deployments?.splice(indexOfDeployment); - } - setMessageSnackbar('Deployment deleted', dialogContext); - } catch (e) { - setMessageSnackbar('Failed to delete deployment.', dialogContext); - } - }; - - const deploymentOverlapTest = (deployment: IDeploymentTimespan) => { - if (!deviceDetails) { - return; - } - - if (!deployment.attachment_start) { - return; - } - const existingDeployment = deviceDetails.deployments.find( - (existingDeployment) => - deployment.deployment_id !== existingDeployment.deployment_id && - dateRangesOverlap( - deployment.attachment_start, - deployment.attachment_end, - existingDeployment.attachment_start, - existingDeployment.attachment_end - ) - ); - if (!existingDeployment) { - return; - } - return `This will conflict with an existing deployment for the device running from ${ - existingDeployment.attachment_start - } until ${existingDeployment.attachment_end ?? 'indefinite.'}`; - }; - - return ( - <> - - {deployments?.map((deploy, i) => { - return ( - - - validateField(`deployments.${i}.attachment_start`) }} - validate={() => deploymentOverlapTest(deploy)} - /> - - - validateField(`deployments.${i}.attachment_end`) }} - validate={() => deploymentOverlapTest(deploy)} - /> - - {mode === ANIMAL_FORM_MODE.EDIT && ( - - { - setDeploymentIDToDelete(String(deploy.deployment_id)); - }}> - - - - )} - - ); - })} - - - {/* Delete Dialog */} - setDeploymentIDToDelete(null)} - onNo={() => setDeploymentIDToDelete(null)} - onYes={async () => { - if (deploymentIDToDelete) { - await handleRemoveDeployment(deploymentIDToDelete); - } - setDeploymentIDToDelete(null); - }} - /> - - ); -}; diff --git a/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryDeviceForm.tsx b/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryDeviceForm.tsx deleted file mode 100644 index f7ef3f7a11..0000000000 --- a/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryDeviceForm.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import Box from '@mui/material/Box'; -import Grid from '@mui/material/Grid'; -import Typography from '@mui/material/Typography'; -import CustomTextField from 'components/fields/CustomTextField'; -import TelemetrySelectField from 'components/fields/TelemetrySelectField'; -import FormikDevDebugger from 'components/formik/FormikDevDebugger'; -import { AttachmentType } from 'constants/attachments'; -import { Field, useFormikContext } from 'formik'; -import useDataLoader from 'hooks/useDataLoader'; -import { useTelemetryApi } from 'hooks/useTelemetryApi'; -import { useEffect } from 'react'; -import { ANIMAL_FORM_MODE } from '../animal'; -import { DeploymentForm } from './DeploymentForm'; -import { IAnimalTelemetryDevice } from './device'; -import TelemetryFileUpload from './TelemetryFileUpload'; - -export interface ITelemetryDeviceFormProps { - mode: ANIMAL_FORM_MODE; -} - -const TelemetryDeviceForm = (props: ITelemetryDeviceFormProps) => { - const { mode } = props; - - const telemetryApi = useTelemetryApi(); - const { values: device } = useFormikContext(); - - const { data: deviceDetails, refresh, isReady } = useDataLoader(telemetryApi.devices.getDeviceDetails); - - const canRenderFileUpload = - isReady && ((device.device_make === 'Vectronic' && !deviceDetails?.keyXStatus) || device.device_make === 'Lotek'); - - useEffect(() => { - if (device.device_id && device.device_make) { - refresh(device.device_id, device.device_make); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [device.device_id, device.device_make]); - - if (!device) { - return <>; - } - - return ( - <> - - - Device Metadata - - - - - - - - - - - - { - const codeVals = await telemetryApi.devices.getCodeValues('frequency_unit'); - return codeVals.map((a) => a.description); - }} - /> - - - - - - - - - - - - {canRenderFileUpload && ( - - - Upload Attachment - - {device.device_make === 'Vectronic' && ( - <> - {`Vectronic KeyX File (Optional)`} - - - )} - {device.device_make === 'Lotek' && ( - <> - {`Lotek Config File (Optional)`} - - - )} - - )} - - - Deployments - - - - - - ); -}; - -export default TelemetryDeviceForm; diff --git a/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryFileUpload.test.tsx b/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryFileUpload.test.tsx deleted file mode 100644 index 03d286c94c..0000000000 --- a/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryFileUpload.test.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { AttachmentType } from 'constants/attachments'; -import { ConfigContext, IConfig } from 'contexts/configContext'; -import { Formik } from 'formik'; -import { render } from 'test-helpers/test-utils'; -import TelemetryFileUpload from './TelemetryFileUpload'; - -describe('TelemetryFileUpload component', () => { - it('should render with correct props', async () => { - const { getByTestId } = render( - - - - - - ); - - const fileUploadComponent = getByTestId('drop-zone-input'); - expect(fileUploadComponent).toBeInTheDocument(); - }); -}); diff --git a/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryFileUpload.tsx b/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryFileUpload.tsx deleted file mode 100644 index 845bf0562c..0000000000 --- a/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryFileUpload.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import FileUpload, { IReplaceHandler } from 'components/file-upload/FileUpload'; -import { IFileHandler, UploadFileStatus } from 'components/file-upload/FileUploadItem'; -import { AttachmentType, ProjectSurveyAttachmentValidExtensions } from 'constants/attachments'; -import { useFormikContext } from 'formik'; -import React from 'react'; -import { IAnimalTelemetryDeviceFile } from './device'; - -export const TelemetryFileUpload: React.FC<{ attachmentType: AttachmentType; fileKey: string; typeKey: string }> = ( - props -) => { - const { setFieldValue } = useFormikContext<{ formValues: IAnimalTelemetryDeviceFile[] }>(); - const fileHandler: IFileHandler = (file) => { - setFieldValue(props.fileKey, file); - setFieldValue(props.typeKey, props.attachmentType); - }; - - const replaceHandler: IReplaceHandler = () => { - setFieldValue(props.fileKey, null); - setFieldValue(props.typeKey, props.attachmentType); - }; - - return ( - - ); -}; - -export default TelemetryFileUpload; diff --git a/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryMap.tsx b/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryMap.tsx deleted file mode 100644 index 5da5ef7adf..0000000000 --- a/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryMap.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import Box from '@mui/material/Box'; -import Paper from '@mui/material/Paper'; -import Typography from '@mui/material/Typography'; -import AdditionalLayers from 'components/map/components/AdditionalLayers'; -import BaseLayerControls from 'components/map/components/BaseLayerControls'; -import { SetMapBounds } from 'components/map/components/Bounds'; -import { MapBaseCss } from 'components/map/styles/MapBaseCss'; -import { MAP_DEFAULT_CENTER, MAP_DEFAULT_ZOOM } from 'constants/spatial'; -import { default as dayjs } from 'dayjs'; -import { Feature } from 'geojson'; -import L, { LatLng } from 'leaflet'; -import { useMemo, useState } from 'react'; -import { GeoJSON, LayersControl, MapContainer as LeafletMapContainer } from 'react-leaflet'; -import { uuidToColor } from 'utils/Utils'; -import { v4 } from 'uuid'; -import { IAnimalDeployment, ITelemetryPointCollection } from './device'; - -interface ITelemetryMapProps { - telemetryData?: ITelemetryPointCollection; - deploymentData?: IAnimalDeployment[]; -} - -type ColourDeployment = IAnimalDeployment & { colour: string; fillColour: string }; - -interface ILegend { - hasData: boolean; - colourMap: ColourDeployment[]; -} - -const Legend = ({ hasData, colourMap }: ILegend) => { - return ( -
-
- - {hasData ? ( - colourMap.map((deploymentAndColour) => ( - - - {`Device ID: ${deploymentAndColour.device_id}, deployed from ${dayjs( - deploymentAndColour.attachment_start - ).format('DD-MM-YYYY')} to ${ - deploymentAndColour.attachment_end - ? dayjs(deploymentAndColour.attachment_end).format('DD-MM-YYYY') - : 'indefinite' - }`} - - )) - ) : ( - {`No telemetry available for this animal's deployment(s).`} - )} - -
-
- ); -}; - -const TelemetryMap = ({ deploymentData, telemetryData }: ITelemetryMapProps): JSX.Element => { - const [legendColours, setLegendColours] = useState([]); - - const features = useMemo(() => { - const featureCollections = telemetryData; - if (!featureCollections || !deploymentData) { - return []; - } - const result: Feature[] = []; - const colourMap: Record = {}; - const fillColourMap: Record = {}; - const legendColours: ColourDeployment[] = []; - deploymentData.forEach((deployment) => { - const { fillColor, outlineColor } = uuidToColor(deployment.deployment_id); - fillColourMap[deployment.deployment_id] = fillColor; - colourMap[deployment.deployment_id] = outlineColor; - legendColours.push({ ...deployment, colour: fillColor, fillColour: outlineColor }); - }); - setLegendColours(legendColours); - for (const featureCollection of Object.values(featureCollections)) { - for (const feature of featureCollection.features) { - if (!feature.properties) { - feature.properties = {}; - } - feature.properties.colour = colourMap[feature.properties.deployment_id]; - feature.properties.fillColour = fillColourMap[feature.properties.deployment_id]; - result.push(feature); - } - } - result.sort((a, b) => a.geometry.type.localeCompare(b.geometry.type)); - return result; - }, [telemetryData, deploymentData]); - - const mapBounds = useMemo(() => { - const bounds = new L.LatLngBounds([]); - telemetryData?.points.features.forEach((feature) => { - if (feature.geometry.type === 'Point') { - const [lon, lat] = feature.geometry.coordinates; - if (lon > -140 && lon < -110 && lat > 45 && lat < 60) { - //We filter points that are clearly bad values out of the map bounds so that we don't wind up too zoomed out due to one wrong point. - //These values are still present on the map if you move around though. - bounds.extend([lat, lon]); - } - } - }); - if (bounds.isValid()) { - return bounds; - } else { - return undefined; - } - }, [telemetryData?.points.features]); - - const point = (_feature: Feature, latlng: LatLng) => { - return new L.CircleMarker(latlng, { radius: 5, fillOpacity: 1 }); - }; - - return ( - - - - ( - - )), - 0} colourMap={legendColours} /> - ]} - /> - - - - - ); -}; - -export default TelemetryMap; diff --git a/app/src/features/surveys/view/survey-animals/telemetry-device/device.ts b/app/src/features/surveys/view/survey-animals/telemetry-device/device.ts deleted file mode 100644 index 19360b0ad7..0000000000 --- a/app/src/features/surveys/view/survey-animals/telemetry-device/device.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { AttachmentType } from 'constants/attachments'; -import { FeatureCollection } from 'geojson'; -import yup from 'utils/YupSchema'; -import { InferType } from 'yup'; - -export type IAnimalDeployment = InferType; - -export type IDeploymentTimespan = InferType; - -export type IAnimalTelemetryDevice = InferType; - -export type ICreateAnimalDeployment = InferType; - -export type ITelemetryPointCollection = { points: FeatureCollection; tracks: FeatureCollection }; - -const req = 'Required.'; -const mustBeNum = 'Must be a number'; -const mustBePos = 'Must be positive'; -const mustBeInt = 'Must be an integer'; -const maxInt = 2147483647; -const numSchema = yup.number().typeError(mustBeNum).min(0, mustBePos); -const intSchema = numSchema.max(maxInt, `Must be less than ${maxInt}`).integer(mustBeInt).required(req); - -export const AnimalDeploymentTimespanSchema = yup.object({}).shape({ - deployment_id: yup.string(), - attachment_start: yup.string().isValidDateString().required(req).typeError(req), - attachment_end: yup.string().isValidDateString().isEndDateSameOrAfterStartDate('attachment_start').nullable() -}); - -export const AnimalTelemetryDeviceSchema = yup.object({}).shape({ - device_id: intSchema, - device_make: yup.string().required(req), - frequency: numSchema.nullable(), - frequency_unit: yup.string().nullable(), - device_model: yup.string().nullable(), - deployments: yup.array(AnimalDeploymentTimespanSchema) -}); - -export const AnimalDeploymentSchema = yup.object({}).shape({ - assignment_id: yup.string().required(), - collar_id: yup.string().required(), - critter_id: yup.string().required(), - attachment_start: yup.string().isValidDateString().required(), - attachment_end: yup.string().isValidDateString().isEndDateSameOrAfterStartDate('attachment_start'), - deployment_id: yup.string().required(), - device_id: yup.number().required(), - device_make: yup.string().required(), - device_model: yup.string(), - frequency: numSchema, - frequency_unit: yup.string() -}); - -export const CreateAnimalDeployment = yup.object({ - critter_id: yup.string().uuid().required(req), // Critterbase critter_id - device_id: intSchema, - device_make: yup.string().required(req), - frequency: numSchema.optional(), - frequency_unit: yup.string().optional(), - device_model: yup.string().optional(), - attachment_start: yup.string().isValidDateString(), - attachment_end: yup.string().isValidDateString().isEndDateSameOrAfterStartDate('attachment_start') -}); - -export interface IAnimalTelemetryDeviceFile extends IAnimalTelemetryDevice { - attachmentFile?: File; - attachmentType?: AttachmentType; -} - -export class Device implements Omit { - device_id: number; - device_make: string; - device_model: string | null; - frequency: number | null; - frequency_unit: string | null; - collar_id: string; - constructor(obj: Record) { - this.device_id = Number(obj.device_id); - this.device_make = String(obj.device_make); - this.device_model = obj.device_model ? String(obj.device_model) : null; - this.frequency = obj.frequency ? Number(obj.frequency) : null; - this.frequency_unit = obj.frequency_unit ? String(obj.frequency_unit) : null; - this.collar_id = String(obj.collar_id); - } -} diff --git a/app/src/features/surveys/view/survey-spatial/SurveySpatialContainer.tsx b/app/src/features/surveys/view/survey-spatial/SurveySpatialContainer.tsx new file mode 100644 index 0000000000..cec15b59b0 --- /dev/null +++ b/app/src/features/surveys/view/survey-spatial/SurveySpatialContainer.tsx @@ -0,0 +1,84 @@ +import { mdiEye, mdiPaw, mdiWifiMarker } from '@mdi/js'; +import Paper from '@mui/material/Paper'; +import { TelemetryDataContextProvider } from 'contexts/telemetryDataContext'; +import { SurveySpatialAnimal } from 'features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimal'; +import { SurveySpatialObservation } from 'features/surveys/view/survey-spatial/components/observation/SurveySpatialObservation'; +import { + SurveySpatialDatasetViewEnum, + SurveySpatialToolbar +} from 'features/surveys/view/survey-spatial/components/SurveySpatialToolbar'; +import { SurveySpatialTelemetry } from 'features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry'; +import { useObservationsContext, useTaxonomyContext } from 'hooks/useContext'; +import { isEqual } from 'lodash-es'; +import { useEffect, useState } from 'react'; + +/** + * Container component for displaying survey spatial data. + * It includes a toolbar to switch between different dataset views + * (observations, animals, telemetry) and fetches and catches necessary taxonomic data. + * + * @returns {JSX.Element} The rendered component. + */ +export const SurveySpatialContainer = (): JSX.Element => { + const observationsContext = useObservationsContext(); + const taxonomyContext = useTaxonomyContext(); + + const [activeView, setActiveView] = useState(SurveySpatialDatasetViewEnum.OBSERVATIONS); + + // Fetch and cache all taxonomic data required for the observations. + useEffect(() => { + const cacheTaxonomicData = async () => { + if (observationsContext.observationsDataLoader.data) { + // Fetch all unique ITIS TSNs from observations to retrieve taxonomic names + const taxonomicIds = [ + ...new Set(observationsContext.observationsDataLoader.data.surveyObservations.map((item) => item.itis_tsn)) + ].filter((tsn): tsn is number => tsn !== null); + + await taxonomyContext.cacheSpeciesTaxonomyByIds(taxonomicIds); + } + }; + + cacheTaxonomicData(); + // Should not re-run this effect on `taxonomyContext` changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [observationsContext.observationsDataLoader.data]); + + return ( + + {/* Toolbar for switching between different dataset views */} + + + {/* Display the corresponding dataset view based on the selected active view */} + {isEqual(SurveySpatialDatasetViewEnum.OBSERVATIONS, activeView) && } + {isEqual(SurveySpatialDatasetViewEnum.TELEMETRY, activeView) && ( + + + + )} + {isEqual(SurveySpatialDatasetViewEnum.ANIMALS, activeView) && } + + ); +}; diff --git a/app/src/features/surveys/view/components/spatial-data/SurveySpatialToolbar.tsx b/app/src/features/surveys/view/survey-spatial/components/SurveySpatialToolbar.tsx similarity index 82% rename from app/src/features/surveys/view/components/spatial-data/SurveySpatialToolbar.tsx rename to app/src/features/surveys/view/survey-spatial/components/SurveySpatialToolbar.tsx index 194d06a963..285d71a019 100644 --- a/app/src/features/surveys/view/components/spatial-data/SurveySpatialToolbar.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/SurveySpatialToolbar.tsx @@ -1,4 +1,4 @@ -import { mdiBroadcast, mdiChevronDown, mdiCog, mdiEye } from '@mdi/js'; +import { mdiChevronDown, mdiCog, mdiEye, mdiPaw, mdiWifiMarker } from '@mdi/js'; import Icon from '@mdi/react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; @@ -19,7 +19,7 @@ import { Link as RouterLink } from 'react-router-dom'; export enum SurveySpatialDatasetViewEnum { OBSERVATIONS = 'OBSERVATIONS', TELEMETRY = 'TELEMETRY', - MARKED_ANIMALS = 'MARKED_ANIMALS' + ANIMALS = 'ANIMALS' } interface ISurveySpatialDatasetView { @@ -29,13 +29,21 @@ interface ISurveySpatialDatasetView { isLoading: boolean; } -interface ISurveySptialToolbarProps { - updateDatasetView: (view: SurveySpatialDatasetViewEnum) => void; - views: ISurveySpatialDatasetView[]; +interface ISurveySpatialToolbarProps { activeView: SurveySpatialDatasetViewEnum; + setActiveView: (view: SurveySpatialDatasetViewEnum) => void; + views: ISurveySpatialDatasetView[]; } -const SurveySpatialToolbar = (props: ISurveySptialToolbarProps) => { +/** + * Toolbar that buttons (tabs) to switch between different views of the survey data (observations, animals, telemetry). + * + * @param {ISurveySpatialToolbarProps} props + * @return {*} + */ +export const SurveySpatialToolbar = (props: ISurveySpatialToolbarProps) => { + const { activeView, setActiveView, views } = props; + const [anchorEl, setAnchorEl] = useState(null); const updateDatasetView = (_event: React.MouseEvent, view: SurveySpatialDatasetViewEnum) => { @@ -43,7 +51,7 @@ const SurveySpatialToolbar = (props: ISurveySptialToolbarProps) => { return; } - props.updateDatasetView(view); + setActiveView(view); }; const handleMenuClick = (event: React.MouseEvent) => { @@ -85,13 +93,19 @@ const SurveySpatialToolbar = (props: ISurveySptialToolbarProps) => { }}> - + Observations + + + + + Animals + - + Telemetry @@ -126,7 +140,7 @@ const SurveySpatialToolbar = (props: ISurveySptialToolbarProps) => { { letterSpacing: '0.02rem' } }}> - {props.views.map((view) => ( + {views.map((view) => ( { ); }; - -export default SurveySpatialToolbar; diff --git a/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimal.tsx b/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimal.tsx new file mode 100644 index 0000000000..8fb5799d49 --- /dev/null +++ b/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimal.tsx @@ -0,0 +1,103 @@ +import Box from '@mui/material/Box'; +import { IStaticLayer } from 'components/map/components/StaticLayers'; +import { SURVEY_MAP_LAYER_COLOURS } from 'constants/colours'; +import { SurveySpatialAnimalCapturePopup } from 'features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalCapturePopup'; +import { SurveySpatialAnimalMortalityPopup } from 'features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalMortalityPopup'; +import { SurveySpatialAnimalTable } from 'features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalTable'; +import { SurveySpatialMap } from 'features/surveys/view/survey-spatial/components/map/SurveySpatialMap'; +import SurveyMapTooltip from 'features/surveys/view/SurveyMapTooltip'; +import { useSurveyContext } from 'hooks/useContext'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect, useMemo } from 'react'; +import { coloredCustomMortalityMarker } from 'utils/mapUtils'; + +/** + * Component for displaying animal capture points on a map and in a table. + * Retrieves and displays data related to animal captures for a specific survey. + */ +export const SurveySpatialAnimal = () => { + const surveyContext = useSurveyContext(); + + const crittersApi = useCritterbaseApi(); + + const critterIds = useMemo( + () => surveyContext.critterDataLoader.data?.map((critter) => critter.critterbase_critter_id) ?? [], + [surveyContext.critterDataLoader.data] + ); + + // Data loader for fetching animal capture data for the map ONLY. Table data is fetched separately in `SurveySpatialAnimalTable.tsx` + const geometryDataLoader = useDataLoader((critter_ids: string[]) => + crittersApi.critters.getMultipleCrittersGeometryByIds(critter_ids) + ); + + useEffect(() => { + if (!critterIds.length) { + return; + } + + geometryDataLoader.load(critterIds); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [critterIds]); + + const captureLayer: IStaticLayer = { + layerName: 'Animal Captures', + layerOptions: { + fillColor: SURVEY_MAP_LAYER_COLOURS.CAPTURE_COLOUR ?? SURVEY_MAP_LAYER_COLOURS.DEFAULT_COLOUR, + color: SURVEY_MAP_LAYER_COLOURS.CAPTURE_COLOUR ?? SURVEY_MAP_LAYER_COLOURS.DEFAULT_COLOUR + }, + features: + geometryDataLoader.data?.captures.map((capture) => ({ + id: capture.capture_id, + key: `capture-${capture.capture_id}`, + geoJSON: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [capture.coordinates[1], capture.coordinates[0]] + }, + properties: {} + } + })) ?? [], + popup: (feature) => , + tooltip: (feature) => + }; + + const mortalityLayer: IStaticLayer = { + layerName: 'Animal Mortalities', + layerOptions: { + fillColor: SURVEY_MAP_LAYER_COLOURS.MORTALITY_COLOUR ?? SURVEY_MAP_LAYER_COLOURS.DEFAULT_COLOUR, + color: SURVEY_MAP_LAYER_COLOURS.MORTALITY_COLOUR ?? SURVEY_MAP_LAYER_COLOURS.DEFAULT_COLOUR, + marker: coloredCustomMortalityMarker + }, + features: + geometryDataLoader.data?.mortalities.map((mortality) => ({ + id: mortality.mortality_id, + key: `mortality-${mortality.mortality_id}`, + geoJSON: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [mortality.coordinates[1], mortality.coordinates[0]] + }, + properties: {} + } + })) ?? [], + popup: (feature) => , + tooltip: (feature) => + }; + + return ( + <> + {/* Display map with animal capture points */} + + + + + {/* Display data table with animal capture details */} + + + + + ); +}; diff --git a/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalCapturePopup.tsx b/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalCapturePopup.tsx new file mode 100644 index 0000000000..ca3506ce35 --- /dev/null +++ b/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalCapturePopup.tsx @@ -0,0 +1,60 @@ +import { IStaticLayerFeature } from 'components/map/components/StaticLayers'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import dayjs from 'dayjs'; +import { SurveyMapPopup } from 'features/surveys/view/SurveyMapPopup'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { ICaptureResponse } from 'interfaces/useCritterApi.interface'; +import { Popup } from 'react-leaflet'; + +export interface ISurveySpatialAnimalCapturePopupProps { + feature: IStaticLayerFeature; +} + +/** + * Renders a popup for animal capture data on the map. + * + * @param {ISurveySpatialAnimalCapturePopupProps} props + * @return {*} + */ +export const SurveySpatialAnimalCapturePopup = (props: ISurveySpatialAnimalCapturePopupProps) => { + const { feature } = props; + + const critterbaseApi = useCritterbaseApi(); + + const captureDataLoader = useDataLoader((captureId) => critterbaseApi.capture.getCapture(captureId)); + + const getCaptureMetadata = (capture: ICaptureResponse) => { + return [ + { label: 'Capture ID', value: String(capture.capture_id) }, + { label: 'Date', value: dayjs(capture.capture_date).format(DATE_FORMAT.LongDateTimeFormat) }, + { label: 'Time', value: String(capture.capture_time ?? '') }, + { + label: 'Coordinates', + value: [capture.release_location?.latitude ?? null, capture.release_location?.longitude ?? null] + .filter((coord): coord is number => coord !== null) + .map((coord) => coord.toFixed(6)) + .join(', ') + } + ]; + }; + + return ( + { + captureDataLoader.load(String(feature.id)); + } + }}> + + + ); +}; diff --git a/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalMortalityPopup.tsx b/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalMortalityPopup.tsx new file mode 100644 index 0000000000..393890c9d5 --- /dev/null +++ b/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalMortalityPopup.tsx @@ -0,0 +1,59 @@ +import { IStaticLayerFeature } from 'components/map/components/StaticLayers'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import dayjs from 'dayjs'; +import { SurveyMapPopup } from 'features/surveys/view/SurveyMapPopup'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { IMortalityResponse } from 'interfaces/useCritterApi.interface'; +import { Popup } from 'react-leaflet'; + +export interface ISurveySpatialAnimalMortalityPopupProps { + feature: IStaticLayerFeature; +} + +/** + * Renders a popup for animal mortality data on the map. + * + * @param {ISurveySpatialAnimalMortalityPopupProps} props + * @return {*} + */ +export const SurveySpatialAnimalMortalityPopup = (props: ISurveySpatialAnimalMortalityPopupProps) => { + const { feature } = props; + + const critterbaseApi = useCritterbaseApi(); + + const mortalityDataLoader = useDataLoader((mortalityId) => critterbaseApi.mortality.getMortality(mortalityId)); + + const getMortalityMetadata = (mortality: IMortalityResponse) => { + return [ + { label: 'Mortality ID', value: String(mortality.mortality_id) }, + { label: 'Date', value: dayjs(mortality.mortality_timestamp).format(DATE_FORMAT.LongDateTimeFormat) }, + { + label: 'Coordinates', + value: [mortality.location?.latitude ?? null, mortality.location?.longitude ?? null] + .filter((coord): coord is number => coord !== null) + .map((coord) => coord.toFixed(6)) + .join(', ') + } + ]; + }; + + return ( + { + mortalityDataLoader.load(String(feature.id)); + } + }}> + + + ); +}; diff --git a/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalTable.tsx b/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalTable.tsx new file mode 100644 index 0000000000..e8d184f511 --- /dev/null +++ b/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalTable.tsx @@ -0,0 +1,117 @@ +import { mdiArrowTopRight } from '@mdi/js'; +import { GridColDef } from '@mui/x-data-grid'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; +import { ScientificNameTypography } from 'features/surveys/animals/components/ScientificNameTypography'; +import { useSurveyContext } from 'hooks/useContext'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; + +// Set height so the skeleton loader matches table rows +const rowHeight = 52; + +/** + * Interface defining the structure of animal data used in the table. + */ +interface IAnimalData { + id: number; + animal_id: string; + scientificName: string; +} + +/** + * Props interface for SurveySpatialAnimalTable component. + */ +interface ISurveyDataAnimalTableProps { + isLoading: boolean; +} + +/** + * Component for displaying animal data in a table, fetching data via context and API hooks. + * Renders a table with animal nicknames and scientific names, with loading skeleton when data is loading. + */ +export const SurveySpatialAnimalTable = (props: ISurveyDataAnimalTableProps) => { + const surveyContext = useSurveyContext(); + const critterbaseApi = useCritterbaseApi(); + + // Fetch critter data loader from survey context + const animals = surveyContext.critterDataLoader.data ?? []; + + // DataLoader to fetch detailed critter data based on IDs from context + const animalsDataLoader = useDataLoader(() => + critterbaseApi.critters.getMultipleCrittersByIds(animals.map((animal) => animal.critterbase_critter_id)) + ); + + // Load data if animals data is available + if (animals.length) { + animalsDataLoader.load(); + } + + // Map fetched data to table data structure + const rows: IAnimalData[] = + animalsDataLoader.data?.map((item) => ({ + id: item.critter_id, + animal_id: item.animal_id ?? '', + scientificName: item.itis_scientific_name, + status: !!item.mortality?.length + })) ?? []; + + // Define columns for the data grid + const columns: GridColDef[] = [ + { + field: 'animal_id', + headerName: 'Nickname', + flex: 1 + }, + { + field: 'scientificName', + headerName: 'Species', + flex: 1, + renderCell: (params) => // Render scientific name with custom typography component + } + ]; + + return ( + 0 && (props.isLoading || animalsDataLoader.isLoading || !animalsDataLoader.isReady)} + isLoadingFallback={} + isLoadingFallbackDelay={100} + hasNoData={!animals.length || !rows.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + row.id} + columns={columns} + initialState={{ + pagination: { + paginationModel: { page: 1, pageSize: 5 } + } + }} + pageSizeOptions={[5]} + rowSelection={false} + checkboxSelection={false} + disableRowSelectionOnClick + disableColumnSelector + disableColumnFilter + disableColumnMenu + disableVirtualization + sortingOrder={['asc', 'desc']} + data-testid="survey-animals-data-table" + /> + + ); +}; diff --git a/app/src/features/surveys/view/survey-spatial/components/map/SurveySpatialMap.tsx b/app/src/features/surveys/view/survey-spatial/components/map/SurveySpatialMap.tsx new file mode 100644 index 0000000000..ce916ecbc1 --- /dev/null +++ b/app/src/features/surveys/view/survey-spatial/components/map/SurveySpatialMap.tsx @@ -0,0 +1,42 @@ +import { IStaticLayer } from 'components/map/components/StaticLayers'; +import { useSamplingSiteStaticLayer } from 'features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer'; +import { useStudyAreaStaticLayer } from 'features/surveys/view/survey-spatial/components/map/useStudyAreaStaticLayer'; +import SurveyMap from 'features/surveys/view/SurveyMap'; +import { useMemo } from 'react'; + +/** + * Props interface for SurveySpatialMap component. + */ +interface ISurveyDataMapProps { + /** + * Array of additional static layers to be added to the map. + */ + staticLayers: IStaticLayer[]; + /** + * Loading indicator to control map skeleton loader. + */ + isLoading: boolean; +} + +/** + * Component for displaying survey-related spatial data on a map. + * + * Automatically includes the study area and sampling site static layers. + * + * @param {ISurveyDataMapProps} props + * @return {*} + */ +export const SurveySpatialMap = (props: ISurveyDataMapProps) => { + const { staticLayers, isLoading } = props; + + const studyAreaStaticLayer = useStudyAreaStaticLayer(); + + const samplingSiteStaticLayer = useSamplingSiteStaticLayer(); + + const allStaticLayers = useMemo( + () => [studyAreaStaticLayer, samplingSiteStaticLayer, ...staticLayers], + [samplingSiteStaticLayer, staticLayers, studyAreaStaticLayer] + ); + + return ; +}; diff --git a/app/src/features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer.tsx b/app/src/features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer.tsx new file mode 100644 index 0000000000..9fdf33aa54 --- /dev/null +++ b/app/src/features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer.tsx @@ -0,0 +1,64 @@ +import { IStaticLayer } from 'components/map/components/StaticLayers'; +import { SURVEY_MAP_LAYER_COLOURS } from 'constants/colours'; +import { SurveyMapPopup } from 'features/surveys/view/SurveyMapPopup'; +import SurveyMapTooltip from 'features/surveys/view/SurveyMapTooltip'; +import { useSurveyContext } from 'hooks/useContext'; +import { Popup } from 'react-leaflet'; + +/** + * Hook to get the sampling site static layer. + * + * @return {*} {IStaticLayer} + */ +export const useSamplingSiteStaticLayer = (): IStaticLayer => { + const surveyContext = useSurveyContext(); + + const samplingSiteStaticLayer: IStaticLayer = { + layerName: 'Sampling Sites', + layerOptions: { + color: SURVEY_MAP_LAYER_COLOURS.SAMPLING_SITE_COLOUR, + fillColor: SURVEY_MAP_LAYER_COLOURS.SAMPLING_SITE_COLOUR + }, + features: + surveyContext.sampleSiteDataLoader.data?.sampleSites.flatMap((site) => { + return { + id: site.survey_sample_site_id, + key: `sampling-site-${site.survey_sample_site_id}`, + geoJSON: site.geojson + }; + }) ?? [], + popup: (feature) => { + const sampleSite = surveyContext.sampleSiteDataLoader.data?.sampleSites.find( + (item) => item.survey_sample_site_id === feature.id + ); + + const metadata = []; + + if (sampleSite) { + metadata.push({ + label: 'Name', + value: sampleSite.name + }); + + metadata.push({ + label: 'Description', + value: sampleSite.description + }); + } + + return ( + + + + ); + }, + tooltip: (feature) => + }; + + return samplingSiteStaticLayer; +}; diff --git a/app/src/features/surveys/view/survey-spatial/components/map/useStudyAreaStaticLayer.tsx b/app/src/features/surveys/view/survey-spatial/components/map/useStudyAreaStaticLayer.tsx new file mode 100644 index 0000000000..66e8d839b3 --- /dev/null +++ b/app/src/features/surveys/view/survey-spatial/components/map/useStudyAreaStaticLayer.tsx @@ -0,0 +1,66 @@ +import { IStaticLayer } from 'components/map/components/StaticLayers'; +import { SURVEY_MAP_LAYER_COLOURS } from 'constants/colours'; +import { SurveyMapPopup } from 'features/surveys/view/SurveyMapPopup'; +import SurveyMapTooltip from 'features/surveys/view/SurveyMapTooltip'; +import { useSurveyContext } from 'hooks/useContext'; +import { Popup } from 'react-leaflet'; + +/** + * Hook to get the study area static layer. + * + * @return {*} {IStaticLayer} + */ +export const useStudyAreaStaticLayer = (): IStaticLayer => { + const surveyContext = useSurveyContext(); + + const studyAreaStaticLayer: IStaticLayer = { + layerName: 'Study Areas', + layerOptions: { + color: SURVEY_MAP_LAYER_COLOURS.STUDY_AREA_COLOUR, + fillColor: SURVEY_MAP_LAYER_COLOURS.STUDY_AREA_COLOUR + }, + features: + surveyContext.surveyDataLoader.data?.surveyData.locations.flatMap((location) => { + return location.geojson.map((feature) => { + return { + id: location.survey_location_id, + key: `study-area-${location.survey_location_id}`, + geoJSON: feature + }; + }); + }) ?? [], + popup: (feature) => { + const location = surveyContext.surveyDataLoader.data?.surveyData.locations.find( + (item) => item.survey_location_id === feature.id + ); + + const metadata = []; + + if (location) { + metadata.push({ + label: 'Name', + value: location.name + }); + + metadata.push({ + label: 'Description', + value: location.description + }); + } + + return ( + + + + ); + }, + tooltip: (feature) => + }; + + return studyAreaStaticLayer; +}; diff --git a/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservation.tsx b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservation.tsx new file mode 100644 index 0000000000..34836bdf3a --- /dev/null +++ b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservation.tsx @@ -0,0 +1,74 @@ +import Box from '@mui/material/Box'; +import { IStaticLayer, IStaticLayerFeature } from 'components/map/components/StaticLayers'; +import { SURVEY_MAP_LAYER_COLOURS } from 'constants/colours'; +import SurveyObservationTabularDataContainer from 'features/surveys/view/components/data-container/SurveyObservationTabularDataContainer'; +import { SurveySpatialMap } from 'features/surveys/view/survey-spatial/components/map/SurveySpatialMap'; +import { SurveySpatialObservationPointPopup } from 'features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationPointPopup'; +import SurveyMapTooltip from 'features/surveys/view/SurveyMapTooltip'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { IGetSurveyObservationsGeometryResponse } from 'interfaces/useObservationApi.interface'; +import { useEffect, useMemo } from 'react'; +import { coloredCustomObservationMarker } from 'utils/mapUtils'; + +/** + * Component to display survey observation data on a map and in a table. + */ +export const SurveySpatialObservation = () => { + const surveyContext = useSurveyContext(); + const { surveyId, projectId } = surveyContext; + const biohubApi = useBiohubApi(); + + const observationsGeometryDataLoader = useDataLoader(() => + biohubApi.observation.getObservationsGeometry(projectId, surveyId) + ); + + useEffect(() => { + observationsGeometryDataLoader.load(); + }, [observationsGeometryDataLoader]); + + const observations: IGetSurveyObservationsGeometryResponse | undefined = observationsGeometryDataLoader.data; + + const observationPoints: IStaticLayerFeature[] = useMemo(() => { + return ( + observations?.surveyObservationsGeometry.map((item) => ({ + id: Number(item.survey_observation_id), + key: `observation-${item.survey_observation_id}`, + geoJSON: { + type: 'Feature', + properties: {}, + geometry: item.geometry + } + })) ?? [] + ); + }, [observations?.surveyObservationsGeometry]); + + const observationLayer: IStaticLayer = { + layerName: 'Species Observations', + layerOptions: { + fillColor: SURVEY_MAP_LAYER_COLOURS.OBSERVATIONS_COLOUR ?? SURVEY_MAP_LAYER_COLOURS.DEFAULT_COLOUR, + color: SURVEY_MAP_LAYER_COLOURS.OBSERVATIONS_COLOUR ?? SURVEY_MAP_LAYER_COLOURS.DEFAULT_COLOUR, + marker: coloredCustomObservationMarker + }, + features: observationPoints, + popup: (feature) => { + return ; + }, + tooltip: (feature) => + }; + + return ( + <> + {/* Display map with observation points */} + + + + + {/* Display data table with observation details */} + + + + + ); +}; diff --git a/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationPointPopup.tsx b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationPointPopup.tsx new file mode 100644 index 0000000000..8e17ef5285 --- /dev/null +++ b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationPointPopup.tsx @@ -0,0 +1,70 @@ +import { IStaticLayerFeature } from 'components/map/components/StaticLayers'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import { SurveyMapPopup } from 'features/surveys/view/SurveyMapPopup'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { ObservationRecord } from 'interfaces/useObservationApi.interface'; +import { Popup } from 'react-leaflet'; +import { getFormattedDate } from 'utils/Utils'; + +interface ISurveySpatialObservationPointPopupProps { + feature: IStaticLayerFeature; +} + +/** + * Renders a popup for observation data on the map. + * + * @param {ISurveySpatialObservationPointPopupProps} props + * @return {*} + */ +export const SurveySpatialObservationPointPopup = (props: ISurveySpatialObservationPointPopupProps) => { + const { feature } = props; + + const surveyContext = useSurveyContext(); + const biohubApi = useBiohubApi(); + + const observationDataLoader = useDataLoader((observationId: number) => + biohubApi.observation.getObservationRecord(surveyContext.projectId, surveyContext.surveyId, observationId) + ); + + const getObservationMetadata = (observation: ObservationRecord) => { + return [ + { label: 'Taxon ID', value: String(observation.itis_tsn) }, + { label: 'Count', value: String(observation.count) }, + { + label: 'Coords', + value: [observation.latitude, observation.longitude] + .filter((coord): coord is number => coord !== null) + .map((coord) => coord.toFixed(6)) + .join(', ') + }, + { + label: 'Date', + value: getFormattedDate( + observation.observation_time ? DATE_FORMAT.LongDateTimeFormat : DATE_FORMAT.MediumDateFormat, + `${observation.observation_date} ${observation.observation_time}` + ) + } + ]; + }; + + return ( + { + observationDataLoader.load(Number(feature.id)); + } + }}> + + + ); +}; diff --git a/app/src/features/surveys/view/components/spatial-data/SurveySpatialObservationDataTable.tsx b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationTable.tsx similarity index 52% rename from app/src/features/surveys/view/components/spatial-data/SurveySpatialObservationDataTable.tsx rename to app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationTable.tsx index 0605ddd16a..a47dc7181a 100644 --- a/app/src/features/surveys/view/components/spatial-data/SurveySpatialObservationDataTable.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationTable.tsx @@ -1,12 +1,9 @@ import { mdiArrowTopRight } from '@mdi/js'; -import Icon from '@mdi/react'; -import Box from '@mui/material/Box'; -import grey from '@mui/material/colors/grey'; -import Skeleton from '@mui/material/Skeleton'; -import Stack from '@mui/material/Stack'; -import Typography from '@mui/material/Typography'; -import { GridColDef, GridOverlay, GridSortModel } from '@mui/x-data-grid'; +import { GridColDef, GridSortModel } from '@mui/x-data-grid'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; import { SurveyContext } from 'contexts/surveyContext'; import dayjs from 'dayjs'; import { useBiohubApi } from 'hooks/useBioHubApi'; @@ -14,7 +11,7 @@ import { useTaxonomyContext } from 'hooks/useContext'; import useDataLoader from 'hooks/useDataLoader'; import { useContext, useEffect, useState } from 'react'; -// Set height so we the skeleton loader will match table rows +// Set height so the skeleton loader will match table rows const rowHeight = 52; interface IObservationTableRow { @@ -31,66 +28,38 @@ interface IObservationTableRow { longitude: number | null; } -interface ISurveySpatialObservationDataTableProps { +interface ISurveyDataObservationTableProps { isLoading: boolean; } -// Skeleton Loader template -const SkeletonRow = () => ( - - - - - - - - - - - -); - -const SurveySpatialObservationDataTable = (props: ISurveySpatialObservationDataTableProps) => { +/** + * Component to display observation data in a table with server-side pagination and sorting. + * + * @param {ISurveyDataObservationTableProps} props - Component properties. + * @returns {JSX.Element} The rendered component. + */ +export const SurveySpatialObservationTable = (props: ISurveyDataObservationTableProps) => { const biohubApi = useBiohubApi(); const surveyContext = useContext(SurveyContext); const taxonomyContext = useTaxonomyContext(); const [totalRows, setTotalRows] = useState(0); const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState(5); + const [pageSize, setPageSize] = useState(10); const [sortModel, setSortModel] = useState([]); - const [tableData, setTableData] = useState([]); + const [rows, setTableData] = useState([]); const [tableColumns, setTableColumns] = useState[]>([]); const paginatedDataLoader = useDataLoader((page: number, limit: number, sort?: string, order?: 'asc' | 'desc') => biohubApi.observation.getObservationRecords(surveyContext.projectId, surveyContext.surveyId, { - page: page + 1, // this fixes an off by one error between the front end and the back end + page: page + 1, // This fixes an off-by-one error between the front end and the back end limit, sort, order }) ); - // page information has changed, fetch more data + // Page information has changed, fetch more data useEffect(() => { if (sortModel.length > 0) { if (sortModel[0].sort) { @@ -99,10 +68,10 @@ const SurveySpatialObservationDataTable = (props: ISurveySpatialObservationDataT } else { paginatedDataLoader.refresh(page, pageSize); } - // Should not re-run this effect on `paginatedDataLoader.refresh` changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [page, pageSize, sortModel]); + // Update table data and columns when new data is loaded useEffect(() => { if (paginatedDataLoader.data) { setTotalRows(paginatedDataLoader.data.pagination.total); @@ -132,9 +101,7 @@ const SurveySpatialObservationDataTable = (props: ISurveySpatialObservationDataT headerName: 'Species', flex: 1, minWidth: 200, - renderCell: (params) => { - return {params.row.itis_scientific_name}; - } + renderCell: (params) => {params.row.itis_scientific_name} }, { field: 'survey_sample_site_name', @@ -192,69 +159,48 @@ const SurveySpatialObservationDataTable = (props: ISurveySpatialObservationDataT }, [paginatedDataLoader.data, taxonomyContext]); return ( - <> - {props.isLoading ? ( - - - - - - ) : ( - { - setPage(model.page); - setPageSize(model.pageSize); - }} - pageSizeOptions={[5]} - paginationMode="server" - sortingMode="server" - sortModel={sortModel} - onSortModelChange={(model) => setSortModel(model)} - loading={paginatedDataLoader.isLoading} - getRowId={(row) => row.survey_observation_id} - columns={tableColumns} - rowSelection={false} - checkboxSelection={false} - disableColumnSelector - disableColumnFilter - disableColumnMenu - disableVirtualization - data-testid="survey-spatial-observation-data-table" - noRowsOverlay={ - - - - Add observations after sampling information  - - - - After adding sampling information, add observations and assign them to a sampling period - - - - } - sx={{ - '& .MuiDataGrid-virtualScroller': { - height: '250px', - overflowY: 'auto !important' - }, - '& .MuiDataGrid-overlay': { - height: '250px', - display: 'flex', - justifyContent: 'center', - alignItems: 'center' - } - }} + } + isLoadingFallbackDelay={100} + hasNoData={!rows.length} + hasNoDataFallback={ + - )} - + } + hasNoDataFallbackDelay={100}> + { + setPage(model.page); + setPageSize(model.pageSize); + }} + pageSizeOptions={[10, 25, 50]} + paginationMode="server" + sortingMode="server" + sortModel={sortModel} + onSortModelChange={(model) => setSortModel(model)} + loading={paginatedDataLoader.isLoading} + getRowId={(row) => row.survey_observation_id} + columns={tableColumns} + rowSelection={false} + autoHeight={false} + checkboxSelection={false} + disableColumnSelector + disableColumnFilter + disableColumnMenu + disableVirtualization + data-testid="survey-spatial-observation-data-table" + /> + ); }; - -export default SurveySpatialObservationDataTable; diff --git a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry.tsx b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry.tsx new file mode 100644 index 0000000000..3737f7bc89 --- /dev/null +++ b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry.tsx @@ -0,0 +1,140 @@ +import Box from '@mui/material/Box'; +import { IStaticLayer, IStaticLayerFeature } from 'components/map/components/StaticLayers'; +import { SURVEY_MAP_LAYER_COLOURS } from 'constants/colours'; +import { SurveySpatialMap } from 'features/surveys/view/survey-spatial/components/map/SurveySpatialMap'; +import { SurveySpatialTelemetryPopup } from 'features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryPopup'; +import { SurveySpatialTelemetryTable } from 'features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable'; +import SurveyMapTooltip from 'features/surveys/view/SurveyMapTooltip'; +import { Position } from 'geojson'; +import { useSurveyContext, useTelemetryDataContext } from 'hooks/useContext'; +import { ICritterSimpleResponse } from 'interfaces/useCritterApi.interface'; +import { IAnimalDeployment, ITelemetry } from 'interfaces/useTelemetryApi.interface'; +import { useCallback, useEffect, useMemo } from 'react'; + +/** + * Component to display telemetry data on a map and in a table. + * + * @returns {JSX.Element} The rendered component. + */ +export const SurveySpatialTelemetry = () => { + const surveyContext = useSurveyContext(); + const telemetryDataContext = useTelemetryDataContext(); + + const deploymentDataLoader = telemetryDataContext.deploymentsDataLoader; + const telemetryDataLoader = telemetryDataContext.telemetryDataLoader; + + // Load deployments data + useEffect(() => { + deploymentDataLoader.load(surveyContext.projectId, surveyContext.surveyId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [surveyContext.projectId, surveyContext.surveyId]); + + // Load telemetry data for all deployments + useEffect(() => { + if (!deploymentDataLoader.data?.length) { + // No deployments data, therefore no telemetry data to load + return; + } + + telemetryDataLoader.load(deploymentDataLoader.data?.map((deployment) => deployment.bctw_deployment_id) ?? []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [deploymentDataLoader.data]); + + const isLoading = + deploymentDataLoader.isLoading || + !deploymentDataLoader.isReady || + ((telemetryDataLoader.isLoading || !telemetryDataLoader.isReady) && !!deploymentDataLoader.data?.length); + + /** + * Combines telemetry, deployment, and critter data into a single list of telemetry points. + * + * @param {ITelemetry[]} telemetry The telemetry data. + * @param {IAnimalDeployment[]} deployments The deployment data. + * @param {ICritterSimpleResponse[]} critters The critter data. + * @returns {IStaticLayerFeature[]} The combined list of telemetry points. + */ + const combineTelemetryData = useCallback( + ( + telemetry: ITelemetry[], + deployments: IAnimalDeployment[], + critters: ICritterSimpleResponse[] + ): IStaticLayerFeature[] => { + return ( + telemetry + ?.filter((telemetry) => telemetry.latitude !== undefined && telemetry.longitude !== undefined) + .reduce( + ( + acc: { + deployment: IAnimalDeployment; + critter: ICritterSimpleResponse; + telemetry: ITelemetry; + }[], + telemetry: ITelemetry + ) => { + const deployment = deployments.find( + (animalDeployment) => animalDeployment.bctw_deployment_id === telemetry.deployment_id + ); + + const critter = critters.find((detailedCritter) => detailedCritter.critter_id === deployment?.critter_id); + + if (critter && deployment) { + acc.push({ deployment, critter, telemetry }); + } + + return acc; + }, + [] + ) + .map(({ telemetry }) => { + return { + id: telemetry.id, + key: `telemetry-id-${telemetry.id}`, + geoJSON: { + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: [telemetry.longitude, telemetry.latitude] as Position + } + } + }; + }) ?? [] + ); + }, + [] + ); + + const telemetryPoints: IStaticLayerFeature[] = useMemo(() => { + const telemetry = telemetryDataLoader.data ?? []; + const deployments = deploymentDataLoader.data ?? []; + const critters = surveyContext.critterDataLoader.data ?? []; + + return combineTelemetryData(telemetry, deployments, critters); + }, [combineTelemetryData, surveyContext.critterDataLoader.data, deploymentDataLoader.data, telemetryDataLoader.data]); + + const telemetryLayer: IStaticLayer = { + layerName: 'Telemetry', + layerOptions: { + fillColor: SURVEY_MAP_LAYER_COLOURS.TELEMETRY_COLOUR ?? SURVEY_MAP_LAYER_COLOURS.DEFAULT_COLOUR, + color: SURVEY_MAP_LAYER_COLOURS.TELEMETRY_COLOUR ?? SURVEY_MAP_LAYER_COLOURS.DEFAULT_COLOUR, + opacity: 0.75 + }, + features: telemetryPoints, + popup: (feature) => , + tooltip: (feature) => + }; + + return ( + <> + {/* Display map with telemetry points */} + + + + + {/* Display data table with telemetry details */} + + + + + ); +}; diff --git a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryPopup.tsx b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryPopup.tsx new file mode 100644 index 0000000000..609dcaff08 --- /dev/null +++ b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryPopup.tsx @@ -0,0 +1,101 @@ +import { IStaticLayerFeature } from 'components/map/components/StaticLayers'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import dayjs from 'dayjs'; +import { SurveyMapPopup } from 'features/surveys/view/SurveyMapPopup'; +import { useSurveyContext, useTelemetryDataContext } from 'hooks/useContext'; +import { Popup } from 'react-leaflet'; + +export interface ISurveySpatialTelemetryPopupProps { + feature: IStaticLayerFeature; +} + +/** + * Renders a popup for telemetry data on the map. + * + * TODO: This currently relies on the telemetry, deployment, and critter data loaders to already be loaded. The + * improvement would be to fetch that data when the popup is opened, based on the provided feature ID. + * + * @param {ISurveySpatialTelemetryPopupProps} props + * @return {*} + */ +export const SurveySpatialTelemetryPopup = (props: ISurveySpatialTelemetryPopupProps) => { + const { feature } = props; + + const surveyContext = useSurveyContext(); + const telemetryDataContext = useTelemetryDataContext(); + + const deploymentDataLoader = telemetryDataContext.deploymentsDataLoader; + const telemetryDataLoader = telemetryDataContext.telemetryDataLoader; + + const getTelemetryMetadata = () => { + const telemetryId = feature.id; + + const telemetryRecord = telemetryDataLoader.data?.find((telemetry) => telemetry.id === telemetryId); + + if (!telemetryRecord) { + return [{ label: 'Telemetry ID', value: telemetryId }]; + } + + const deploymentRecord = deploymentDataLoader.data?.find( + (deployment) => deployment.bctw_deployment_id === telemetryRecord.deployment_id + ); + + if (!deploymentRecord) { + return [ + { label: 'Telemetry ID', value: telemetryId }, + { + label: 'Location', + value: [telemetryRecord.latitude, telemetryRecord.longitude] + .filter((coord): coord is number => coord !== null) + .map((coord) => coord.toFixed(6)) + .join(', ') + }, + { label: 'Date', value: dayjs(telemetryRecord?.acquisition_date).toISOString() } + ]; + } + + const critterRecord = surveyContext.critterDataLoader.data?.find( + (critter) => critter.critter_id === deploymentRecord.critter_id + ); + + if (!critterRecord) { + return [ + { label: 'Telemetry ID', value: telemetryId }, + { label: 'Device ID', value: String(deploymentRecord.device_id) }, + { + label: 'Location', + value: [telemetryRecord.latitude, telemetryRecord.longitude] + .filter((coord): coord is number => coord !== null) + .map((coord) => coord.toFixed(6)) + .join(', ') + }, + { label: 'Date', value: dayjs(telemetryRecord?.acquisition_date).toISOString() } + ]; + } + + return [ + { label: 'Telemetry ID', value: telemetryId }, + { label: 'Device ID', value: String(deploymentRecord.device_id) }, + { label: 'Nickname', value: critterRecord.animal_id ?? '' }, + { + label: 'Location', + value: [telemetryRecord?.latitude, telemetryRecord?.longitude] + .filter((coord): coord is number => coord !== null) + .map((coord) => coord.toFixed(6)) + .join(', ') + }, + { label: 'Date', value: dayjs(telemetryRecord?.acquisition_date).format(DATE_FORMAT.LongDateTimeFormat) } + ]; + }; + + return ( + + + + ); +}; diff --git a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable.tsx b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable.tsx new file mode 100644 index 0000000000..1f35e17b73 --- /dev/null +++ b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable.tsx @@ -0,0 +1,211 @@ +import { mdiArrowTopRight } from '@mdi/js'; +import Typography from '@mui/material/Typography'; +import { GridColDef } from '@mui/x-data-grid'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import { SurveyContext } from 'contexts/surveyContext'; +import dayjs from 'dayjs'; +import { ScientificNameTypography } from 'features/surveys/animals/components/ScientificNameTypography'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useTelemetryDataContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { IAnimalDeploymentWithCritter } from 'interfaces/useSurveyApi.interface'; +import { useContext, useEffect, useMemo } from 'react'; + +// Set height so the skeleton loader will match table rows +const rowHeight = 52; + +interface ITelemetryData { + id: number; + critter_id: number | null; + device_id: number; + frequency: number | null; + frequency_unit: string | null; + // start: string; + end: string; + itis_scientific_name: string; +} + +interface ISurveyDataTelemetryTableProps { + isLoading: boolean; +} + +/** + * Component to display telemetry data in a table format. + * + * @param {ISurveyDataTelemetryTableProps} props - The component props. + * @param {boolean} props.isLoading - Indicates if the data is currently loading. + * @returns {JSX.Element} The rendered component. + */ +export const SurveySpatialTelemetryTable = (props: ISurveyDataTelemetryTableProps) => { + const surveyContext = useContext(SurveyContext); + const telemetryDataContext = useTelemetryDataContext(); + + const biohubApi = useBiohubApi(); + + const critterDataLoader = useDataLoader(biohubApi.survey.getSurveyCritters); + const deploymentDataLoader = telemetryDataContext.deploymentsDataLoader; + const frequencyUnitDataLoader = useDataLoader(() => biohubApi.telemetry.getCodeValues('frequency_unit')); + + useEffect(() => { + deploymentDataLoader.load(surveyContext.projectId, surveyContext.surveyId); + critterDataLoader.load(surveyContext.projectId, surveyContext.surveyId); + frequencyUnitDataLoader.load(); + }, [ + critterDataLoader, + deploymentDataLoader, + frequencyUnitDataLoader, + surveyContext.projectId, + surveyContext.surveyId + ]); + + /** + * Merges critters with associated deployments + * + * @returns {ICritterDeployment[]} Critter deployments + */ + const critterDeployments: IAnimalDeploymentWithCritter[] = useMemo(() => { + const critterDeployments: IAnimalDeploymentWithCritter[] = []; + const critters = critterDataLoader.data ?? []; + const deployments = deploymentDataLoader.data ?? []; + + if (!critters.length || !deployments.length) { + return []; + } + + const critterMap = new Map(critters.map((critter) => [critter.critterbase_critter_id, critter])); + + deployments.forEach((deployment) => { + const critter = critterMap.get(String(deployment.critterbase_critter_id)); + if (critter) { + critterDeployments.push({ critter, deployment }); + } + }); + + return critterDeployments; + }, [critterDataLoader.data, deploymentDataLoader.data]); + + /** + * Memoized calculation of table rows based on critter deployments data. + * Formats dates and combines necessary fields for display. + */ + const rows: ITelemetryData[] = useMemo(() => { + return critterDeployments.map((item) => { + return { + // Critters in this table may use multiple devices across multiple timespans + id: item.deployment.deployment_id, + critter_id: item.critter.critter_id, + animal_id: item.critter.animal_id, + device_id: item.deployment.device_id, + // start: dayjs(item.deployment.attachment_start).format(DATE_FORMAT.MediumDateFormat), + end: item.deployment.attachment_end_date + ? dayjs(item.deployment.attachment_end_date).format(DATE_FORMAT.MediumDateFormat) + : '', + frequency: item.deployment.frequency ?? null, + frequency_unit: item.deployment.frequency_unit + ? frequencyUnitDataLoader.data?.find((frequencyCode) => frequencyCode.id === item.deployment.frequency_unit) + ?.code ?? null + : null, + itis_scientific_name: item.critter.itis_scientific_name + }; + }); + }, [critterDeployments, frequencyUnitDataLoader.data]); + + // Define table columns + const columns: GridColDef[] = [ + { + field: 'animal_id', + headerName: 'Nickname', + flex: 1 + }, + { + field: 'itis_scientific_name', + headerName: 'Species', + flex: 1, + renderCell: (param) => { + return ( + + ); + } + }, + { + field: 'device_id', + headerName: 'Device ID', + flex: 1 + }, + { + field: 'frequency', + headerName: 'Frequency', + flex: 1, + renderCell: (param) => { + return ( + + {param.row.frequency}  + + {param.row.frequency_unit} + + + ); + } + }, + { + field: 'start', + headerName: 'Start Date', + flex: 1 + }, + { + field: 'end', + headerName: 'End Date', + flex: 1 + } + ]; + + return ( + } + isLoadingFallbackDelay={100} + hasNoData={!rows.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + row.id} + columns={columns} + initialState={{ + pagination: { + paginationModel: { page: 1, pageSize: 5 } + } + }} + pageSizeOptions={[5]} + rowSelection={false} + checkboxSelection={false} + disableRowSelectionOnClick + disableColumnSelector + disableColumnFilter + disableColumnMenu + disableVirtualization + sortingOrder={['asc', 'desc']} + data-testid="survey-spatial-telemetry-data-table" + /> + + ); +}; diff --git a/app/src/hooks/api/useAnalyticsApi.test.ts b/app/src/hooks/api/useAnalyticsApi.test.ts new file mode 100644 index 0000000000..cf7d72e18a --- /dev/null +++ b/app/src/hooks/api/useAnalyticsApi.test.ts @@ -0,0 +1,65 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { IObservationCountByGroup } from 'interfaces/useAnalyticsApi.interface'; +import useAnalyticsApi from './useAnalyticsApi'; + +describe('useAnalyticsApi', () => { + let mock: MockAdapter; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + it('getObservationCountByGroup works as expected', async () => { + const response: IObservationCountByGroup[] = [ + { + id: '123-456-789', + row_count: 10, + individual_count: 40, + individual_percentage: 1, + itis_tsn: 123456, + observation_date: '2021-01-01', + survey_sample_site_id: 1, + survey_sample_method_id: 2, + survey_sample_period_id: 3, + qualitative_measurements: [ + { + taxon_measurement_id: '66', + measurement_name: 'a', + option: { + option_id: '1', + option_label: 'x' + } + } + ], + quantitative_measurements: [ + { + taxon_measurement_id: '77', + measurement_name: 'b', + value: 1 + } + ] + } + ]; + + mock.onGet('/api/analytics/observations').reply(200, response); + + const surveyIds = [1, 2]; + const groupByColumns = ['a', 'b']; + const groupByQuantitativeMeasurements = ['c', 'd']; + const groupByQualitativeMeasurements = ['e', 'f']; + + const result = await useAnalyticsApi(axios).getObservationCountByGroup( + surveyIds, + groupByColumns, + groupByQuantitativeMeasurements, + groupByQualitativeMeasurements + ); + + expect(result).toEqual(response); + }); +}); diff --git a/app/src/hooks/api/useAnalyticsApi.ts b/app/src/hooks/api/useAnalyticsApi.ts new file mode 100644 index 0000000000..b45ae447c3 --- /dev/null +++ b/app/src/hooks/api/useAnalyticsApi.ts @@ -0,0 +1,36 @@ +import { AxiosInstance } from 'axios'; +import { IObservationCountByGroup } from 'interfaces/useAnalyticsApi.interface'; + +/** + * Returns a set of supported api methods for working with survey analytics + * + * @param {AxiosInstance} axios + * @return {*} object whose properties are supported api methods. + */ +const useAnalyticsApi = (axios: AxiosInstance) => { + /** + * Create a new project survey + * + * @param {number[]} surveyIds + * @param {string[]} groupByColumns + * @param {string[]} groupByQualitativeMeasurements + * @param {string[]} groupByQuantitativeMeasurements + * @return {*} + */ + const getObservationCountByGroup = async ( + surveyIds: number[], + groupByColumns: string[], + groupByQuantitativeMeasurements: string[], + groupByQualitativeMeasurements: string[] + ): Promise => { + const { data } = await axios.get('/api/analytics/observations', { + params: { surveyIds, groupByColumns, groupByQuantitativeMeasurements, groupByQualitativeMeasurements } + }); + + return data; + }; + + return { getObservationCountByGroup }; +}; + +export default useAnalyticsApi; diff --git a/app/src/hooks/api/useAnimalApi.ts b/app/src/hooks/api/useAnimalApi.ts index 2889d79abb..9adf107fee 100644 --- a/app/src/hooks/api/useAnimalApi.ts +++ b/app/src/hooks/api/useAnimalApi.ts @@ -1,6 +1,6 @@ import { AxiosInstance } from 'axios'; import { IAnimalsAdvancedFilters } from 'features/summary/tabular-data/animal/AnimalsListFilterForm'; -import { IFindAnimalsResponse } from 'interfaces/useAnimalApi.interface'; +import { IFindAnimalsResponse, IGetCaptureMortalityGeometryResponse } from 'interfaces/useAnimalApi.interface'; import qs from 'qs'; import { ApiPaginationRequestOptions } from 'types/misc'; @@ -14,6 +14,25 @@ import { ApiPaginationRequestOptions } from 'types/misc'; * @return {*} object whose properties are supported api methods. */ const useAnimalApi = (axios: AxiosInstance) => { + /** + * Fetches all geojson capture and mortalities points for all animals in survey + * the given survey. + * + * @param {number} projectId + * @param {number} surveyId + * @return {*} {Promise} + */ + const getCaptureMortalityGeometry = async ( + projectId: number, + surveyId: number + ): Promise => { + const { data } = await axios.get( + `/api/project/${projectId}/survey/${surveyId}/critters/spatial` + ); + + return data; + }; + /** * Get animals for a system user id. * @@ -35,7 +54,7 @@ const useAnimalApi = (axios: AxiosInstance) => { return data; }; - return { findAnimals }; + return { getCaptureMortalityGeometry, findAnimals }; }; export default useAnimalApi; diff --git a/app/src/hooks/api/useAxios.ts b/app/src/hooks/api/useAxios.ts index e40b59af12..c49c586679 100644 --- a/app/src/hooks/api/useAxios.ts +++ b/app/src/hooks/api/useAxios.ts @@ -63,7 +63,6 @@ const useAxios = (baseUrl?: string): AxiosInstance => { authRefreshAttemptsRef.current++; // Attempt to refresh the keycloak token - // Note: updateToken called with an arbitrarily large number of seconds to guarantee the update is executed const user = await auth.signinSilent(); if (!user) { diff --git a/app/src/hooks/api/useObservationApi.ts b/app/src/hooks/api/useObservationApi.ts index 3d53ab2ee6..525d6e483f 100644 --- a/app/src/hooks/api/useObservationApi.ts +++ b/app/src/hooks/api/useObservationApi.ts @@ -1,5 +1,9 @@ import { AxiosInstance, AxiosProgressEvent, CancelTokenSource } from 'axios'; import { IObservationsAdvancedFilters } from 'features/summary/tabular-data/observation/ObservationsListFilterForm'; +import { + CBQualitativeMeasurementTypeDefinition, + CBQuantitativeMeasurementTypeDefinition +} from 'interfaces/useCritterApi.interface'; import { IGetSurveyObservationsGeometryResponse, IGetSurveyObservationsResponse, @@ -8,6 +12,7 @@ import { SupplementaryObservationCountData } from 'interfaces/useObservationApi.interface'; import { EnvironmentTypeIds } from 'interfaces/useReferenceApi.interface'; +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; import qs from 'qs'; import { ApiPaginationRequestOptions } from 'types/misc'; @@ -121,6 +126,43 @@ const useObservationApi = (axios: AxiosInstance) => { return data; }; + /** + * Retrieves species observed in a given survey + * + * @param {number} projectId + * @param {number} surveyId + * @return {*} {Promise} + */ + const getObservedSpecies = async (projectId: number, surveyId: number): Promise => { + const { data } = await axios.get( + `/api/project/${projectId}/survey/${surveyId}/observations/taxon` + ); + + return data; + }; + + /** + * Retrieves all measurements associated with all observation records + * + * @param {number} projectId + * @param {number} surveyId + * @return {*} {Promise} + */ + const getObservationMeasurementDefinitions = async ( + projectId: number, + surveyId: number + ): Promise<{ + qualitative_measurements: CBQualitativeMeasurementTypeDefinition[]; + quantitative_measurements: CBQuantitativeMeasurementTypeDefinition[]; + }> => { + const { data } = await axios.get<{ + qualitative_measurements: CBQualitativeMeasurementTypeDefinition[]; + quantitative_measurements: CBQuantitativeMeasurementTypeDefinition[]; + }>(`/api/project/${projectId}/survey/${surveyId}/observations/measurements`); + + return data; + }; + /** * Retrieves all survey observation records for the given survey * @@ -295,8 +337,10 @@ const useObservationApi = (axios: AxiosInstance) => { insertUpdateObservationRecords, getObservationRecords, getObservationRecord, + getObservedSpecies, findObservations, getObservationsGeometry, + getObservationMeasurementDefinitions, deleteObservationRecords, deleteObservationMeasurements, deleteObservationEnvironments, diff --git a/app/src/hooks/api/useProjectApi.test.ts b/app/src/hooks/api/useProjectApi.test.ts index c644f4b985..363e4ba6a3 100644 --- a/app/src/hooks/api/useProjectApi.test.ts +++ b/app/src/hooks/api/useProjectApi.test.ts @@ -6,7 +6,7 @@ import { IProjectIUCNForm } from 'features/projects/components/ProjectIUCNForm'; import { IProjectObjectivesForm } from 'features/projects/components/ProjectObjectivesForm'; import { ICreateProjectRequest, IFindProjectsResponse, UPDATE_GET_ENTITIES } from 'interfaces/useProjectApi.interface'; import { getProjectForViewResponse } from 'test-helpers/project-helpers'; -import { ISurveyPermitForm } from '../../features/surveys/SurveyPermitForm'; +import { ISurveyPermitForm } from '../../features/surveys/components/permit/SurveyPermitForm'; import useProjectApi from './useProjectApi'; describe('useProjectApi', () => { @@ -66,7 +66,8 @@ describe('useProjectApi', () => { end_date: '2021-12-31', regions: [], focal_species: [123, 456], - types: [1, 2, 3] + types: [1, 2, 3], + members: [{ system_user_id: 1, display_name: 'John doe' }] } ], pagination: { diff --git a/app/src/hooks/api/useStandardsApi.ts b/app/src/hooks/api/useStandardsApi.ts index 75ecaf5f15..e27190cdbe 100644 --- a/app/src/hooks/api/useStandardsApi.ts +++ b/app/src/hooks/api/useStandardsApi.ts @@ -1,5 +1,6 @@ import { AxiosInstance } from 'axios'; -import { IGetSpeciesStandardsResponse } from 'interfaces/useStandardsApi.interface'; +import { IEnvironmentStandards, IMethodStandard, ISpeciesStandards } from 'interfaces/useStandardsApi.interface'; +import qs from 'qs'; /** * Returns information about what data can be uploaded for a given species, @@ -12,16 +13,52 @@ const useStandardsApi = (axios: AxiosInstance) => { /** * Fetch species standards * - * @return {*} {Promise} + * @return {*} {Promise} */ - const getSpeciesStandards = async (tsn: number): Promise => { + const getSpeciesStandards = async (tsn: number): Promise => { const { data } = await axios.get(`/api/standards/taxon/${tsn}`); return data; }; + /** + * Fetch method standards + * + * @param {string} keyword + * @return {*} {Promise} + */ + const getMethodStandards = async (keyword?: string): Promise => { + const params = { keyword }; + + const { data } = await axios.get('/api/standards/methods', { + params, + paramsSerializer: (params) => qs.stringify(params) + }); + + return data; + }; + + /** + * Fetch environment standards + * + * @param {string} keyword + * @return {*} {Promise} + */ + const getEnvironmentStandards = async (keyword?: string): Promise => { + const params = { keyword }; + + const { data } = await axios.get('/api/standards/environment', { + params, + paramsSerializer: (params) => qs.stringify(params) + }); + + return data; + }; + return { - getSpeciesStandards + getSpeciesStandards, + getEnvironmentStandards, + getMethodStandards }; }; diff --git a/app/src/hooks/api/useSurveyApi.test.ts b/app/src/hooks/api/useSurveyApi.test.ts index a9763f8a4a..7cd6dd2ff3 100644 --- a/app/src/hooks/api/useSurveyApi.test.ts +++ b/app/src/hooks/api/useSurveyApi.test.ts @@ -1,7 +1,6 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { AnimalSex, ICreateCritter } from 'features/surveys/view/survey-animals/animal'; -import { IAnimalDeployment } from 'features/surveys/view/survey-animals/telemetry-device/device'; import { ICreateSurveyRequest, ICreateSurveyResponse, @@ -9,6 +8,7 @@ import { IFindSurveysResponse, SurveyBasicFieldsObject } from 'interfaces/useSurveyApi.interface'; +import { IAnimalDeployment } from 'interfaces/useTelemetryApi.interface'; import { ApiPaginationResponseParams } from 'types/misc'; import { v4 } from 'uuid'; import useSurveyApi from './useSurveyApi'; @@ -27,6 +27,7 @@ describe('useSurveyApi', () => { const projectId = 1; const surveyId = 1; const critterId = 1; + const deploymentId = 1; describe('createSurvey', () => { it('creates a survey', async () => { @@ -93,19 +94,21 @@ describe('useSurveyApi', () => { }); }); - describe('addDeployment', () => { + describe('createDeployment', () => { it('should add deployment to survey critter', async () => { mock.onPost(`/api/project/${projectId}/survey/${surveyId}/critters/${critterId}/deployments`).reply(201, 1); - const result = await useSurveyApi(axios).addDeployment(projectId, surveyId, critterId, { + const result = await useSurveyApi(axios).createDeployment(projectId, surveyId, critterId, { device_id: 1, - device_make: 'ATS', + device_make: 22, device_model: 'E', frequency: 1, - frequency_unit: 'Hz', - attachment_start: '2023-01-01', - attachment_end: undefined, - critter_id: v4() + frequency_unit: 33, + critterbase_start_capture_id: '', + critterbase_end_capture_id: '', + critterbase_end_mortality_id: '', + attachment_end_date: '', + attachment_end_time: '' }); expect(result).toBe(1); @@ -117,15 +120,22 @@ describe('useSurveyApi', () => { const response: IAnimalDeployment = { assignment_id: v4(), collar_id: v4(), - critter_id: v4(), - attachment_start: '2023-01-01', - attachment_end: '2023-01-01', - deployment_id: v4(), + critterbase_critter_id: v4(), + critter_id: 123, + critterbase_start_capture_id: '', + critterbase_end_capture_id: '', + critterbase_end_mortality_id: '', + attachment_start_date: '', + attachment_start_time: '', + attachment_end_date: '', + attachment_end_time: '', + deployment_id: 123, + bctw_deployment_id: v4(), device_id: 123, - device_make: '', + device_make: 22, device_model: 'a', frequency: 1, - frequency_unit: 'Hz' + frequency_unit: 33 }; mock.onGet(`/api/project/${projectId}/survey/${surveyId}/deployments`).reply(200, [response]); @@ -140,11 +150,19 @@ describe('useSurveyApi', () => { describe('updateDeployment', () => { it('should update a deployment', async () => { - mock.onPatch(`/api/project/${projectId}/survey/${surveyId}/critters/${critterId}/deployments`).reply(200, 1); - const result = await useSurveyApi(axios).updateDeployment(projectId, surveyId, critterId, { - attachment_end: undefined, - deployment_id: 'a', - attachment_start: 'a' + mock.onPut(`/api/project/${projectId}/survey/${surveyId}/deployments/${deploymentId}`).reply(200, 1); + const result = await useSurveyApi(axios).updateDeployment(projectId, surveyId, deploymentId, { + critter_id: 1, + critterbase_start_capture_id: '', + critterbase_end_capture_id: '', + critterbase_end_mortality_id: '', + attachment_end_date: '', + attachment_end_time: '', + frequency: 10.5, + frequency_unit: 44, + device_id: 1, + device_make: 22, + device_model: '' }); expect(result).toBe(1); @@ -155,7 +173,7 @@ describe('useSurveyApi', () => { it('should get critters', async () => { const response = [ { - critter_id: 'critter' + critterbase_critter_id: 'critter' } as IDetailedCritterWithInternalId ]; @@ -168,18 +186,4 @@ describe('useSurveyApi', () => { expect(result).toEqual(response); }); }); - - describe('uploadSurveyKeyx', () => { - it('should upload a keyx file', async () => { - const file = new File([''], 'file.keyx', { type: 'application/keyx' }); - const response = { - attachmentId: 'attachment', - revision_count: 1 - }; - mock.onPost(`/api/project/${projectId}/survey/${surveyId}/attachments/keyx/upload`).reply(201, response); - - const result = await useSurveyApi(axios).uploadSurveyKeyx(projectId, surveyId, file); - expect(result).toEqual(response); - }); - }); }); diff --git a/app/src/hooks/api/useSurveyApi.ts b/app/src/hooks/api/useSurveyApi.ts index 3187b31704..a9df91cb9d 100644 --- a/app/src/hooks/api/useSurveyApi.ts +++ b/app/src/hooks/api/useSurveyApi.ts @@ -4,12 +4,7 @@ import { IReportMetaForm } from 'components/attachments/ReportMetaForm'; import { ISurveyCritter } from 'contexts/animalPageContext'; import { ISurveyAdvancedFilters } from 'features/summary/list-data/survey/SurveysListFilterForm'; import { ICreateCritter } from 'features/surveys/view/survey-animals/animal'; -import { - IAnimalDeployment, - ICreateAnimalDeployment, - IDeploymentTimespan, - ITelemetryPointCollection -} from 'features/surveys/view/survey-animals/telemetry-device/device'; +import { ICritterDetailedResponse, ICritterSimpleResponse } from 'interfaces/useCritterApi.interface'; import { IGetReportDetails, IUploadAttachmentResponse } from 'interfaces/useProjectApi.interface'; import { ICreateSurveyRequest, @@ -18,9 +13,13 @@ import { IGetSurveyAttachmentsResponse, IGetSurveyForUpdateResponse, IGetSurveyForViewResponse, - ISimpleCritterWithInternalId, - SurveyUpdateObject + IUpdateSurveyRequest } from 'interfaces/useSurveyApi.interface'; +import { + IAllTelemetryPointCollection, + IAnimalDeployment, + ICreateAnimalDeploymentPostData +} from 'interfaces/useTelemetryApi.interface'; import qs from 'qs'; import { ApiPaginationRequestOptions } from 'types/misc'; @@ -126,10 +125,10 @@ const useSurveyApi = (axios: AxiosInstance) => { * * @param {number} projectId * @param {number} surveyId - * @param {SurveyUpdateObject} surveyData + * @param {IUpdateSurveyRequest} surveyData * @return {*} {Promise} */ - const updateSurvey = async (projectId: number, surveyId: number, surveyData: SurveyUpdateObject): Promise => { + const updateSurvey = async (projectId: number, surveyId: number, surveyData: IUpdateSurveyRequest): Promise => { const { data } = await axios.put(`/api/project/${projectId}/survey/${surveyId}/update`, surveyData); return data; @@ -165,39 +164,6 @@ const useSurveyApi = (axios: AxiosInstance) => { return data; }; - /** - * Upload survey keyx files. - * - * @param {number} projectId - * @param {number} surveyId - * @param {File} file - * @param {CancelTokenSource} [cancelTokenSource] - * @param {(progressEvent: AxiosProgressEvent) => void} [onProgress] - * @return {*} {Promise} - */ - const uploadSurveyKeyx = async ( - projectId: number, - surveyId: number, - file: File, - cancelTokenSource?: CancelTokenSource, - onProgress?: (progressEvent: AxiosProgressEvent) => void - ): Promise => { - const req_message = new FormData(); - - req_message.append('media', file); - - const { data } = await axios.post( - `/api/project/${projectId}/survey/${surveyId}/attachments/keyx/upload`, - req_message, - { - cancelToken: cancelTokenSource?.token, - onUploadProgress: onProgress - } - ); - - return data; - }; - /** * Upload survey reports. * @@ -392,20 +358,55 @@ const useSurveyApi = (axios: AxiosInstance) => { * * @param {number} projectId * @param {number} surveyId - * @returns {ISimpleCritterWithInternalId[]} + * @returns {ICritterSimpleResponse[]} */ - const getSurveyCritters = async (projectId: number, surveyId: number): Promise => { + const getSurveyCritters = async (projectId: number, surveyId: number): Promise => { const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/critters`); return data; }; + /** + * Retrieve a list of critters associated with the given survey with details taken from critterbase. + * + * @param {number} projectId + * @param {number} surveyId + * @param {number} critterId + * @return {*} {Promise} + */ + const getCritterById = async ( + projectId: number, + surveyId: number, + critterId: number + ): Promise => { + const { data } = await axios.get( + `/api/project/${projectId}/survey/${surveyId}/critters/${critterId}?format=detailed` + ); + return data; + }; + + /** + * Retrieve a list of critters associated with the given survey with details from critterbase, including + * additional information such as captures and mortality + * + * @param {number} projectId + * @param {number} surveyId + * @return {*} {Promise} + */ + const getSurveyCrittersDetailed = async ( + projectId: number, + surveyId: number + ): Promise => { + const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/critters?format=detailed`); + return data; + }; + /** * Create a critter and add it to the list of critters associated with this survey. This will create a new critter in Critterbase. * * @param {number} projectId * @param {number} surveyId - * @param {Critter} critter Critter payload type - * @returns Count of affected rows + * @param {ICreateCritter} critter + * @return {*} {Promise} */ const createCritterAndAddToSurvey = async ( projectId: number, @@ -421,8 +422,8 @@ const useSurveyApi = (axios: AxiosInstance) => { * * @param {number} projectId * @param {number} surveyId - * @param {number} critterId - * @returns {*} + * @param {number[]} critterIds + * @return {*} {Promise} */ const removeCrittersFromSurvey = async ( projectId: number, @@ -436,20 +437,20 @@ const useSurveyApi = (axios: AxiosInstance) => { }; /** - * Add a new deployment with associated device hardware metadata. Must include critterbase critter id. + * Create a new deployment with associated device hardware metadata. Must include critterbase critter id. * * @param {number} projectId * @param {number} surveyId * @param {number} critterId - * @param {IAnimalTelemetryDevice & {critter_id: string}} body - * @returns {*} + * @param {Omit} body + * @return {*} {Promise<{ deploymentId: number }>} */ - const addDeployment = async ( + const createDeployment = async ( projectId: number, surveyId: number, - critterId: number, // Survey critter_id - body: ICreateAnimalDeployment // Critterbase critter_id - ): Promise => { + critterId: number, + body: Omit + ): Promise<{ deploymentId: number }> => { const { data } = await axios.post( `/api/project/${projectId}/survey/${surveyId}/critters/${critterId}/deployments`, body @@ -462,20 +463,17 @@ const useSurveyApi = (axios: AxiosInstance) => { * * @param {number} projectId * @param {number} surveyId - * @param {number} critterId - * @param {IDeploymentTimespan} body - * @returns {*} + * @param {number} deploymentId + * @param {ICreateAnimalDeploymentPostData} body + * @return {*} {Promise} */ const updateDeployment = async ( projectId: number, surveyId: number, - critterId: number, - body: IDeploymentTimespan + deploymentId: number, + body: ICreateAnimalDeploymentPostData ): Promise => { - const { data } = await axios.patch( - `/api/project/${projectId}/survey/${surveyId}/critters/${critterId}/deployments`, - body - ); + const { data } = await axios.put(`/api/project/${projectId}/survey/${surveyId}/deployments/${deploymentId}`, body); return data; }; @@ -484,22 +482,41 @@ const useSurveyApi = (axios: AxiosInstance) => { * * @param {number} projectId * @param {number} surveyId - * @returns {*} + * @return {*} {Promise} */ const getDeploymentsInSurvey = async (projectId: number, surveyId: number): Promise => { const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/deployments`); return data; }; + /** + * Get deployment by Id, using the integer Id from SIMS instead of the BCTW GUID + * + * @param {number} projectId + * @param {number} surveyId + * @param {number} deploymentId + * @return {*} {Promise} + */ + const getDeploymentById = async ( + projectId: number, + surveyId: number, + deploymentId: number + ): Promise => { + const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/deployments/${deploymentId}`); + return data; + }; + /** * Get all telemetry points for a critter in a survey within a given time span. * + * TODO: Unused? + * * @param {number} projectId * @param {number} surveyId * @param {number} critterId * @param {string} startDate * @param {string} endDate - * @return {*} {Promise} + * @return {*} {Promise} */ const getCritterTelemetry = async ( projectId: number, @@ -507,26 +524,27 @@ const useSurveyApi = (axios: AxiosInstance) => { critterId: number, startDate: string, endDate: string - ): Promise => { + ): Promise => { const { data } = await axios.get( `/api/project/${projectId}/survey/${surveyId}/critters/${critterId}/telemetry?startDate=${startDate}&endDate=${endDate}` ); return data; }; + /** - * Removes a deployment. Will trigger removal in both SIMS and BCTW. + * Ends a deployment. Will trigger removal in both SIMS and BCTW. * * @param {number} projectId * @param {number} surveyId * @param {number} critterId - * @param {string} deploymentId - * @returns {*} + * @param {number} deploymentId + * @return {*} {Promise} */ - const removeDeployment = async ( + const endDeployment = async ( projectId: number, surveyId: number, critterId: number, - deploymentId: string + deploymentId: number ): Promise => { const { data } = await axios.delete( `/api/project/${projectId}/survey/${surveyId}/critters/${critterId}/deployments/${deploymentId}` @@ -534,14 +552,26 @@ const useSurveyApi = (axios: AxiosInstance) => { return data; }; + /** + * Deletes a deployment. Will trigger deletion in SIMS and invalidates the deployment in BCTW. + * + * @param {number} projectId + * @param {number} surveyId + * @param {number} deploymentId + * @return {*} {Promise} + */ + const deleteDeployment = async (projectId: number, surveyId: number, deploymentId: number): Promise => { + const { data } = await axios.delete(`/api/project/${projectId}/survey/${surveyId}/deployments/${deploymentId}`); + return data; + }; + /** * Bulk upload Critters from CSV. * - * @async - * @param {File} file - Critters CSV. + * @param {File} file * @param {number} projectId * @param {number} surveyId - * @returns {Promise} + * @return {*} {Promise<{ survey_critter_ids: number[] }>} */ const importCrittersFromCsv = async ( file: File, @@ -563,9 +593,9 @@ const useSurveyApi = (axios: AxiosInstance) => { getSurveysBasicFieldsByProjectId, getSurveyForUpdate, findSurveys, + getDeploymentById, updateSurvey, uploadSurveyAttachments, - uploadSurveyKeyx, uploadSurveyReports, updateSurveyReportMetadata, getSurveyReportDetails, @@ -576,11 +606,14 @@ const useSurveyApi = (axios: AxiosInstance) => { getSurveyCritters, createCritterAndAddToSurvey, removeCrittersFromSurvey, - addDeployment, + createDeployment, + getSurveyCrittersDetailed, getDeploymentsInSurvey, + getCritterById, updateDeployment, getCritterTelemetry, - removeDeployment, + endDeployment, + deleteDeployment, importCrittersFromCsv }; }; diff --git a/app/src/hooks/api/useTaxonomyApi.ts b/app/src/hooks/api/useTaxonomyApi.ts index 6aa8d92d52..23635a87e1 100644 --- a/app/src/hooks/api/useTaxonomyApi.ts +++ b/app/src/hooks/api/useTaxonomyApi.ts @@ -1,5 +1,5 @@ import { useConfigContext } from 'hooks/useContext'; -import { IPartialTaxonomy, ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { IPartialTaxonomy, ITaxonomy, ITaxonomyHierarchy } from 'interfaces/useTaxonomyApi.interface'; import { startCase } from 'lodash-es'; import qs from 'qs'; import useAxios from './useAxios'; @@ -32,6 +32,25 @@ const useTaxonomyApi = () => { return parseSearchResponse(data.searchResponse); }; + /** + * Retrieves parent taxons for multiple TSNs + * + * @param {number[]} tsns + * @return {*} {Promise} + */ + const getTaxonHierarchyByTSNs = async (tsns: number[]): Promise => { + const { data } = await apiAxios.get('/api/taxonomy/taxon/tsn/hierarchy', { + params: { + tsn: [...new Set(tsns)] + }, + paramsSerializer: (params) => { + return qs.stringify(params); + } + }); + + return data; + }; + /** * Search for taxon records by search terms. * @@ -59,13 +78,20 @@ const useTaxonomyApi = () => { return { getSpeciesFromIds, - searchSpeciesByTerms + searchSpeciesByTerms, + getTaxonHierarchyByTSNs }; }; /** * Parses the taxon search response into start case. * + * The case of scientific names should not be modified. Genus names and higher are capitalized while + * species-level and subspecies-level names (the second and third words in a species/subspecies name) are not capitalized. + * Example: Ursus americanus, Rangifier tarandus caribou, Mammalia, Alces alces. + * + * The case of common names is less standardized and often just preference. + * * @template T * @param {T[]} searchResponse - Array of Taxonomy objects * @returns {T[]} Correctly cased Taxonomy @@ -74,7 +100,7 @@ const parseSearchResponse = (searchResponse: T[]): T return searchResponse.map((taxon) => ({ ...taxon, commonNames: taxon.commonNames.map((commonName) => startCase(commonName)), - scientificName: startCase(taxon.scientificName) + scientificName: taxon.scientificName })); }; diff --git a/app/src/hooks/api/useTelemetryApi.test.ts b/app/src/hooks/api/useTelemetryApi.test.ts index 8c66b9bb2a..2d518c4b2c 100644 --- a/app/src/hooks/api/useTelemetryApi.test.ts +++ b/app/src/hooks/api/useTelemetryApi.test.ts @@ -45,4 +45,43 @@ describe('useTelemetryApi', () => { expect(result).toEqual(mockResponse); }); + + describe('getCodeValues', () => { + it('should return a list of code values', async () => { + const mockCodeValues = { + code_header_title: 'code_header_title', + code_header_name: 'code_header_name', + id: 123, + description: 'description', + long_description: 'long_description' + }; + + mock.onGet('/api/telemetry/code?codeHeader=code_header_name').reply(200, [mockCodeValues]); + const result = await useTelemetryApi(axios).getCodeValues('code_header_name'); + expect(result).toEqual([mockCodeValues]); + }); + + it('should catch errors', async () => { + mock.onGet('/api/telemetry/code?codeHeader=code_header_name').reply(500, 'error'); + const result = await useTelemetryApi(axios).getCodeValues('code_header_name'); + expect(result).toEqual([]); + }); + }); + + describe('uploadTelemetryDeviceCredentialFile', () => { + it('should upload a keyx file', async () => { + const projectId = 1; + const surveyId = 2; + + const file = new File([''], 'file.keyx', { type: 'application/keyx' }); + const response = { + attachmentId: 'attachment', + revision_count: 1 + }; + mock.onPost(`/api/project/${projectId}/survey/${surveyId}/attachments/telemetry`).reply(201, response); + + const result = await useTelemetryApi(axios).uploadTelemetryDeviceCredentialFile(projectId, surveyId, file); + expect(result).toEqual(response); + }); + }); }); diff --git a/app/src/hooks/api/useTelemetryApi.ts b/app/src/hooks/api/useTelemetryApi.ts index 08f51bd1ce..62d2d9e22b 100644 --- a/app/src/hooks/api/useTelemetryApi.ts +++ b/app/src/hooks/api/useTelemetryApi.ts @@ -1,7 +1,15 @@ -import { AxiosInstance } from 'axios'; -import { ITelemetryAdvancedFilters } from 'features/summary/tabular-data/telemetry/TelemetryListFilterForm'; - -import { IFindTelemetryResponse } from 'interfaces/useTelemetryApi.interface'; +import { AxiosInstance, AxiosProgressEvent, CancelTokenSource } from 'axios'; +import { IAllTelemetryAdvancedFilters } from 'features/summary/tabular-data/telemetry/TelemetryListFilterForm'; +import { IUploadAttachmentResponse } from 'interfaces/useProjectApi.interface'; +import { + IAllTelemetry, + ICodeResponse, + ICreateManualTelemetry, + IFindTelemetryResponse, + IManualTelemetry, + IUpdateManualTelemetry, + TelemetryDeviceKeyFile +} from 'interfaces/useTelemetryApi.interface'; import qs from 'qs'; import { ApiPaginationRequestOptions } from 'types/misc'; @@ -16,12 +24,12 @@ const useTelemetryApi = (axios: AxiosInstance) => { * Get telemetry for a system user id. * * @param {ApiPaginationRequestOptions} [pagination] - * @param {ITelemetryAdvancedFilters} filterFieldData + * @param {IAllTelemetryAdvancedFilters} filterFieldData * @return {*} {Promise} */ const findTelemetry = async ( pagination?: ApiPaginationRequestOptions, - filterFieldData?: ITelemetryAdvancedFilters + filterFieldData?: IAllTelemetryAdvancedFilters ): Promise => { const params = { ...pagination, @@ -33,7 +41,180 @@ const useTelemetryApi = (axios: AxiosInstance) => { return data; }; - return { findTelemetry }; + /** + * Get list of manual and vendor telemetry by deployment ids + * + * @param {string[]} deploymentIds BCTW deployment ids + * @return {*} {Promise} + */ + const getAllTelemetryByDeploymentIds = async (deploymentIds: string[]): Promise => { + const { data } = await axios.get('/api/telemetry/deployments', { + params: { + bctwDeploymentIds: deploymentIds + } + }); + return data; + }; + + /** + * Bulk create Manual Telemetry + * + * @param {ICreateManualTelemetry[]} manualTelemetry Manual Telemetry create objects + * @return {*} {Promise} + */ + const createManualTelemetry = async ( + manualTelemetry: ICreateManualTelemetry[] + ): Promise => { + const { data } = await axios.post('/api/telemetry/manual', manualTelemetry); + return data; + }; + + /** + * Bulk update Manual Telemetry + * + * @param {IUpdateManualTelemetry[]} manualTelemetry Manual Telemetry update objects + * @return {*} + */ + const updateManualTelemetry = async (manualTelemetry: IUpdateManualTelemetry[]) => { + const { data } = await axios.patch('/api/telemetry/manual', manualTelemetry); + return data; + }; + + /** + * Delete manual telemetry records + * + * @param {string[]} telemetryIds Manual Telemetry ids to delete + * @return {*} + */ + const deleteManualTelemetry = async (telemetryIds: string[]) => { + const { data } = await axios.post('/api/telemetry/manual/delete', telemetryIds); + return data; + }; + + /** + * Uploads a telemetry CSV for import. + * + * @param {number} projectId + * @param {number} surveyId + * @param {File} file + * @param {CancelTokenSource} [cancelTokenSource] + * @param {(progressEvent: AxiosProgressEvent) => void} [onProgress] + * @return {*} {Promise<{ submission_id: number }>} + */ + const uploadCsvForImport = async ( + projectId: number, + surveyId: number, + file: File, + cancelTokenSource?: CancelTokenSource, + onProgress?: (progressEvent: AxiosProgressEvent) => void + ): Promise<{ submission_id: number }> => { + const formData = new FormData(); + + formData.append('media', file); + + const { data } = await axios.post<{ submission_id: number }>( + `/api/project/${projectId}/survey/${surveyId}/telemetry/upload`, + formData, + { + cancelToken: cancelTokenSource?.token, + onUploadProgress: onProgress + } + ); + return data; + }; + + /** + * Begins processing an uploaded telemetry CSV for import + * + * @param {number} submissionId + * @return {*} + */ + const processTelemetryCsvSubmission = async (submissionId: number) => { + const { data } = await axios.post('/api/telemetry/manual/process', { + submission_id: submissionId + }); + + return data; + }; + + /** + * Returns a list of code values for a given code header. + * + * @param {string} codeHeader + * @return {*} {Promise} + */ + const getCodeValues = async (codeHeader: string): Promise => { + try { + const { data } = await axios.get(`/api/telemetry/code?codeHeader=${codeHeader}`); + return data; + } catch (e) { + if (e instanceof Error) { + console.error(e.message); + } + } + return []; + }; + + /** + * Upload a telemetry device credential file. + * + * @param {number} projectId + * @param {number} surveyId + * @param {File} file + * @param {CancelTokenSource} [cancelTokenSource] + * @param {(progressEvent: AxiosProgressEvent) => void} [onProgress] + * @return {*} {Promise} + */ + const uploadTelemetryDeviceCredentialFile = async ( + projectId: number, + surveyId: number, + file: File, + cancelTokenSource?: CancelTokenSource, + onProgress?: (progressEvent: AxiosProgressEvent) => void + ): Promise => { + const req_message = new FormData(); + + req_message.append('media', file); + + const { data } = await axios.post( + `/api/project/${projectId}/survey/${surveyId}/attachments/telemetry`, + req_message, + { + cancelToken: cancelTokenSource?.token, + onUploadProgress: onProgress + } + ); + + return data; + }; + + /** + * Get all uploaded telemetry device credential key files. + * + * @param {number} projectId + * @param {number} surveyId + * @return {*} {Promise} + */ + const getTelemetryDeviceKeyFiles = async (projectId: number, surveyId: number): Promise => { + const { data } = await axios.get<{ telemetryAttachments: TelemetryDeviceKeyFile[] }>( + `/api/project/${projectId}/survey/${surveyId}/attachments/telemetry` + ); + + return data.telemetryAttachments; + }; + + return { + findTelemetry, + getAllTelemetryByDeploymentIds, + createManualTelemetry, + updateManualTelemetry, + deleteManualTelemetry, + uploadCsvForImport, + processTelemetryCsvSubmission, + getCodeValues, + uploadTelemetryDeviceCredentialFile, + getTelemetryDeviceKeyFiles + }; }; export default useTelemetryApi; diff --git a/app/src/hooks/cb_api/useCritterApi.tsx b/app/src/hooks/cb_api/useCritterApi.tsx index 8ced897d93..8a40403043 100644 --- a/app/src/hooks/cb_api/useCritterApi.tsx +++ b/app/src/hooks/cb_api/useCritterApi.tsx @@ -1,5 +1,6 @@ import { AxiosInstance } from 'axios'; import { IBulkCreate, IBulkUpdate, ICreateCritter } from 'features/surveys/view/survey-animals/animal'; +import { IGetCaptureMortalityGeometryResponse } from 'interfaces/useAnimalApi.interface'; import { ICritterDetailedResponse, ICritterSimpleResponse } from 'interfaces/useCritterApi.interface'; const useCritterApi = (axios: AxiosInstance) => { @@ -69,16 +70,33 @@ const useCritterApi = (axios: AxiosInstance) => { * * @async * @param {string[]} critter_ids - Critter identifiers. - * @returns {Promise} + * @returns {Promise} + */ + // TODO: Fix critterbase bug. This endpoint returns an empty array when ?format=detailed. + const getMultipleCrittersByIds = async (critter_ids: string[]): Promise => { + const { data } = await axios.post(`/api/critterbase/critters?format=detailed`, { critter_ids }); + return data; + }; + + /** + * Get capture and mortality geometry for multiple critter Ids. + * + * @async + * @param {string[]} critter_ids - Critter identifiers. + * @returns {Promise} */ - const getMultipleCrittersByIds = async (critter_ids: string[]): Promise => { - const { data } = await axios.post(`/api/critterbase/critters`, { critter_ids }); + // TODO: Fix critterbase bug. This endpoint returns an empty array when ?format=detailed. + const getMultipleCrittersGeometryByIds = async ( + critter_ids: string[] + ): Promise => { + const { data } = await axios.post(`/api/critterbase/critters/spatial`, { critter_ids }); return data; }; return { getDetailedCritter, getMultipleCrittersByIds, + getMultipleCrittersGeometryByIds, createCritter, updateCritter, bulkCreate, diff --git a/app/src/hooks/cb_api/useFamilyApi.test.tsx b/app/src/hooks/cb_api/useFamilyApi.test.tsx deleted file mode 100644 index e9f9d0b293..0000000000 --- a/app/src/hooks/cb_api/useFamilyApi.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; -import { v4 } from 'uuid'; -import { useFamilyApi } from './useFamilyApi'; - -describe('useFamily', () => { - let mock: MockAdapter; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - const family = { - family_id: v4(), - family_label: 'fam' - }; - - const immediateFamily = { - parents: [], - children: [] - }; - - it('should return a list of families', async () => { - mock.onGet('/api/critterbase/family').reply(200, [family]); - const result = await useFamilyApi(axios).getAllFamilies(); - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBe(1); - expect(result[0].family_id).toBeDefined(); - }); - - it('should return an immediate family by id', async () => { - const familyId = v4(); - mock.onGet('/api/critterbase/family/' + familyId).reply(200, immediateFamily); - const result = await useFamilyApi(axios).getImmediateFamily(familyId); - expect(Array.isArray(result.parents)).toBe(true); - expect(Array.isArray(result.children)).toBe(true); - }); -}); diff --git a/app/src/hooks/cb_api/useFamilyApi.tsx b/app/src/hooks/cb_api/useFamilyApi.tsx deleted file mode 100644 index 15f293c90c..0000000000 --- a/app/src/hooks/cb_api/useFamilyApi.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { AxiosInstance } from 'axios'; -import { AnimalRelationship } from 'features/surveys/view/survey-animals/animal'; -import { IFamilyChildResponse, IFamilyParentResponse } from 'interfaces/useCritterApi.interface'; -import { v4 } from 'uuid'; - -interface ICritterStub { - critter_id: string; - animal_id: string | null; -} - -export type IFamily = { - family_id: string; - family_label: string; -}; - -export type IImmediateFamily = { - parents: ICritterStub[]; - siblings: ICritterStub[]; - children: ICritterStub[]; -}; - -type CreateFamilyRelationshipPayload = { - relationship: AnimalRelationship; - family_label?: string; - family_id?: string; - critter_id: string; -}; - -export const useFamilyApi = (axios: AxiosInstance) => { - /** - * Get all Critterbase families. - * - * @async - * @returns {Promise} Critter families. - */ - const getAllFamilies = async (): Promise => { - const { data } = await axios.get('/api/critterbase/family'); - - return data; - }; - - /** - * Get immediate family of a specific critter. - * - * @async - * @param {string} family_id - Family primary key identifier. - * @returns {Promise} The critters parents, children and siblings. - */ - const getImmediateFamily = async (family_id: string): Promise => { - const { data } = await axios.get(`/api/critterbase/family/${family_id}`); - - return data; - }; - - /** - * Create a new family. - * Families must be created before parents or children can be added. - * - * @async - * @param {string} label - The family's label. example: `caribou-2024-skeena-family` - * @returns {Promise} Critter family. - */ - const createFamily = async (label: string): Promise => { - const { data } = await axios.post(`/api/critterbase/family/create`, { family_id: v4(), family_label: label }); - - return data; - }; - - /** - * Edit a family label. - * - * @async - * @param {string} family_id - The id of the family. - * @param {string} label - New family label. example: `caribou-2025-skeena-family-v2` - * @returns {Promise} Critter family. - */ - const editFamily = async (family_id: string, label: string) => { - const { data } = await axios.patch(`/api/critterbase/family/${family_id}`, { family_label: label }); - - return data; - }; - - /** - * Create (parent or child) relationship of a family. - * - * @async - * @param {CreateFamilyRelationshipPayload} payload - Create relationship payload. - * @returns {Promise} - */ - const createFamilyRelationship = async ( - payload: CreateFamilyRelationshipPayload - ): Promise => { - if (payload.relationship === AnimalRelationship.CHILD) { - const { data } = await axios.post(`/api/critterbase/family/children`, { - family_id: payload.family_id, - child_critter_id: payload.critter_id - }); - - return data; - } - - const { data } = await axios.post(`/api/critterbase/family/parents`, { - family_id: payload.family_id, - parent_critter_id: payload.critter_id - }); - - return data; - }; - - /** - * Delete a relationship (parent or child) of a family. - * - * @async - * @param {*} params - * @returns {Promise} Either parent or child delete response. - */ - const deleteRelationship = async (params: { - relationship: AnimalRelationship; - family_id: string; - critter_id: string; - }): Promise => { - const payload = - params.relationship === AnimalRelationship.CHILD - ? { family_id: params.family_id, child_critter_id: params.critter_id } - : { family_id: params.family_id, parent_critter_id: params.critter_id }; - - const { data } = await axios.delete(`/api/critterbase/family/${params.relationship}`, { data: payload }); - - return data; - }; - - return { - getAllFamilies, - getImmediateFamily, - editFamily, - deleteRelationship, - createFamily, - createFamilyRelationship - }; -}; diff --git a/app/src/hooks/cb_api/useXrefApi.tsx b/app/src/hooks/cb_api/useXrefApi.tsx index 14056c47ad..dcd8723fa7 100644 --- a/app/src/hooks/cb_api/useXrefApi.tsx +++ b/app/src/hooks/cb_api/useXrefApi.tsx @@ -5,6 +5,7 @@ import { ICollectionCategory, ICollectionUnit } from 'interfaces/useCritterApi.interface'; +import qs from 'qs'; export const useXrefApi = (axios: AxiosInstance) => { /** @@ -21,13 +22,21 @@ export const useXrefApi = (axios: AxiosInstance) => { /** * Get measurement definitions by search term. * - * @param {string} searchTerm + * @param {string} name + * @param {string[]} tsns * @return {*} {Promise} */ const getMeasurementTypeDefinitionsBySearchTerm = async ( - searchTerm: string + name: string, + tsns?: number[] ): Promise => { - const { data } = await axios.get(`/api/critterbase/xref/taxon-measurements/search?name=${searchTerm}`); + const t = tsns?.map((tsn) => Number(tsn)); + const { data } = await axios.get(`/api/critterbase/xref/taxon-measurements/search`, { + params: { name, tsns: t }, + paramsSerializer: (params) => { + return qs.stringify(params); + } + }); return data; }; @@ -38,12 +47,18 @@ export const useXrefApi = (axios: AxiosInstance) => { * @return {*} {Promise} */ const getTsnCollectionCategories = async (tsn: number): Promise => { - const { data } = await axios.get(`/api/critterbase/xref/taxon-collection-categories?tsn=${tsn}`); + const { data } = await axios.get('/api/critterbase/xref/taxon-collection-categories', { + params: { tsn }, + paramsSerializer: (params) => { + return qs.stringify(params); + } + }); + return data; }; /** - * Get collection (ie. ecological) units that are available for a given taxon + * Get collection (ie. ecological) units values for a given collection unit * * @param {string} unit_id * @return {*} {Promise} diff --git a/app/src/hooks/telemetry/useDeviceApi.test.tsx b/app/src/hooks/telemetry/useDeviceApi.test.tsx deleted file mode 100644 index 8d94d662c7..0000000000 --- a/app/src/hooks/telemetry/useDeviceApi.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; -import { Device } from 'features/surveys/view/survey-animals/telemetry-device/device'; -import { useDeviceApi } from './useDeviceApi'; - -describe('useDeviceApi', () => { - let mock: MockAdapter; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - const mockVendors = ['vendor1', 'vendor2']; - - const mockCodeValues = { - code_header_title: 'code_header_title', - code_header_name: 'code_header_name', - id: 123, - description: 'description', - long_description: 'long_description' - }; - - describe('getCollarVendors', () => { - it('should return a list of vendors', async () => { - mock.onGet('/api/telemetry/vendors').reply(200, mockVendors); - const result = await useDeviceApi(axios).getCollarVendors(); - expect(result).toEqual(mockVendors); - }); - - it('should catch errors', async () => { - mock.onGet('/api/telemetry/vendors').reply(500, 'error'); - const result = await useDeviceApi(axios).getCollarVendors(); - expect(result).toEqual([]); - }); - }); - - describe('getCodeValues', () => { - it('should return a list of code values', async () => { - mock.onGet('/api/telemetry/code?codeHeader=code_header_name').reply(200, [mockCodeValues]); - const result = await useDeviceApi(axios).getCodeValues('code_header_name'); - expect(result).toEqual([mockCodeValues]); - }); - - it('should catch errors', async () => { - mock.onGet('/api/telemetry/code?codeHeader=code_header_name').reply(500, 'error'); - const result = await useDeviceApi(axios).getCodeValues('code_header_name'); - expect(result).toEqual([]); - }); - }); - - describe('getDeviceDetails', () => { - it('should return device deployment details', async () => { - mock.onGet(`/api/telemetry/device/${123}`).reply(200, { device: undefined, deployments: [] }); - const result = await useDeviceApi(axios).getDeviceDetails(123, 'Vectronic'); - expect(result.deployments.length).toBe(0); - }); - - it('should catch errors', async () => { - mock.onGet(`/api/telemetry/device/${123}`).reply(500, 'error'); - const result = await useDeviceApi(axios).getDeviceDetails(123, 'Vectronic'); - expect(result.deployments.length).toBe(0); - expect(result.device).toBeUndefined(); - expect(result.keyXStatus).toBe(false); - }); - }); - - describe('upsertCollar', () => { - it('should upsert a collar', async () => { - const device = new Device({ device_id: 123, collar_id: 'abc' }); - mock.onPost(`/api/telemetry/device`).reply(200, { device_id: 123, collar_id: 'abc' }); - const result = await useDeviceApi(axios).upsertCollar(device); - expect(result.device_id).toBe(123); - }); - - it('should catch errors', async () => { - const device = new Device({ device_id: 123, collar_id: 'abc' }); - mock.onPost(`/api/telemetry/device`).reply(500, 'error'); - const result = await useDeviceApi(axios).upsertCollar(device); - expect(result).toEqual({}); - }); - }); -}); diff --git a/app/src/hooks/telemetry/useDeviceApi.tsx b/app/src/hooks/telemetry/useDeviceApi.tsx deleted file mode 100644 index 50f454e6b0..0000000000 --- a/app/src/hooks/telemetry/useDeviceApi.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { AxiosInstance } from 'axios'; -import { Device, IAnimalDeployment } from 'features/surveys/view/survey-animals/telemetry-device/device'; - -interface ICodeResponse { - code_header_title: string; - code_header_name: string; - id: number; - code: string; - description: string; - long_description: string; -} - -export interface IGetDeviceDetailsResponse { - device: Record | undefined; - keyXStatus: boolean; - deployments: Omit[]; -} - -/** - * Returns a set of functions for making device-related API calls. - * - * @param {AxiosInstance} axios - * @return {*} - */ -const useDeviceApi = (axios: AxiosInstance) => { - /** - * Returns a list of supported collar vendors. - * - * @return {*} {Promise} - */ - const getCollarVendors = async (): Promise => { - try { - const { data } = await axios.get('/api/telemetry/vendors'); - return data; - } catch (e) { - if (e instanceof Error) { - console.error(e.message); - } - } - return []; - }; - - /** - * Returns a list of code values for a given code header. - * - * @param {string} codeHeader - * @return {*} {Promise} - */ - const getCodeValues = async (codeHeader: string): Promise => { - try { - const { data } = await axios.get(`/api/telemetry/code?codeHeader=${codeHeader}`); - return data; - } catch (e) { - if (e instanceof Error) { - console.error(e.message); - } - } - return []; - }; - - /** - * Returns details for a given device. - * - * @param {number} deviceId - * @param {string} deviceMake - * @return {*} {Promise} - */ - const getDeviceDetails = async (deviceId: number, deviceMake: string): Promise => { - try { - const { data } = await axios.get(`/api/telemetry/device/${deviceId}?make=${deviceMake}`); - return data; - } catch (e) { - if (e instanceof Error) { - console.error(e.message); - } - } - return { device: undefined, keyXStatus: false, deployments: [] }; - }; - - /** - * Allows you to update a collar in bctw, invalidating the old record. - * @param {Device} body - * @returns {*} - */ - const upsertCollar = async (body: Device): Promise => { - try { - const { data } = await axios.post(`/api/telemetry/device`, body); - return data; - } catch (e) { - if (e instanceof Error) { - console.error(e.message); - } - } - return {}; - }; - - return { - getDeviceDetails, - getCollarVendors, - getCodeValues, - upsertCollar - }; -}; - -export { useDeviceApi }; diff --git a/app/src/hooks/useBioHubApi.ts b/app/src/hooks/useBioHubApi.ts index 2443e19f5e..0115bd2df5 100644 --- a/app/src/hooks/useBioHubApi.ts +++ b/app/src/hooks/useBioHubApi.ts @@ -3,6 +3,7 @@ import useReferenceApi from 'hooks/api/useReferenceApi'; import { useConfigContext } from 'hooks/useContext'; import { useMemo } from 'react'; import useAdminApi from './api/useAdminApi'; +import useAnalyticsApi from './api/useAnalyticsApi'; import useAnimalApi from './api/useAnimalApi'; import useAxios from './api/useAxios'; import useCodesApi from './api/useCodesApi'; @@ -31,6 +32,8 @@ export const useBiohubApi = () => { const config = useConfigContext(); const apiAxios = useAxios(config.API_HOST); + const analytics = useAnalyticsApi(apiAxios); + const project = useProjectApi(apiAxios); const projectParticipants = useProjectParticipationApi(apiAxios); @@ -71,6 +74,7 @@ export const useBiohubApi = () => { return useMemo( () => ({ + analytics, project, projectParticipants, taxonomy, diff --git a/app/src/hooks/useContext.tsx b/app/src/hooks/useContext.tsx index e07c93c36b..39da771e41 100644 --- a/app/src/hooks/useContext.tsx +++ b/app/src/hooks/useContext.tsx @@ -8,7 +8,8 @@ import { IObservationsTableContext, ObservationsTableContext } from 'contexts/ob import { IProjectContext, ProjectContext } from 'contexts/projectContext'; import { ISurveyContext, SurveyContext } from 'contexts/surveyContext'; import { ITaxonomyContext, TaxonomyContext } from 'contexts/taxonomyContext'; -import { ITelemetryTableContext, TelemetryTableContext } from 'contexts/telemetryTableContext'; +import { ITelemetryDataContext, TelemetryDataContext } from 'contexts/telemetryDataContext'; +import { IAllTelemetryTableContext, TelemetryTableContext } from 'contexts/telemetryTableContext'; import { useContext } from 'react'; /** @@ -148,11 +149,28 @@ export const useObservationsTableContext = (): IObservationsTableContext => { }; /** - * Returns an instance of `IObservationsTableContext` from `ObservationsTableContext`. + * Returns an instance of `ITelemetryDataContext` from `TelemetryDataContext`. * - * @return {*} {IObservationsTableContext} + * @return {*} {ITelemetryDataContext} */ -export const useTelemetryTableContext = (): ITelemetryTableContext => { +export const useTelemetryDataContext = (): ITelemetryDataContext => { + const context = useContext(TelemetryDataContext); + + if (!context) { + throw Error( + 'TelemetryDataContext is undefined, please verify you are calling useTelemetryDataContext() as child of an component.' + ); + } + + return context; +}; + +/** + * Returns an instance of `ITelemetryTableContext` from `TelemetryTableContext`. + * + * @return {*} {ITelemetryTableContext} + */ +export const useTelemetryTableContext = (): IAllTelemetryTableContext => { const context = useContext(TelemetryTableContext); if (!context) { @@ -165,7 +183,7 @@ export const useTelemetryTableContext = (): ITelemetryTableContext => { }; /** - * Returns an instance of `ITaxonomyContext` from `SurveyContext`. + * Returns an instance of `ITaxonomyContext` from `TaxonomyContext`. * * @return {*} {ITaxonomyContext} */ @@ -184,7 +202,7 @@ export const useTaxonomyContext = (): ITaxonomyContext => { /** * Returns an instance of `IAnimalPageContext` from `AnimalPageContext`. * - * @return {*} {ISurveyContext} + * @return {*} {IAnimalPageContext} */ export const useAnimalPageContext = (): IAnimalPageContext => { const context = useContext(AnimalPageContext); diff --git a/app/src/hooks/useCritterbaseApi.ts b/app/src/hooks/useCritterbaseApi.ts index d142fcce4d..308455853c 100644 --- a/app/src/hooks/useCritterbaseApi.ts +++ b/app/src/hooks/useCritterbaseApi.ts @@ -6,7 +6,6 @@ import { useAuthentication } from './cb_api/useAuthenticationApi'; import { useCaptureApi } from './cb_api/useCaptureApi'; import { useCollectionUnitApi } from './cb_api/useCollectionUnitApi'; import { useCritterApi } from './cb_api/useCritterApi'; -import { useFamilyApi } from './cb_api/useFamilyApi'; import { useLookupApi } from './cb_api/useLookupApi'; import { useMarkingApi } from './cb_api/useMarkingApi'; import { useMeasurementApi } from './cb_api/useMeasurementApi'; @@ -27,8 +26,6 @@ export const useCritterbaseApi = () => { const lookup = useLookupApi(apiAxios); - const family = useFamilyApi(apiAxios); - const xref = useXrefApi(apiAxios); const marking = useMarkingApi(apiAxios); @@ -46,7 +43,6 @@ export const useCritterbaseApi = () => { critters, authentication, lookup, - family, xref, marking, collectionUnit, diff --git a/app/src/hooks/useTelemetryApi.ts b/app/src/hooks/useTelemetryApi.ts deleted file mode 100644 index dbf2f1ea5e..0000000000 --- a/app/src/hooks/useTelemetryApi.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { AxiosProgressEvent, CancelTokenSource } from 'axios'; -import { useConfigContext } from 'hooks/useContext'; -import useAxios from './api/useAxios'; -import { useDeviceApi } from './telemetry/useDeviceApi'; - -export interface ICritterDeploymentResponse { - critter_id: string; - device_id: number; - deployment_id: string; - survey_critter_id: string; - alias: string; - attachment_start: string; - attachment_end?: string; - taxon: string; -} - -export interface IUpdateManualTelemetry { - telemetry_manual_id: string; - latitude: number; - longitude: number; - acquisition_date: string; -} -export interface ICreateManualTelemetry { - deployment_id: string; - latitude: number; - longitude: number; - acquisition_date: string; -} - -export interface IManualTelemetry extends ICreateManualTelemetry { - telemetry_manual_id: string; -} - -export interface IVendorTelemetry extends ICreateManualTelemetry { - telemetry_id: string; -} - -export interface ITelemetry { - id: string; - deployment_id: string; - telemetry_manual_id: string; - telemetry_id: number | null; - latitude: number; - longitude: number; - acquisition_date: string; - telemetry_type: string; -} - -export const useTelemetryApi = () => { - const config = useConfigContext(); - const axios = useAxios(config.API_HOST); - const devices = useDeviceApi(axios); - - /** - * Get list of manual and vendor telemetry by deployment ids - * - * @param {string[]} deploymentIds BCTW deployment ids - * @return {*} {Promise} - */ - const getAllTelemetryByDeploymentIds = async (deploymentIds: string[]): Promise => { - const { data } = await axios.post('/api/telemetry/deployments', deploymentIds); - return data; - }; - - /** - * Get a list of vendor retrieved telemetry by deployment ids - * - * @param {string[]} deploymentIds Vendor telemetry deployment ids - * @return {*} {Promise} - */ - const getVendorTelemetryByDeploymentIds = async (deploymentIds: string[]): Promise => { - const { data } = await axios.post('/api/telemetry/vendor/deployments', deploymentIds); - return data; - }; - - /** - * Get a list of manually created telemetry by deployment ids - * - * @param {string[]} deploymentIds Manual Telemetry deployment ids - * @return {*} {Promise} - */ - const getManualTelemetryByDeploymentIds = async (deploymentIds: string[]): Promise => { - const { data } = await axios.post('/api/telemetry/manual/deployments', deploymentIds); - return data; - }; - - /** - * Bulk create Manual Telemetry - * - * @param {ICreateManualTelemetry[]} manualTelemetry Manual Telemetry create objects - * @return {*} {Promise} - */ - const createManualTelemetry = async ( - manualTelemetry: ICreateManualTelemetry[] - ): Promise => { - const { data } = await axios.post('/api/telemetry/manual', manualTelemetry); - return data; - }; - - /** - * Bulk update Manual Telemetry - * - * @param {IUpdateManualTelemetry[]} manualTelemetry Manual Telemetry update objects - * @return {*} - */ - const updateManualTelemetry = async (manualTelemetry: IUpdateManualTelemetry[]) => { - const { data } = await axios.patch('/api/telemetry/manual', manualTelemetry); - return data; - }; - - /** - * Delete manual telemetry records - * - * @param {string[]} telemetryIds Manual Telemetry ids to delete - * @return {*} - */ - const deleteManualTelemetry = async (telemetryIds: string[]) => { - const { data } = await axios.post('/api/telemetry/manual/delete', telemetryIds); - return data; - }; - - /** - * Uploads a telemetry CSV for import. - * - * @param {number} projectId - * @param {number} surveyId - * @param {File} file - * @param {CancelTokenSource} [cancelTokenSource] - * @param {(progressEvent: AxiosProgressEvent) => void} [onProgress] - * @return {*} {Promise<{ submission_id: number }>} - */ - const uploadCsvForImport = async ( - projectId: number, - surveyId: number, - file: File, - cancelTokenSource?: CancelTokenSource, - onProgress?: (progressEvent: AxiosProgressEvent) => void - ): Promise<{ submission_id: number }> => { - const formData = new FormData(); - - formData.append('media', file); - - const { data } = await axios.post<{ submission_id: number }>( - `/api/project/${projectId}/survey/${surveyId}/telemetry/upload`, - formData, - { - cancelToken: cancelTokenSource?.token, - onUploadProgress: onProgress - } - ); - return data; - }; - - /** - * Begins processing an uploaded telemetry CSV for import - * - * @param {number} submissionId - * @return {*} - */ - const processTelemetryCsvSubmission = async (submissionId: number) => { - const { data } = await axios.post(`/api/telemetry/manual/process`, { - submission_id: submissionId - }); - - return data; - }; - - return { - devices, - getAllTelemetryByDeploymentIds, - getManualTelemetryByDeploymentIds, - createManualTelemetry, - updateManualTelemetry, - getVendorTelemetryByDeploymentIds, - deleteManualTelemetry, - uploadCsvForImport, - processTelemetryCsvSubmission - }; -}; diff --git a/app/src/interfaces/useAdminApi.interface.ts b/app/src/interfaces/useAdminApi.interface.ts index 2f2140928d..41bc926d27 100644 --- a/app/src/interfaces/useAdminApi.interface.ts +++ b/app/src/interfaces/useAdminApi.interface.ts @@ -29,6 +29,8 @@ export interface IGetAccessRequestsListResponse { description: string; notes: string; create_date: string; + update_date: string | null; + updated_by: string | null; data: IAccessRequestDataObject; } diff --git a/app/src/interfaces/useAnalyticsApi.interface.ts b/app/src/interfaces/useAnalyticsApi.interface.ts new file mode 100644 index 0000000000..1e15f26046 --- /dev/null +++ b/app/src/interfaces/useAnalyticsApi.interface.ts @@ -0,0 +1,31 @@ +interface IQualitativeMeasurementGroup { + taxon_measurement_id: string; + measurement_name: string; + option: { + option_id: string; + option_label: string; + }; +} + +interface IQuantitativeMeasurementGroup { + taxon_measurement_id: string; + measurement_name: string; + value: number; +} + +export interface IObservationCountByGroup { + /** + * Randomly generated unique ID for the group. + */ + id: string; + row_count: number; + individual_count: number; + individual_percentage: number; + itis_tsn?: number; + observation_date?: string; + survey_sample_site_id?: number; + survey_sample_method_id?: number; + survey_sample_period_id?: number; + qualitative_measurements: IQualitativeMeasurementGroup[]; + quantitative_measurements: IQuantitativeMeasurementGroup[]; +} diff --git a/app/src/interfaces/useAnimalApi.interface.ts b/app/src/interfaces/useAnimalApi.interface.ts index 02b9243480..61cbc9f397 100644 --- a/app/src/interfaces/useAnimalApi.interface.ts +++ b/app/src/interfaces/useAnimalApi.interface.ts @@ -22,3 +22,27 @@ export interface IFindAnimalsResponse { animals: IFindAnimalObj[]; pagination: ApiPaginationResponseParams; } + +/** + * Interface representing the geometry of a mortality event. + */ +interface ICaptureGeometry { + capture_id: string; + coordinates: [number, number]; +} + +/** + * Interface representing the geometry of a mortality event. + */ +interface IMortalityGeometry { + mortality_id: string; + coordinates: [number, number]; +} + +/** + * Interface representing combined capture and mortality geometries + */ +export interface IGetCaptureMortalityGeometryResponse { + captures: ICaptureGeometry[]; + mortalities: IMortalityGeometry[]; +} diff --git a/app/src/interfaces/useCodesApi.interface.ts b/app/src/interfaces/useCodesApi.interface.ts index eed3a36d6c..92310c26b4 100644 --- a/app/src/interfaces/useCodesApi.interface.ts +++ b/app/src/interfaces/useCodesApi.interface.ts @@ -34,7 +34,6 @@ export interface IGetAllCodeSetsResponse { project_roles: CodeSet; administrative_activity_status_type: CodeSet; intended_outcomes: CodeSet<{ id: number; name: string; description: string }>; - vantage_codes: CodeSet; survey_jobs: CodeSet; site_selection_strategies: CodeSet; survey_progress: CodeSet<{ id: number; name: string; description: string }>; diff --git a/app/src/interfaces/useCritterApi.interface.ts b/app/src/interfaces/useCritterApi.interface.ts index 810e8a6d0f..f913f7cfdd 100644 --- a/app/src/interfaces/useCritterApi.interface.ts +++ b/app/src/interfaces/useCritterApi.interface.ts @@ -1,4 +1,4 @@ -import { ICreateCritterCollectionUnit } from 'features/surveys/view/survey-animals/animal'; +import { AnimalSex, ICreateCritterCollectionUnit } from 'features/surveys/view/survey-animals/animal'; import { Feature } from 'geojson'; import { IPartialTaxonomy } from './useTaxonomyApi.interface'; @@ -6,7 +6,7 @@ export interface ICritterCreate { critter_id?: string; wlh_id?: string | null; animal_id?: string | null; - sex: string; + sex: AnimalSex; itis_tsn: number; responsible_region_nr_id?: string | null; critter_comment?: string | null; @@ -16,6 +16,7 @@ export interface ICreateEditAnimalRequest { critter_id?: string; nickname: string; species: IPartialTaxonomy | null; + sex: AnimalSex; ecological_units: ICreateCritterCollectionUnit[]; wildlife_health_id: string | null; critter_comment: string | null; @@ -103,6 +104,11 @@ export interface IEditMortalityRequest extends IMarkings, IMeasurementsUpdate { mortality: IMortalityPostData; } +export interface ICollectionUnitMultiTsnResponse { + tsn: number; + categories: ICollectionCategory[]; +} + export interface ICollectionCategory { collection_category_id: string; category_name: string; @@ -269,7 +275,8 @@ export type IFamilyChildResponse = { }; export type ICritterDetailedResponse = { - critter_id: string; + critter_id: number; + critterbase_critter_id: string; itis_tsn: number; itis_scientific_name: string; wlh_id: string | null; @@ -290,7 +297,8 @@ export type ICritterDetailedResponse = { }; export interface ICritterSimpleResponse { - critter_id: string; + critter_id: number; + critterbase_critter_id: string; wlh_id: string | null; animal_id: string | null; sex: string; @@ -386,6 +394,6 @@ export type CBMeasurementSearchByTsnResponse = { * Response object when searching for measurement type definitions by search term. */ export type CBMeasurementSearchByTermResponse = { - qualitative: (CBQualitativeMeasurementTypeDefinition & { tsnHierarchy: number[] })[]; - quantitative: (CBQuantitativeMeasurementTypeDefinition & { tsnHierarchy: number[] })[]; + qualitative: CBQualitativeMeasurementTypeDefinition[]; + quantitative: CBQuantitativeMeasurementTypeDefinition[]; }; diff --git a/app/src/interfaces/useObservationApi.interface.ts b/app/src/interfaces/useObservationApi.interface.ts index 05a861f614..aa0e695c8d 100644 --- a/app/src/interfaces/useObservationApi.interface.ts +++ b/app/src/interfaces/useObservationApi.interface.ts @@ -13,11 +13,13 @@ export interface IGetSurveyObservationsResponse { pagination: ApiPaginationResponseParams; } +export interface IGetSurveyObservationsGeometryObject { + survey_observation_id: number; + geometry: GeoJSON.Point; +} + export interface IGetSurveyObservationsGeometryResponse { - surveyObservationsGeometry: { - survey_observation_id: number; - geometry: GeoJSON.Point; - }[]; + surveyObservationsGeometry: IGetSurveyObservationsGeometryObject[]; supplementaryObservationData: SupplementaryObservationData; } diff --git a/app/src/interfaces/useProjectApi.interface.ts b/app/src/interfaces/useProjectApi.interface.ts index 38adfe2455..384cbf0690 100644 --- a/app/src/interfaces/useProjectApi.interface.ts +++ b/app/src/interfaces/useProjectApi.interface.ts @@ -130,6 +130,10 @@ export interface IProjectsListItemData { * The types of the surveys in the project. */ types: number[]; + /** + * Members of the project + */ + members: { system_user_id: number; display_name: string }[]; } export interface IProjectUserRoles { diff --git a/app/src/interfaces/useSamplingSiteApi.interface.ts b/app/src/interfaces/useSamplingSiteApi.interface.ts index 675839738c..33799d4129 100644 --- a/app/src/interfaces/useSamplingSiteApi.interface.ts +++ b/app/src/interfaces/useSamplingSiteApi.interface.ts @@ -117,6 +117,7 @@ export interface IGetSampleMethodRecord { export interface IGetSampleMethodDetails extends IGetSampleMethodRecord { technique: { method_technique_id: number; + method_lookup_id: number; name: string; description: string; attractants: number[]; diff --git a/app/src/interfaces/useStandardsApi.interface.ts b/app/src/interfaces/useStandardsApi.interface.ts index fc58fd85ab..09069b72c3 100644 --- a/app/src/interfaces/useStandardsApi.interface.ts +++ b/app/src/interfaces/useStandardsApi.interface.ts @@ -4,13 +4,26 @@ import { ICollectionUnit } from './useCritterApi.interface'; +interface IStandardNameDescription { + name: string; + description: string; +} + +interface IQualitativeAttributeStandard extends IStandardNameDescription { + options: IStandardNameDescription[]; +} + +interface IQuantitativeAttributeStandard extends IStandardNameDescription { + unit: string; +} + /** * Data standards for a taxon * * @export - * @interface IGetSpeciesStandardsResponse + * @interface ISpeciesStandards */ -export interface IGetSpeciesStandardsResponse { +export interface ISpeciesStandards { tsn: number; scientificName: string; measurements: { @@ -22,5 +35,27 @@ export interface IGetSpeciesStandardsResponse { key: string; value: string; }[]; - ecological_units: ICollectionUnit[]; + ecologicalUnits: ICollectionUnit[]; +} + +/** + * Data standards for methods + * + * @export + * @interface IMethodStandard + */ +export interface IMethodStandard extends IStandardNameDescription { + method_lookup_id: number; + attributes: { qualitative: IQualitativeAttributeStandard[]; quantitative: IQuantitativeAttributeStandard[] }; +} + +/** + * Data standards for environments + * + * @export + * @interface IEnvironmentStandards + */ +export interface IEnvironmentStandards { + qualitative: IQualitativeAttributeStandard[]; + quantitative: IQuantitativeAttributeStandard[]; } diff --git a/app/src/interfaces/useSurveyApi.interface.ts b/app/src/interfaces/useSurveyApi.interface.ts index 6059106f18..fd99d9a7fe 100644 --- a/app/src/interfaces/useSurveyApi.interface.ts +++ b/app/src/interfaces/useSurveyApi.interface.ts @@ -8,9 +8,12 @@ import { import { IGeneralInformationForm } from 'features/surveys/components/general-information/GeneralInformationForm'; import { ISurveyLocationForm } from 'features/surveys/components/locations/StudyAreaForm'; import { IPurposeAndMethodologyForm } from 'features/surveys/components/methodology/PurposeAndMethodologyForm'; -import { IBlockData } from 'features/surveys/components/sampling-strategy/blocks/BlockForm'; +import { ISurveyPermitForm } from 'features/surveys/components/permit/SurveyPermitForm'; +import { ISpeciesForm, ITaxonomyWithEcologicalUnits } from 'features/surveys/components/species/SpeciesForm'; +import { ISurveyPartnershipsForm } from 'features/surveys/view/components/SurveyPartnershipsForm'; import { Feature } from 'geojson'; import { ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { IAnimalDeployment } from 'interfaces/useTelemetryApi.interface'; import { ApiPaginationResponseParams, StringBoolean } from 'types/misc'; import { ICritterDetailedResponse, ICritterSimpleResponse } from './useCritterApi.interface'; @@ -27,7 +30,12 @@ export interface ICreateSurveyRequest IAgreementsForm, IParticipantsJobForm, ISurveyLocationForm, - ISurveyBlockForm {} + ISurveyBlockForm, + ISurveyPartnershipsForm, + ISurveySiteSelectionForm, + ISpeciesForm, + Omit, + Omit {} /** * Create survey response object. @@ -47,17 +55,10 @@ export interface IGetSurveyStratumForm { export interface IPostSurveyStratum { survey_stratum_id: number | null; name: string; - description?: string; + description: string | null; } export interface ISurveySiteSelectionForm { - site_selection: { - strategies: string[]; - stratums: IGetSurveyStratum[]; - }; -} - -export interface ISurveySiteSelectionUpdateObject { site_selection: { strategies: string[]; stratums: IPostSurveyStratum[]; @@ -91,7 +92,6 @@ export interface IGetSurveyForViewResponseDetails { export interface IGetSurveyForViewResponsePurposeAndMethodology { intended_outcome_ids: number[]; additional_details: string; - vantage_code_ids: number[]; } export interface IGetSurveyForViewResponseProprietor { @@ -176,40 +176,37 @@ export interface SurveyBasicFieldsObject { types: number[]; } -export type SurveyUpdateObject = ISurveyUpdateObject & ISurveySiteSelectionUpdateObject; - -interface ISurveyUpdateObject extends ISurveyLocationForm { - survey_details?: { +export type IUpdateSurveyRequest = ISurveyLocationForm & { + survey_details: { survey_name: string; + progress_id: number; start_date: string; end_date: string; survey_types: number[]; revision_count: number; }; - species?: { - focal_species: ITaxonomy[]; - ancillary_species: ITaxonomy[]; + species: { + focal_species: ITaxonomyWithEcologicalUnits[]; }; - permit?: { + permit: { permits: { - permit_id?: number; + permit_id?: number | null; permit_number: string; permit_type: string; }[]; }; - funding_sources?: { + funding_sources: { funding_source_id: number; amount: number; revision_count: number; }[]; - partnerships?: IGetSurveyForUpdateResponsePartnerships; - purpose_and_methodology?: { + partnerships: IGetSurveyForUpdateResponsePartnerships; + purpose_and_methodology: { intended_outcome_ids: number[]; additional_details: string; - vantage_code_ids: number[]; revision_count: number; }; - proprietor?: { + proprietor: { survey_data_proprietary: StringBoolean; proprietary_data_category: number; proprietor_name: string; @@ -217,9 +214,25 @@ interface ISurveyUpdateObject extends ISurveyLocationForm { category_rationale: string; disa_required: StringBoolean; }; - participants?: IGetSurveyParticipant[]; - blocks: IBlockData[]; -} + participants: IGetSurveyParticipant[]; + blocks: { + survey_block_id?: number | null; + name: string; + description: string; + }[]; + site_selection: { + strategies: string[]; + stratums: { + survey_stratum_id: number | null; + name: string; + description: string | null; + }[]; + }; + agreements: { + sedis_procedures_accepted: StringBoolean; + foippa_requirements_accepted: StringBoolean; + }; +}; export interface SurveySupplementaryData { survey_metadata_publish: { @@ -269,7 +282,6 @@ export interface IGetSurveyForViewResponse { export interface IGetSpecies { focal_species: ITaxonomy[]; - ancillary_species: ITaxonomy[]; } export interface IGetSurveyAttachment { @@ -335,22 +347,126 @@ export interface IUpdateAgreementsForm { } export interface IGetSurveyForUpdateResponse { - surveyData: SurveyUpdateObject; + surveyData: { + survey_details: { + id: number; + project_id: number; + uuid: string; + survey_name: string; + start_date: string; + end_date: string; + progress_id: number; + survey_types: number[]; + revision_count: number; + }; + species: { + focal_species: ITaxonomyWithEcologicalUnits[]; + }; + permit: { + permits: { + permit_id: number; + permit_number: string; + permit_type: string; + }[]; + }; + funding_sources: { + funding_source_id: number; + survey_id: number; + survey_funding_source_id: number; + amount: number; + funding_source_name: string; + start_date: string | null; + end_date: string | null; + description: string | null; + revision_count: number; + }[]; + partnerships: IGetSurveyForUpdateResponsePartnerships; + purpose_and_methodology: { + intended_outcome_ids: number[]; + additional_details: string; + revision_count: number; + }; + proprietor: { + proprietor_type_name: string; + proprietor_type_id: number; + first_nations_name: string | null; + first_nations_id: number; + category_rationale: string; + proprietor_name: string; + disa_required: StringBoolean; + survey_data_proprietary: StringBoolean; + proprietary_data_category: number; + }; + locations: { + survey_id: number; + survey_location_id: number; + name: string; + description: string; + geometry: null; + geography: string; + geojson: Feature[]; + revision_count: number; + }[]; + participants: { + system_user_id: number; + user_identifier: string; + user_guid: string; + record_end_date: string | null; + identity_source: string; + role_ids: number[]; + role_names: string[]; + email: string; + display_name: string; + given_name: string | null; + family_name: string | null; + agency: string | null; + survey_participation_id: number; + survey_id: number; + survey_job_id: number; + survey_job_name: string; + }[]; + site_selection: { + strategies: string[]; + stratums: { + survey_stratum_id: number; + survey_id: number; + name: string; + description: 's1111'; + sample_stratum_count: number; + revision_count: number; + }[]; + }; + blocks: { + survey_block_id: number; + survey_id: number; + name: string; + description: string; + sample_block_count: number; + revision_count: number; + }[]; + agreements: { + sedis_procedures_accepted: StringBoolean; + foippa_requirements_accepted: StringBoolean; + }; + }; } -export interface ISimpleCritterWithInternalId extends ICritterSimpleResponse { - survey_critter_id: number; +export interface IDetailedCritterWithInternalId extends ICritterDetailedResponse { + critter_id: number; //The internal critter_id in the SIMS DB. Called this to distinguish against the critterbase UUID of the same name. } -export interface IDetailedCritterWithInternalId extends ICritterDetailedResponse { - survey_critter_id: number; //The internal critter_id in the SIMS DB. Called this to distinguish against the critterbase UUID of the same name. +export interface IAnimalDeploymentWithCritter { + deployment: IAnimalDeployment; + critter: ICritterSimpleResponse; } export type IEditSurveyRequest = IGeneralInformationForm & + ISpeciesForm & IPurposeAndMethodologyForm & - ISurveyFundingSourceForm & ISurveyLocationForm & IProprietaryDataForm & IUpdateAgreementsForm & { partnerships: IGetSurveyForViewResponsePartnerships } & ISurveySiteSelectionForm & IParticipantsJobForm & - ISurveyBlockForm; + ISurveyBlockForm & + ISurveyPermitForm & + ISurveyFundingSourceForm; diff --git a/app/src/interfaces/useTaxonomyApi.interface.ts b/app/src/interfaces/useTaxonomyApi.interface.ts index 27ce499345..83edf013bc 100644 --- a/app/src/interfaces/useTaxonomyApi.interface.ts +++ b/app/src/interfaces/useTaxonomyApi.interface.ts @@ -1,14 +1,3 @@ -export interface IItisSearchResponse { - commonNames: string[]; - kingdom: string; - name: string; - parentTSN: string; - scientificName: string; - tsn: string; - updateDate: string; - usage: string; -} - export type ITaxonomy = { tsn: number; commonNames: string[]; @@ -21,3 +10,5 @@ export type ITaxonomy = { // to return the extra `rank` and `kingdom` fields, which are currently only available in some of the BioHub taxonomy // endpoints. export type IPartialTaxonomy = Partial & Pick; + +export type ITaxonomyHierarchy = Pick & { hierarchy: number[] }; diff --git a/app/src/interfaces/useTelemetryApi.interface.ts b/app/src/interfaces/useTelemetryApi.interface.ts index c04bf5e3b5..c2a4349e76 100644 --- a/app/src/interfaces/useTelemetryApi.interface.ts +++ b/app/src/interfaces/useTelemetryApi.interface.ts @@ -1,4 +1,7 @@ +import { DeploymentFormYupSchema } from 'features/surveys/telemetry/deployments/components/form/DeploymentForm'; +import { FeatureCollection } from 'geojson'; import { ApiPaginationResponseParams } from 'types/misc'; +import yup from 'utils/YupSchema'; export interface IFindTelementryObj { telemetry_id: string; @@ -24,3 +27,188 @@ export interface IFindTelemetryResponse { telemetry: IFindTelementryObj[]; pagination: ApiPaginationResponseParams; } + +export interface ICritterDeploymentResponse { + critter_id: string; + device_id: number; + deployment_id: string; + survey_critter_id: string; + alias: string; + attachment_start: string; + attachment_end?: string; + taxon: string; +} + +export interface IUpdateManualTelemetry { + telemetry_manual_id: string; + latitude: number; + longitude: number; + acquisition_date: string; +} +export interface ICreateManualTelemetry { + deployment_id: string; + latitude: number; + longitude: number; + acquisition_date: string; +} + +export interface IManualTelemetry extends ICreateManualTelemetry { + telemetry_manual_id: string; +} + +export interface IAllTelemetry { + id: string; + deployment_id: string; + telemetry_manual_id: string; + telemetry_id: number | null; + device_id: string; + latitude: number; + longitude: number; + acquisition_date: string; + telemetry_type: string; +} + +export type IAnimalDeployment = { + // BCTW properties + + /** + * The ID of a BCTW collar animal assignment (aka: deployment) record. + */ + assignment_id: string; + /** + * The ID of a BCTW collar record. + */ + collar_id: string; + /** + * The ID of a BCTW critter record. Should match a critter_id in Critterbase. + */ + critter_id: number; + /** + * The ID of a BCTW device. + */ + device_id: number; + /** + * The time the deployment started. + */ + attachment_start_date: string | null; + /** + * The time the deployment started. + */ + attachment_start_time: string | null; + /** + * The time the deployment ended. + */ + attachment_end_date: string | null; + /** + * The time the deployment ended. + */ + attachment_end_time: string | null; + /** + * The ID of a BCTW deployment record. + */ + bctw_deployment_id: string; + /** + * The ID of a BCTW device make record. + */ + device_make: number; + /** + * The model of the device. + */ + device_model: string | null; + /** + * The frequency of the device. + */ + frequency: number | null; + /** + * The ID of a BCTW frequency unit record. + */ + frequency_unit: number | null; + + // SIMS properties + + /** + * SIMS deployment record ID + */ + deployment_id: number; + /** + * Critterbase critter ID + */ + critterbase_critter_id: string; + /** + * Critterbase capture ID for the start of the deployment. + */ + critterbase_start_capture_id: string; + /** + * Critterbase capture ID for the end of the deployment. + */ + critterbase_end_capture_id: string | null; + /** + * Critterbase mortality ID for the end of the deployment. + */ + critterbase_end_mortality_id: string | null; +}; + +export type ICreateAnimalDeployment = yup.InferType; + +export interface ICreateAnimalDeploymentPostData extends Omit { + device_id: number; +} + +export type IAllTelemetryPointCollection = { points: FeatureCollection; tracks: FeatureCollection }; + +export interface ITelemetry { + id: string; + /** + * Either the telemetry_manual_id or telemetry_id (depending on the type of telemetry: manual vs vendor). + */ + deployment_id: string; + /** + * The telemetry_manual_id if the telemetry was manually created. + * Will be null if the telemetry was retrieved from a vendor. + */ + telemetry_manual_id: string | null; + /** + * The telemetry_id if the telemetry was retrieved from a vendor. + * Will be null if the telemetry was manually created. + */ + telemetry_id: number | null; + /** + * The latitude of the telemetry. + */ + latitude: number; + /** + * The longitude of the telemetry. + */ + longitude: number; + /** + * The acquisition date of the telemetry. + */ + acquisition_date: string; + /** + * The type of telemetry. + * Will either be 'MANUAL' (for manual telementry) or the name of the vendor (for vendor telemetry). + */ + telemetry_type: string; +} + +export interface ICodeResponse { + code_header_title: string; + code_header_name: string; + id: number; + code: string; + description: string; + long_description: string; +} + +export type TelemetryDeviceKeyFile = { + survey_telemetry_credential_attachment_id: number; + uuid: string; + file_name: string; + file_type: string; + file_size: number; + create_date: string; + update_date: string | null; + title: string | null; + description: string | null; + key: string; +}; diff --git a/app/src/test-helpers/code-helpers.ts b/app/src/test-helpers/code-helpers.ts index ea54c4efa8..8af0951dda 100644 --- a/app/src/test-helpers/code-helpers.ts +++ b/app/src/test-helpers/code-helpers.ts @@ -35,10 +35,6 @@ export const codes: IGetAllCodeSetsResponse = { { id: 2, name: 'Actioned' }, { id: 3, name: 'Rejected' } ], - vantage_codes: [ - { id: 1, name: 'Vantage Code 1' }, - { id: 2, name: 'Vantage Code 2' } - ], intended_outcomes: [ { id: 1, name: 'Intended Outcome 1', description: 'Description 1' }, { id: 2, name: 'Intended Outcome 2', description: 'Description 2' } diff --git a/app/src/test-helpers/survey-helpers.ts b/app/src/test-helpers/survey-helpers.ts index b8d0dff493..078318156d 100644 --- a/app/src/test-helpers/survey-helpers.ts +++ b/app/src/test-helpers/survey-helpers.ts @@ -20,8 +20,7 @@ export const surveyObject: SurveyViewObject = { blocks: [], purpose_and_methodology: { intended_outcome_ids: [1], - additional_details: 'details', - vantage_code_ids: [1, 2] + additional_details: 'details' }, proprietor: { proprietary_data_category_name: 'proprietor type', @@ -65,15 +64,6 @@ export const surveyObject: SurveyViewObject = { rank: 'species', kingdom: 'animalia' } - ], - ancillary_species: [ - { - tsn: 2, - commonNames: ['focal species 2'], - scientificName: 'scientific name 2', - rank: 'species', - kingdom: 'animalia' - } ] }, site_selection: { diff --git a/app/src/themes/appTheme.ts b/app/src/themes/appTheme.ts index 1afe273e4c..cd82c81de1 100644 --- a/app/src/themes/appTheme.ts +++ b/app/src/themes/appTheme.ts @@ -210,7 +210,8 @@ const appTheme = createTheme({ MuiDialogTitle: { styleOverrides: { root: { - paddingTop: '24px' + paddingTop: '24px', + paddingBottom: '8px' } } }, diff --git a/app/src/types/yup.d.ts b/app/src/types/yup.d.ts index 0fe155bc6c..0bece13fb3 100644 --- a/app/src/types/yup.d.ts +++ b/app/src/types/yup.d.ts @@ -111,17 +111,6 @@ declare module 'yup' { message: string ): yup.StringSchema, string | undefined>; - /** - * Determine if the object of focal and ancillary species has duplicates - * - * @param {string} message='Focal and Ancillary species must be unique' - error message if this check fails - * @return {*} {(yup.StringSchema, string | undefined>)} - * @memberof ArraySchema - */ - isUniqueFocalAncillarySpecies( - message: string - ): yup.StringSchema, string | undefined>; - /** * Determine if the author array contains unique `first_name`/`last_name` pairs. * diff --git a/app/src/utils/Utils.tsx b/app/src/utils/Utils.tsx index 4f99945381..994630cd65 100644 --- a/app/src/utils/Utils.tsx +++ b/app/src/utils/Utils.tsx @@ -461,9 +461,10 @@ export const firstOrNull = (arr: T[]): T | null => (arr.length > 0 ? arr[0] * @param seed * @returns */ -export const getRandomHexColor = (seed: number, min = 100, max = 170): string => { +export const getRandomHexColor = (seed: number, min = 120, max = 180): string => { const randomChannel = (): string => { - const x = Math.sin(seed++) * 10000; + // Change the multiplier to change the colour boldness + const x = Math.sin(seed++) * 1000; return (Math.floor((x - Math.floor(x)) * (max - min + 1)) + min).toString(16).padStart(2, '0'); }; diff --git a/app/src/utils/YupSchema.ts b/app/src/utils/YupSchema.ts index 09a5a55561..cbe50cd099 100644 --- a/app/src/utils/YupSchema.ts +++ b/app/src/utils/YupSchema.ts @@ -26,24 +26,6 @@ yup.addMethod(yup.array, 'isUniqueIUCNClassificationDetail', function (message: }); }); -yup.addMethod(yup.array, 'isUniqueFocalAncillarySpecies', function (message: string) { - return this.test('is-unique-focal-ancillary-species', message, function (values) { - if (!values || !values.length) { - return true; - } - - let hasDuplicates = false; - - this.parent.focal_species.forEach((species: number) => { - if (values.includes(species)) { - hasDuplicates = true; - } - }); - - return !hasDuplicates; - }); -}); - yup.addMethod( yup.string, 'isValidDateString', diff --git a/app/src/utils/mapUtils.ts b/app/src/utils/mapUtils.ts index 6481cd67be..453ffb95e0 100644 --- a/app/src/utils/mapUtils.ts +++ b/app/src/utils/mapUtils.ts @@ -1,54 +1,84 @@ -import { Feature } from 'geojson'; +import { SURVEY_MAP_LAYER_COLOURS } from 'constants/colours'; import L, { LatLng } from 'leaflet'; -export const MapDefaultBlue = '#3388ff'; - -export const DefaultMapValues = { - zoom: 5, - center: [55, -128] -}; - -export interface INonEditableGeometries { - feature: Feature; - popupComponent?: JSX.Element; -} - -export interface IClusteredPointGeometries { - coordinates: number[]; - popupComponent?: JSX.Element; -} -export interface MapPointProps { +export interface ColoredCustomMarkerProps { latlng: LatLng; fillColor?: string; borderColor?: string; } -export const coloredPoint = (point: MapPointProps): L.CircleMarker => { - return new L.CircleMarker(point.latlng, { +/** + * Returns a custom map marker for symbolizing points. + * + * @param {ColoredCustomMarkerProps} point + * @return {*} {L.CircleMarker} + */ +export const coloredCustomMarker = (props: ColoredCustomMarkerProps): L.CircleMarker => { + return new L.CircleMarker(props.latlng, { radius: 6, fillOpacity: 1, - fillColor: point.fillColor ?? MapDefaultBlue, - color: point.borderColor ?? '#ffffff', + fillColor: props.fillColor ?? SURVEY_MAP_LAYER_COLOURS.DEFAULT_COLOUR, + color: props.borderColor ?? '#ffffff', weight: 1 }); }; /** - * Returns custom map marker for symbolizing observations + * Util function for creating a custom map marker for symbolizing observation points. * - * @param fillColor - * @returns + * @param {string} [color] */ -export const generateCustomPointMarkerUrl = (fillColor?: string) => +const _generateCustomObservationMarkerUrl = (color?: string) => 'data:image/svg+xml;base64,' + btoa( `` ); -export const coloredCustomPointMarker = (point: MapPointProps): L.Marker => { - return new L.Marker(point.latlng, { - icon: L.icon({ iconUrl: generateCustomPointMarkerUrl(point?.fillColor), iconSize: [20, 15], iconAnchor: [12, 12] }) +/** + * Util function for creating a custom map marker for symbolizing animal mortality points. + * + * @param {string} [color] + */ +const _generateCustomMortalityMarkerUrl = (color?: string) => + 'data:image/svg+xml;base64,' + + btoa( + ` + + ` + ); + +/** + * Returns custom map marker for symbolizing observation points. + * + * @param {ColoredCustomMarkerProps} props + * @return {*} {L.Marker} + */ +export const coloredCustomObservationMarker = (props: ColoredCustomMarkerProps): L.Marker => { + return new L.Marker(props.latlng, { + icon: L.icon({ + iconUrl: _generateCustomObservationMarkerUrl(props.fillColor), + iconSize: [20, 15], + iconAnchor: [12, 12] + }) + }); +}; + +/** + * Returns custom map marker for symbolizing animal mortality points. + * + * @param {ColoredCustomMarkerProps} props + * @return {*} {L.Marker} + */ +export const coloredCustomMortalityMarker = (props: ColoredCustomMarkerProps): L.Marker => { + return new L.Marker(props.latlng, { + icon: L.icon({ + iconUrl: _generateCustomMortalityMarkerUrl(props.fillColor), + iconSize: [20, 15], + iconAnchor: [12, 12] + }) }); }; diff --git a/app/src/utils/spatial-utils.test.ts b/app/src/utils/spatial-utils.test.ts new file mode 100644 index 0000000000..7f4456b85b --- /dev/null +++ b/app/src/utils/spatial-utils.test.ts @@ -0,0 +1,269 @@ +import { SAMPLING_SITE_SPATIAL_TYPE } from 'constants/spatial'; +import { Feature, Point } from 'geojson'; +import { getSamplingSiteSpatialType } from 'utils/spatial-utils'; +import { getCoordinatesFromGeoJson, isGeoJsonPointFeature, isValidCoordinates } from './spatial-utils'; + +describe('isValidCoordinates', () => { + it('returns true when the latitude and longitude values are valid', () => { + const latitude = 0; + const longitude = 0; + + const response = isValidCoordinates(latitude, longitude); + + expect(response).toEqual(true); + }); + + it('returns false when the latitude is less than -90', () => { + const latitude = -91; + const longitude = 0; + + const response = isValidCoordinates(latitude, longitude); + + expect(response).toEqual(false); + }); + + it('returns false when the latitude is greater than 90', () => { + const latitude = 91; + const longitude = 0; + + const response = isValidCoordinates(latitude, longitude); + + expect(response).toEqual(false); + }); + + it('returns false when the longitude is less than -180', () => { + const latitude = 0; + const longitude = -181; + + const response = isValidCoordinates(latitude, longitude); + + expect(response).toEqual(false); + }); + + it('returns false when the longitude is greater than 180', () => { + const latitude = 0; + const longitude = 181; + + const response = isValidCoordinates(latitude, longitude); + + expect(response).toEqual(false); + }); + + it('returns false when the latitude is undefined', () => { + const longitude = 0; + + const response = isValidCoordinates(undefined, longitude); + + expect(response).toEqual(false); + }); + + it('returns false when the longitude is undefined', () => { + const latitude = 0; + + const response = isValidCoordinates(latitude, undefined); + + expect(response).toEqual(false); + }); +}); + +describe('getCoordinatesFromGeoJson', () => { + it('returns the latitude and longitude values from a GeoJson Point Feature', () => { + const feature: Feature = { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [11, 22] + }, + properties: {} + }; + + const response = getCoordinatesFromGeoJson(feature); + + expect(response).toEqual({ latitude: 22, longitude: 11 }); + }); +}); + +describe('isGeoJsonPointFeature', () => { + it('returns true when the feature is a GeoJson Point Feature', () => { + const feature: Feature = { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [0, 0] + }, + properties: {} + }; + + const response = isGeoJsonPointFeature(feature); + + expect(response).toEqual(true); + }); + + it('returns false when the feature is not a GeoJson Point Feature', () => { + const feature: Feature = { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [0, 0], + [1, 1] + ] + }, + properties: {} + }; + + const response = isGeoJsonPointFeature(feature); + + expect(response).toEqual(false); + }); + + it('returns false when the feature is undefined', () => { + const response = isGeoJsonPointFeature(undefined); + + expect(response).toEqual(false); + }); + + it('returns false when the feature is null', () => { + const response = isGeoJsonPointFeature(null); + + expect(response).toEqual(false); + }); + + it('returns false when the feature is an empty object', () => { + const response = isGeoJsonPointFeature({}); + + expect(response).toEqual(false); + }); + + it('returns false when the feature is an empty array', () => { + const response = isGeoJsonPointFeature([]); + + expect(response).toEqual(false); + }); + + it('returns false when the feature is a string', () => { + const response = isGeoJsonPointFeature('string'); + + expect(response).toEqual(false); + }); + + it('returns false when the feature is a number', () => { + const response = isGeoJsonPointFeature(1); + + expect(response).toEqual(false); + }); +}); + +describe('getSamplingSiteSpatialType', () => { + it('maps MultiLineString to Transect', () => { + const feature: Feature = { + type: 'Feature', + geometry: { + type: 'MultiLineString', + coordinates: [[[0, 0]], [[1, 1]]] + }, + properties: {} + }; + + const response = getSamplingSiteSpatialType(feature); + + expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.TRANSECT); + }); + + it('maps LineString to Transect', () => { + const feature: Feature = { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [0, 0], + [1, 1] + ] + }, + properties: {} + }; + + const response = getSamplingSiteSpatialType(feature); + + expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.TRANSECT); + }); + + it('maps Point to Point', () => { + const feature: Feature = { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [0, 0] + }, + properties: {} + }; + + const response = getSamplingSiteSpatialType(feature); + + expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.POINT); + }); + + it('maps MultiPoint to Point', () => { + const feature: Feature = { + type: 'Feature', + geometry: { + type: 'MultiPoint', + coordinates: [ + [0, 0], + [1, 1] + ] + }, + properties: {} + }; + + const response = getSamplingSiteSpatialType(feature); + + expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.POINT); + }); + + it('maps Polygon to Area', () => { + const feature: Feature = { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [1, 1], + [0, 1], + [0, 0] + ] + ] + }, + properties: {} + }; + + const response = getSamplingSiteSpatialType(feature); + + expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.AREA); + }); + + it('maps MultiPolygon to Area', () => { + const feature: Feature = { + type: 'Feature', + geometry: { + type: 'MultiPolygon', + coordinates: [ + [ + [ + [0, 0], + [1, 1], + [0, 1], + [0, 0] + ] + ] + ] + }, + properties: {} + }; + + const response = getSamplingSiteSpatialType(feature); + + expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.AREA); + }); +}); diff --git a/app/src/utils/spatial-utils.ts b/app/src/utils/spatial-utils.ts index 9cce878245..0b088e5cf5 100644 --- a/app/src/utils/spatial-utils.ts +++ b/app/src/utils/spatial-utils.ts @@ -1,48 +1,26 @@ +import { SAMPLING_SITE_SPATIAL_TYPE } from 'constants/spatial'; import { Feature, Point } from 'geojson'; -import { isDefined } from 'utils/Utils'; +import { isDefined } from './Utils'; /** - * Get a point feature from the given latitude and longitude. + * Checks whether a latitude-longitude pair of coordinates is valid. * - * @template Return00PointType - * @param {{ - * latitude?: number; // Latitude of the point - * longitude?: number; // Longitude of the point - * properties?: Record; // Properties of the point feature - * return00Point?: Return00PointType; // By default, if latitude or longitude is not defined, return null. If this is - * set to true, the default value for latitude and longitude will be 0, and a non-null point feature will be returned. - * }} params - * @return {*} {(Return00PointType extends true ? Feature : Feature | null)} - */ -export const getPointFeature = (params: { - latitude?: number; - longitude?: number; - properties?: Record; - return00Point?: Return00PointType; -}): Return00PointType extends true ? Feature : Feature | null => { - if (!params.return00Point && (!isDefined(params.latitude) || !isDefined(params.longitude))) { - return null as Return00PointType extends true ? Feature : Feature | null; - } - - return { - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [params.longitude ?? 0, params.latitude ?? 0] - }, - properties: { ...params.properties } - }; -}; - -/** - * Checks whether a latitude-longitude pair of coordinates is valid + * A valid latitude is between -90 and 90 degrees, inclusive. + * A valid longitude is between -180 and 180 degrees, inclusive. * - * @param {number} latitude - * @param {number} longitude - * @returns boolean + * @param {(number | undefined)} latitude + * @param {(number | undefined)} longitude + * @return {*} {boolean} */ export const isValidCoordinates = (latitude: number | undefined, longitude: number | undefined) => { - return latitude && longitude && latitude > -90 && latitude < 90 && longitude > -180 && longitude < 180 ? true : false; + return ( + isDefined(latitude) && + isDefined(longitude) && + latitude >= -90 && + latitude <= 90 && + longitude >= -180 && + longitude <= 180 + ); }; /** @@ -52,18 +30,42 @@ export const isValidCoordinates = (latitude: number | undefined, longitude: numb * @return {*} {{ latitude: number; longitude: number }} */ export const getCoordinatesFromGeoJson = (feature: Feature): { latitude: number; longitude: number } => { - const lon = feature.geometry.coordinates[0]; - const lat = feature.geometry.coordinates[1]; + const longitude = feature.geometry.coordinates[0]; + const latitude = feature.geometry.coordinates[1]; - return { latitude: lat as number, longitude: lon as number }; + return { latitude, longitude }; }; /** * Checks if the given feature is a GeoJson Feature containing a Point. * - * @param {(Feature | any)} [feature] + * @param {(unknown)} [feature] * @return {*} {feature is Feature} */ -export const isGeoJsonPointFeature = (feature?: Feature | any): feature is Feature => { - return (feature as Feature)?.geometry.type === 'Point'; +export const isGeoJsonPointFeature = (feature?: unknown): feature is Feature => { + return (feature as Feature)?.geometry?.type === 'Point'; +}; + +/** + * Get the spatial type of a sampling site feature (Point, Transect, Area, etc). + * + * @param {Feature} feature + * @return {*} {(SAMPLING_SITE_SPATIAL_TYPE | null)} + */ +export const getSamplingSiteSpatialType = (feature: Feature): SAMPLING_SITE_SPATIAL_TYPE | null => { + const geometryType = feature.geometry.type; + + if (['MultiLineString', 'LineString'].includes(geometryType)) { + return SAMPLING_SITE_SPATIAL_TYPE.TRANSECT; + } + + if (['Point', 'MultiPoint'].includes(geometryType)) { + return SAMPLING_SITE_SPATIAL_TYPE.POINT; + } + + if (['Polygon', 'MultiPolygon'].includes(geometryType)) { + return SAMPLING_SITE_SPATIAL_TYPE.AREA; + } + + return null; }; diff --git a/docker-compose.yml b/compose.yml similarity index 96% rename from docker-compose.yml rename to compose.yml index 0bd03d55cd..8d1955a247 100644 --- a/docker-compose.yml +++ b/compose.yml @@ -1,5 +1,3 @@ -version: "3.5" - services: ## Build postgres docker image db: @@ -80,8 +78,15 @@ services: - MAX_REQ_BODY_SIZE=${MAX_REQ_BODY_SIZE} - MAX_UPLOAD_NUM_FILES=${MAX_UPLOAD_NUM_FILES} - MAX_UPLOAD_FILE_SIZE=${MAX_UPLOAD_FILE_SIZE} - # Logging and Validation + # Logging - LOG_LEVEL=${LOG_LEVEL} + - LOG_LEVEL_FILE=${LOG_LEVEL_FILE} + - LOG_FILE_DIR=${LOG_FILE_DIR} + - LOG_FILE_NAME=${LOG_FILE_NAME} + - LOG_FILE_DATE_PATTERN=${LOG_FILE_DATE_PATTERN} + - LOG_FILE_MAX_SIZE=${LOG_FILE_MAX_SIZE} + - LOG_FILE_MAX_FILES=${LOG_FILE_MAX_FILES} + # API Validation - API_RESPONSE_VALIDATION_ENABLED=${API_RESPONSE_VALIDATION_ENABLED} - DATABASE_RESPONSE_VALIDATION_ENABLED=${DATABASE_RESPONSE_VALIDATION_ENABLED} # Clamav diff --git a/database/.docker/db/Dockerfile b/database/.docker/db/Dockerfile index b0808f9bfa..effd413ed7 100644 --- a/database/.docker/db/Dockerfile +++ b/database/.docker/db/Dockerfile @@ -1,5 +1,5 @@ # ######################################################################################################## -# This DockerFile is used for local development (via docker-compose) only. +# This DockerFile is used for local development (via compose.yml) only. # ######################################################################################################## ARG POSTGRES_VERSION=12.5 diff --git a/database/.docker/db/Dockerfile.setup b/database/.docker/db/Dockerfile.setup index 6bb7575574..40c77c4659 100644 --- a/database/.docker/db/Dockerfile.setup +++ b/database/.docker/db/Dockerfile.setup @@ -1,5 +1,5 @@ # ######################################################################################################## -# This DockerFile is used for both Openshift deployments and local development (via docker-compose). +# This DockerFile is used for both Openshift deployments and local development (via compose.yml). # ######################################################################################################## FROM node:20 diff --git a/database/.pipeline/config.js b/database/.pipeline/config.js index e126db6e77..5f53a1826b 100644 --- a/database/.pipeline/config.js +++ b/database/.pipeline/config.js @@ -97,6 +97,25 @@ const phases = { memoryLimit: '5Gi', replicas: '1' }, + 'test-spi': { + namespace: 'af2668-test', + name: `${name}-spi`, + phase: 'test-spi', + changeId: deployChangeId, + suffix: '-test-spi', + instance: `${name}-spi-test-spi`, + version: `${version}`, + tag: `test-spi-${version}`, + nodeEnv: 'production', + tz: config.timezone.db, + dbSetupDockerfilePath: dbSetupDockerfilePath, + volumeCapacity: '20Gi', + cpuRequest: '50m', + cpuLimit: '2000m', + memoryRequest: '100Mi', + memoryLimit: '5Gi', + replicas: '1' + }, prod: { namespace: 'af2668-prod', name: `${name}`, diff --git a/database/src/migrations/20240722000002_remove_duplicate_users.ts b/database/src/migrations/20240722000002_remove_duplicate_users.ts index 97b5c23c68..3b453c24e6 100644 --- a/database/src/migrations/20240722000002_remove_duplicate_users.ts +++ b/database/src/migrations/20240722000002_remove_duplicate_users.ts @@ -12,8 +12,7 @@ import { Knex } from 'knex'; * - administrative_activity: Delete duplicate administrative_activity records. * * Updates/fixes several constraints: - * - system_user_nuk1: Don't allow more than 1 active record with the same user_guid. - * - system_user_nuk2: Don't allow more than 1 active record with the same user_identifier (case-insensitive) AND user_identity_source_id. + * - system_user_uk2: Don't allow more than 1 record with the same user_identifier (case-insensitive) AND user_identity_source_id. * * Does not update the following tables: * - audit_log: This table tracks the history of all changes to the database, including changes from this migration. @@ -40,6 +39,7 @@ export async function up(knex: Knex): Promise { -- Drop constraint temporarily (added back at the end) ALTER TABLE project_participation DROP CONSTRAINT IF EXISTS project_participation_uk2; + ---------------------------------------------------------------------------------------- -- Find AND migrate duplicate system_user_ids ---------------------------------------------------------------------------------------- @@ -173,7 +173,7 @@ export async function up(knex: Knex): Promise { WHERE sp.system_user_id = ANY(w_system_user_3.duplicate_system_user_ids) ), -- Update duplicate system_user_ids in the webform_draft table to the canonical system_user_id - w_end_date_duplicate_webform_draft as ( + w_update_duplicate_webform_draft as ( UPDATE webform_draft SET system_user_id = wsu3.system_user_id @@ -181,18 +181,15 @@ export async function up(knex: Knex): Promise { WHERE webform_draft.system_user_id = ANY(wsu3.duplicate_system_user_ids) ), -- Delete duplicate system_user_role records for duplicate system_user_ids - w_end_date_duplicate_system_user_role AS ( + w_delete_duplicate_system_user_role AS ( DELETE FROM system_user_role USING w_system_user_3 wsu3 WHERE system_user_role.system_user_id = ANY(wsu3.duplicate_system_user_ids) ), - -- End date all duplicate system_user records for duplicate system_user_ids - w_end_date_duplicate_system_user AS ( - UPDATE system_user su - SET - record_end_date = NOW(), - notes = 'Duplicate user record; merged into system_user_id ' || wsu3.system_user_id || '.' - FROM w_system_user_3 wsu3 + -- Delete duplicate system_user records for duplicate system_user_ids + w_delete_duplicate_system_user AS ( + DELETE FROM system_user su + USING w_system_user_3 wsu3 WHERE su.system_user_id = ANY(wsu3.duplicate_system_user_ids) ), -- Update the user details for the canonical system user record @@ -245,17 +242,17 @@ export async function up(knex: Knex): Promise { -- Add updated constraints ---------------------------------------------------------------------------------------- - -- Don't allow more than 1 active record with the same user_guid. - CREATE UNIQUE INDEX system_user_nuk1 ON system_user (user_guid, (record_end_date is null)) WHERE record_end_date is null; - - -- Don't allow more than 1 active record with the same user_identifier (case-insensitive) AND user_identity_source_id. - CREATE UNIQUE INDEX system_user_nuk2 ON system_user(LOWER(user_identifier), user_identity_source_id, (record_end_date is null)) WHERE record_end_date is null; + -- Don't allow more than 1 record with the same user_identifier (case-insensitive) AND user_identity_source_id. + CREATE UNIQUE INDEX system_user_uk2 ON system_user(LOWER(user_identifier), user_identity_source_id); -- Don't allow the same system user to have more than one project role within a project. ALTER TABLE biohub.project_participation ADD CONSTRAINT project_participation_uk1 UNIQUE (system_user_id, project_id); -- Don't allow the same system user to have more than one survey role within a survey. ALTER TABLE biohub.survey_participation ADD CONSTRAINT survey_participation_uk1 UNIQUE (system_user_id, survey_id); + + -- Don't allow duplicate user_guid values + CREATE UNIQUE INDEX system_user_uk1 ON system_user (user_guid); `); } diff --git a/database/src/migrations/20240802000000_deployment_capture.ts b/database/src/migrations/20240802000000_deployment_capture.ts new file mode 100644 index 0000000000..455c01ca3f --- /dev/null +++ b/database/src/migrations/20240802000000_deployment_capture.ts @@ -0,0 +1,33 @@ +import { Knex } from 'knex'; + +/** + * Adds capture_id references to the deployment table to track the start and end of deployments + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + + -- Set the search path + SET SEARCH_PATH = biohub, biohub_dapi_v1; + + -- Drop the view if it exists + DROP VIEW IF EXISTS biohub_dapi_v1.deployment; + + -- Add columns to the deployment table + ALTER TABLE biohub.deployment ADD COLUMN critterbase_start_capture_id UUID; + ALTER TABLE biohub.deployment ADD COLUMN critterbase_end_capture_id UUID; + ALTER TABLE biohub.deployment ADD COLUMN critterbase_end_mortality_id UUID; + + -- Recreate the view + CREATE OR REPLACE VIEW biohub_dapi_v1.deployment AS + SELECT * FROM biohub.deployment; + + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/migrations/20240802161900_drop_vantage_codes.ts b/database/src/migrations/20240802161900_drop_vantage_codes.ts new file mode 100644 index 0000000000..6dae62be2c --- /dev/null +++ b/database/src/migrations/20240802161900_drop_vantage_codes.ts @@ -0,0 +1,74 @@ +import { Knex } from 'knex'; + +/** + * Drop survey vantage code + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + ---------------------------------------------------------------------------------------- + -- Drop survey vantage + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub_dapi_v1; + + DROP VIEW IF EXISTS survey; + DROP VIEW IF EXISTS vantage; + DROP VIEW IF EXISTS survey_vantage; + + ---------------------------------------------------------------------------------------- + -- Alter tables/data + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub; + + DROP TABLE survey_vantage; + DROP TABLE vantage; + + ---------------------------------------------------------------------------------------- + -- Truncate tables because no survey_type data has been meaningful as of the migration date + ---------------------------------------------------------------------------------------- + DELETE FROM survey_type; + DELETE FROM survey_intended_outcome; + DELETE FROM type; + DELETE FROM intended_outcome; + + ---------------------------------------------------------------------------------------- + -- Update survey type codes + ---------------------------------------------------------------------------------------- + INSERT INTO + type (name, record_effective_date) + VALUES + ('Telemetry', now()), + ('Species observations', now()), + ('Animal captures', now()), + ('Animal mortalities', now()), + ('Habitat features', now()); + + ---------------------------------------------------------------------------------------- + -- Update ecological variables (intended outcomes) codes + ---------------------------------------------------------------------------------------- + INSERT INTO + intended_outcome (name, description, record_effective_date) + VALUES + ('Survival or mortality', 'The survival or mortality of individuals in a population, including causes of death.', now()), + ('Birth or recruitment', 'The number of individuals born into a population.', now()), + ('Geographic distribution or dispersal', 'The geographic distribution of one or more populations, including movement and dispersals patterns.', now()), + ('Population size', 'The abundance or density of one or more populations.', now()), + ('Population structure', 'The structure or composition of a population.', now()), + ('Community structure', 'The structure or composition of a community, which includes multiple populations.', now()), + ('Species diversity', 'The number of species.', now()); + + ---------------------------------------------------------------------------------------- + -- Update views + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE OR REPLACE VIEW survey as SELECT * FROM biohub.survey; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/migrations/20240809140000_study_species_units.ts b/database/src/migrations/20240809140000_study_species_units.ts new file mode 100644 index 0000000000..5b51403485 --- /dev/null +++ b/database/src/migrations/20240809140000_study_species_units.ts @@ -0,0 +1,62 @@ +import { Knex } from 'knex'; + +/** + * Create new tables: + * - study_species_unit + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + SET SEARCH_PATH=biohub; + + ----------------------------------------------------------------------------------------------------------------- + -- CREATE study_species_unit table for associating collection units / ecological units with a survey + ----------------------------------------------------------------------------------------------------------------- + CREATE TABLE study_species_unit ( + study_species_unit_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + study_species_id integer NOT NULL, + critterbase_collection_category_id UUID NOT NULL, + critterbase_collection_unit_id UUID NOT NULL, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT study_species_unit_pk PRIMARY KEY (study_species_unit_id) + ); + + COMMENT ON TABLE study_species_unit IS 'This table is intended to track ecological units of interest for focal species in a survey.'; + COMMENT ON COLUMN study_species_unit.study_species_unit_id IS 'Primary key to the table.'; + COMMENT ON COLUMN study_species_unit.study_species_id IS 'Foreign key to the study_species table.'; + COMMENT ON COLUMN study_species_unit.critterbase_collection_category_id IS 'UUID of an external critterbase collection category.'; + COMMENT ON COLUMN study_species_unit.critterbase_collection_unit_id IS 'UUID of an external critterbase collection unit.'; + COMMENT ON COLUMN study_species_unit.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN study_species_unit.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN study_species_unit.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN study_species_unit.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN study_species_unit.revision_count IS 'Revision count used for concurrency control.'; + + -- add foreign key constraint + ALTER TABLE study_species_unit ADD CONSTRAINT study_species_unit_fk1 FOREIGN KEY (study_species_id) REFERENCES study_species(study_species_id); + + -- add indexes for foreign keys + CREATE INDEX study_species_unit_idx1 ON study_species_unit(study_species_id); + + -- add triggers for user data + CREATE TRIGGER audit_study_species_unit BEFORE INSERT OR UPDATE OR DELETE ON biohub.study_species_unit FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_study_species_unit AFTER INSERT OR UPDATE OR DELETE ON biohub.study_species_unit FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ---------------------------------------------------------------------------------------- + -- Create measurement table views + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub_dapi_v1; + CREATE OR REPLACE VIEW study_species_unit AS SELECT * FROM biohub.study_species_unit; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/migrations/20240823000000_add_telemetry_credential_attachments_table.ts b/database/src/migrations/20240823000000_add_telemetry_credential_attachments_table.ts new file mode 100644 index 0000000000..5b7986c1d3 --- /dev/null +++ b/database/src/migrations/20240823000000_add_telemetry_credential_attachments_table.ts @@ -0,0 +1,137 @@ +import { Knex } from 'knex'; + +// Manual step 2 (see JSDoc comment below) +// UPDATE survey_telemetry_credential_attachment SET key = REGEXP_REPLACE(key, '(.*/\d+/surveys/\d+/)([^/]+\.pdf)', '\1telemetry-credentials/\2'); + +/** + * Create 1 new table for storing telemetry device credential attachments (ex: device KeyX or Cfg files). + * - survey_telemetry_credential_attachment + * + * Migrate existing survey attachments with file_type 'keyx' to the new survey_telemetry_credential_attachment table. + * + * Manual steps after running this migration: + * + * 1. Move all keyx files in S3 from '...surveys/{surveyId}/' to + * 'surveys/{surveyId}/telemetry-credentials/} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + SET SEARCH_PATH=biohub; + + ---------------------------------------------------------------------------------------- + -- Create table + ---------------------------------------------------------------------------------------- + + CREATE TABLE survey_telemetry_credential_attachment ( + survey_telemetry_credential_attachment_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + uuid uuid DEFAULT public.gen_random_uuid(), + survey_id integer NOT NULL, + file_type varchar(300) NOT NULL, + file_name varchar(300), + title varchar(300), + description varchar(250), + key varchar(1000) NOT NULL, + file_size integer, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT survey_telemetry_credential_attachment_pk PRIMARY KEY (survey_telemetry_credential_attachment_id) + ); + + COMMENT ON COLUMN survey_telemetry_credential_attachment.survey_telemetry_credential_attachment_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN survey_telemetry_credential_attachment.uuid IS 'The universally unique identifier for the record.'; + COMMENT ON COLUMN survey_telemetry_credential_attachment.survey_id IS 'Foreign key to the survey table.'; + COMMENT ON COLUMN survey_telemetry_credential_attachment.file_type IS 'The attachment type. Attachment type examples include keyx, cfg, etc.'; + COMMENT ON COLUMN survey_telemetry_credential_attachment.file_name IS 'The name of the file attachment.'; + COMMENT ON COLUMN survey_telemetry_credential_attachment.title IS 'The title of the file.'; + COMMENT ON COLUMN survey_telemetry_credential_attachment.description IS 'The description of the record.'; + COMMENT ON COLUMN survey_telemetry_credential_attachment.key IS 'The identifying key to the file in the storage system.'; + COMMENT ON COLUMN survey_telemetry_credential_attachment.file_size IS 'The size of the file in bytes.'; + COMMENT ON COLUMN survey_telemetry_credential_attachment.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN survey_telemetry_credential_attachment.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN survey_telemetry_credential_attachment.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN survey_telemetry_credential_attachment.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN survey_telemetry_credential_attachment.revision_count IS 'Revision count used for concurrency control.'; + COMMENT ON TABLE survey_telemetry_credential_attachment IS 'A list of telemetry device credential files (ex: device credential files like Keyx or Cfg).'; + + + -- Add foreign key constraints + ALTER TABLE survey_telemetry_credential_attachment + ADD CONSTRAINT survey_telemetry_credential_attachment_fk1 + FOREIGN KEY (survey_id) + REFERENCES survey(survey_id); + + -- Add indexes for foreign keys + CREATE INDEX survey_telemetry_credential_attachment_idx1 ON survey_telemetry_credential_attachment(survey_id); + + ---------------------------------------------------------------------------------------- + -- Create audit and journal triggers + ---------------------------------------------------------------------------------------- + + CREATE TRIGGER audit_survey_telemetry_credential_attachment BEFORE INSERT OR UPDATE OR DELETE ON survey_telemetry_credential_attachment FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_survey_telemetry_credential_attachment AFTER INSERT OR UPDATE OR DELETE ON survey_telemetry_credential_attachment FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ---------------------------------------------------------------------------------------- + -- Migrate existing survey attachments with file_type 'keyx' to the new + -- survey_telemetry_credential_attachment table. + ---------------------------------------------------------------------------------------- + + INSERT INTO survey_telemetry_credential_attachment + ( + survey_id, + file_type, + file_name, + title, + description, + key, + file_size, + create_user, + create_date, + update_user, + update_date, + revision_count + ) + SELECT + survey_id, + 'KeyX', + file_name, + title, + description, + key, + file_size, + create_user, + create_date, + update_user, + update_date, + revision_count + FROM + survey_attachment + WHERE + file_type = 'keyx'; + + -- Remove the migrated records from the survey_attachment table + DELETE FROM survey_attachment + WHERE file_type = 'keyx'; + + ---------------------------------------------------------------------------------------- + -- Create view + ---------------------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE OR REPLACE VIEW survey_telemetry_credential_attachment as SELECT * FROM biohub.survey_telemetry_credential_attachment; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/procedures/api_patch_system_user.ts b/database/src/procedures/api_patch_system_user.ts index d8c126529c..8803be960e 100644 --- a/database/src/procedures/api_patch_system_user.ts +++ b/database/src/procedures/api_patch_system_user.ts @@ -9,7 +9,8 @@ import { Knex } from 'knex'; * - Second using `p_user_identifier` and `p_user_identity_source_name` * 2. If no user is found, return null * 3. If a user is found, update the system user record with the latest information passed to this function if any of - * the incoming values are not the same as the existing values + * the incoming values are not the same as the existing values (if all incoming values are the same as the existing then + * no update is performed). * * @export * @param {Knex} knex @@ -36,7 +37,6 @@ export async function seed(knex: Knex): Promise { AS $$ DECLARE _system_user system_user%rowtype; - _user_identity_source_id user_identity_source.user_identity_source_id%type; BEGIN -- Attempt to find user based on guid SELECT * INTO _system_user FROM system_user @@ -46,14 +46,15 @@ export async function seed(knex: Knex): Promise { -- Otherwise, attempt to find user based on identifier and identity source IF NOT found THEN - SELECT user_identity_source_id INTO strict _user_identity_source_id FROM user_identity_source - WHERE LOWER(name) = LOWER(p_user_identity_source_name) - AND record_end_date IS NULL; - SELECT * INTO _system_user FROM system_user - WHERE user_identity_source_id = _user_identity_source_id - AND LOWER(user_identifier) = LOWER(p_user_identifier) - LIMIT 1; + WHERE user_identity_source_id = ( + SELECT user_identity_source_id FROM user_identity_source + WHERE LOWER(name) = LOWER(p_user_identity_source_name) + AND record_end_date IS NULL + ) + AND LOWER(user_identifier) = LOWER(p_user_identifier) + AND record_end_date IS NULL + LIMIT 1; END IF; -- If no user found, return and do nothing @@ -73,13 +74,13 @@ export async function seed(knex: Knex): Promise { WHERE system_user_id = _system_user.system_user_id AND ( - user_guid != p_system_user_guid OR - user_identifier != p_user_identifier OR - email != p_email OR - display_name != p_display_name OR - given_name != p_given_name OR - family_name != p_family_name OR - agency != p_agency + user_guid IS DISTINCT FROM p_system_user_guid OR + user_identifier IS DISTINCT FROM p_user_identifier OR + email IS DISTINCT FROM p_email OR + display_name IS DISTINCT FROM p_display_name OR + given_name IS DISTINCT FROM p_given_name OR + family_name IS DISTINCT FROM p_family_name OR + agency IS DISTINCT FROM p_agency ); -- Return system user id of patched record diff --git a/database/src/procedures/delete_survey_procedure.ts b/database/src/procedures/delete_survey_procedure.ts index 16d446fd39..460257526a 100644 --- a/database/src/procedures/delete_survey_procedure.ts +++ b/database/src/procedures/delete_survey_procedure.ts @@ -119,9 +119,6 @@ export async function seed(knex: Knex): Promise { DELETE FROM survey_funding_source WHERE survey_id = p_survey_id; - DELETE FROM survey_vantage - WHERE survey_id = p_survey_id; - DELETE FROM survey_spatial_component WHERE survey_id = p_survey_id; diff --git a/database/src/seeds/03_basic_project_survey_setup.ts b/database/src/seeds/03_basic_project_survey_setup.ts index b98018b086..1ec5a7b19a 100644 --- a/database/src/seeds/03_basic_project_survey_setup.ts +++ b/database/src/seeds/03_basic_project_survey_setup.ts @@ -18,16 +18,11 @@ const focalTaxonIdOptions = [ { itis_tsn: 180543, itis_scientific_name: 'Ursus arctos' } // Grizzly bear ]; -const ancillaryTaxonIdOptions = [ - { itis_tsn: 180703, itis_scientific_name: 'Alces alces' }, // Moose - { itis_tsn: 180596, itis_scientific_name: 'Canis lupus' }, // Wolf - { itis_tsn: 180713, itis_scientific_name: 'Oreamnos americanus' }, // Rocky Mountain goat - { itis_tsn: 180543, itis_scientific_name: 'Ursus arctos' } // Grizzly bear -]; - const surveyRegionsA = ['Kootenay-Boundary Natural Resource Region', 'West Coast Natural Resource Region']; const surveyRegionsB = ['Cariboo Natural Resource Region', 'South Coast Natural Resource Region']; +const identitySources = ['IDIR', 'BCEIDBUSINESS', 'BCEIDBASIC']; + /** * Add spatial transform * @@ -51,6 +46,11 @@ export async function seed(knex: Knex): Promise { `); } + // Insert access requests + for (let i = 0; i < 8; i++) { + await knex.raw(`${insertAccessRequest()}`); + } + // Check if at least 1 project already exists const checkProjectsResponse = await knex.raw(checkAnyProjectExists()); @@ -75,12 +75,10 @@ export async function seed(knex: Knex): Promise { ${insertSurveyTypeData(surveyId)} ${insertSurveyPermitData(surveyId)} ${insertSurveyFocalSpeciesData(surveyId)} - ${insertSurveyAncillarySpeciesData(surveyId)} ${insertSurveyFundingData(surveyId)} ${insertSurveyProprietorData(surveyId)} ${insertSurveyFirstNationData(surveyId)} ${insertSurveyStakeholderData(surveyId)} - ${insertSurveyVantageData(surveyId)} ${insertSurveyParticipationData(surveyId)} ${insertSurveyLocationData(surveyId)} ${insertSurveySiteStrategy(surveyId)} @@ -179,22 +177,6 @@ const insertSurveyParticipationData = (surveyId: number) => ` ; `; -/** - * SQL to insert Survey Vantage data - * - */ -const insertSurveyVantageData = (surveyId: number) => ` - INSERT into survey_vantage - ( - survey_id, - vantage_id - ) - VALUES ( - ${surveyId}, - (select vantage_id from vantage order by random() limit 1) - ); -`; - /** * SQL to insert Survey Proprietor data * @@ -271,23 +253,6 @@ const insertSurveyFocalSpeciesData = (surveyId: number) => { `; }; -const insertSurveyAncillarySpeciesData = (surveyId: number) => { - const ancillarySpecies = ancillaryTaxonIdOptions[Math.floor(Math.random() * ancillaryTaxonIdOptions.length)]; - return ` - INSERT into study_species - ( - survey_id, - itis_tsn, - is_focal - ) - VALUES ( - ${surveyId}, - ${ancillarySpecies.itis_tsn}, - 'N' - ); - `; -}; - /** * SQL to insert Survey permit data * @@ -771,3 +736,37 @@ const insertSurveyRegionData = (surveyId: string, region: string) => ` WHERE region_name = $$${region}$$; `; + +/** + * SQL to insert system access requests + * + */ +const insertAccessRequest = () => ` + INSERT INTO administrative_activity + ( + administrative_activity_status_type_id, + administrative_activity_type_id, + reported_system_user_id, + assigned_system_user_id, + description, + data, + notes + ) + VALUES ( + (SELECT administrative_activity_status_type_id FROM administrative_activity_status_type ORDER BY random() LIMIT 1), + (SELECT administrative_activity_type_id FROM administrative_activity_type WHERE name = 'System Access'), + (SELECT system_user_id FROM system_user ORDER BY random() LIMIT 1), + (SELECT system_user_id FROM system_user ORDER BY random() LIMIT 1), + $$${faker.lorem.sentences(2)}$$, + jsonb_build_object( + 'reason', '${faker.lorem.sentences(1)}', + 'userGuid', '${faker.string.uuid()}', + 'name', '${faker.lorem.words(2)}', + 'username', '${faker.lorem.words(1)}', + 'email', 'default', + 'identitySource', '${identitySources[faker.number.int({ min: 0, max: identitySources.length - 1 })]}', + 'displayName', '${faker.lorem.words(1)}' + ), + $$${faker.lorem.sentences(2)}$$ + ); + `; diff --git a/env_config/env.docker b/env_config/env.docker index face12d12c..b78a07268b 100644 --- a/env_config/env.docker +++ b/env_config/env.docker @@ -5,11 +5,11 @@ # # These env vars are automatically read by the makefile (when running make commands). # -# Newly added environment variables need to be added to the docker-compose file, +# Newly added environment variables need to be added to the compose.yml file, # under whichever service needs them (api, app, etc) # # Exposed Ports/URLs -# - Certain ports/urls are exposed in docker-compose and may conflict with other +# - Certain ports/urls are exposed in compose.yml and may conflict with other # docker-containers if they are exposing the same ports/urls. # # - If conflicts arise, modify the conflicting values in your `.env` and re-build. @@ -46,9 +46,27 @@ API_HOST=localhost API_PORT=6100 API_TZ=America/Vancouver -# See `api/utils/logger.ts` for details on LOG_LEVEL +# ------------------------------------------------------------------------------ +# API - Logging +# ------------------------------------------------------------------------------ +# See `api/utils/logger.ts` for details on LOG_LEVEL and LOG_LEVEL_FILE + +# Log level when logging to the console LOG_LEVEL=debug +# Log level when logging to a persistent file (See `api/data`) +LOG_LEVEL_FILE=debug +# See https://github.com/winstonjs/winston-daily-rotate-file for param details +LOG_FILE_DIR=data/logs +LOG_FILE_NAME=sims-api-%DATE%.log +LOG_FILE_DATE_PATTERN=YYYY-MM-DD-HH +LOG_FILE_MAX_SIZE=50m +LOG_FILE_MAX_FILES=14d + +# ------------------------------------------------------------------------------ +# API - Validation +# ------------------------------------------------------------------------------ + # Control whether or not api response validation is enabled API_RESPONSE_VALIDATION_ENABLED=true