diff --git a/.github/workflows/cleanClosedPR.yml b/.github/workflows/cleanClosedPR.yml index 4aecca18ed..dd49ecd25d 100644 --- a/.github/workflows/cleanClosedPR.yml +++ b/.github/workflows/cleanClosedPR.yml @@ -1,18 +1,16 @@ # Clean out all deployment artifacts when a PR is closed, but not merged. -# Will attempt to remove all artifacts from any PR that was opened against any branch (and then closed (not merged)), except for test and prod. +# Will attempt to remove all artifacts from any PR that was opened against any branch (and then closed (not merged)). name: Clean Closed PR Artifacts on: pull_request: types: [closed] - branches-ignore: - - test - - prod jobs: clean: name: Clean Deployment Artifacts for API and App in Dev and Tools environment runs-on: ubuntu-latest + timeout-minutes: 20 # Don't run if the PR was merged if: ${{ github.event.pull_request.merged != true }} env: diff --git a/.github/workflows/cleanMergedPR.yml b/.github/workflows/cleanMergedPR.yml new file mode 100644 index 0000000000..680c34c0b3 --- /dev/null +++ b/.github/workflows/cleanMergedPR.yml @@ -0,0 +1,83 @@ +# Clean out all deployment artifacts when a PR is merged against a non-standard base branch (aka: neither dev, test, or prod) +# Standard branches (aka: dev, test, prod) have their own cleanup routine that runs as part of the deployStatic action. +name: Clean Merged PR Artifacts + +on: + pull_request: + types: [closed] + branches-ignore: + - dev + - test + - prod + +jobs: + clean: + name: Clean Deployment Artifacts for API and App in Dev and Tools environment + runs-on: ubuntu-latest + timeout-minutes: 20 + # Only run if the PR was merged + if: ${{ github.event.pull_request.merged == true }} + env: + PR_NUMBER: ${{ github.event.number }} + steps: + # Install Node - for `node` and `npm` commands + # Note: This already uses actions/cache internally, so repeat calls in subsequent jobs are not a performance hit + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 14 + + # Load repo from cache + - name: Cache repo + uses: actions/cache@v3 + id: cache-repo + env: + cache-name: cache-repo + with: + path: ${{ github.workspace }}/* + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ github.sha }} + + # Checkout the branch if not restored via cache + - name: Checkout Target Branch + if: steps.cache-repo.outputs.cache-hit != 'true' + uses: actions/checkout@v3 + with: + persist-credentials: false + + # Log in to OpenShift. + # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. + # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. + - name: Log in to OpenShift + run: oc login --token=${{ secrets.TOOLS_SA_TOKEN }} --server=https://api.silver.devops.gov.bc.ca:6443 + + # Clean the app deployment artifacts + - name: Clean APP Deployment + working-directory: "app/.pipeline/" + run: | + npm ci + DEBUG=* npm run clean -- --pr=$PR_NUMBER --env=build + DEBUG=* npm run clean -- --pr=$PR_NUMBER --env=dev + + # Clean the database build/deployment artifacts + - name: Clean Database Artifacts + working-directory: "database/.pipeline/" + run: | + npm ci + DEBUG=* npm run clean -- --pr=$PR_NUMBER --env=build + DEBUG=* npm run clean -- --pr=$PR_NUMBER --env=dev + + # Clean the api deployment artifacts + - name: Clean API Deployment + working-directory: "api/.pipeline/" + run: | + npm ci + DEBUG=* npm run clean -- --pr=$PR_NUMBER --env=build + DEBUG=* npm run clean -- --pr=$PR_NUMBER --env=dev + + # Clean the reamaining build/deployment artifacts + - name: Clean remaining Artifacts + env: + POD_SELECTOR: biohubbc + run: | + oc --namespace af2668-dev get all,pvc,secret,pods,ReplicationController,DeploymentConfig,HorizontalPodAutoscaler,imagestreamtag -o name | grep $POD_SELECTOR | grep $PR_NUMBER | awk '{print "oc delete --ignore-not-found " $1}' | bash + oc --namespace af2668-tools get all,pvc,secret,pods,ReplicationController,DeploymentConfig,HorizontalPodAutoscaler,imagestreamtag -o name | grep $POD_SELECTOR | grep $PR_NUMBER | awk '{print "oc delete --ignore-not-found " $1}' | bash diff --git a/Makefile b/Makefile index f34d3938d8..c7a0f241a7 100644 --- a/Makefile +++ b/Makefile @@ -122,7 +122,6 @@ db-container: ## Executes into database container. @echo "Make: Shelling into database container" @echo "===============================================" @export PGPASSWORD=$(DB_ADMIN_PASS) - @ app-container: ## Executes into the app container. @echo "===============================================" @@ -329,8 +328,24 @@ log-db-setup: ## Runs `docker logs -f` for the database setup contai @docker logs $(DOCKER_PROJECT_NAME)-db-setup-$(DOCKER_NAMESPACE)-container -f $(args) ## ------------------------------------------------------------------------------ -## Help +## Typescript Trace Commands +## Runs ts-trace to find typescript compilation issues and hotspots +## Docs: https://github.com/microsoft/typescript-analyze-trace ## ------------------------------------------------------------------------------ +trace-app: + @echo "===============================================" + @echo "Typscript trace - searching App hotspots" + @echo "===============================================" + @cd app && npx tsc -p ./tsconfig.json --generateTrace ts-traces || npx @typescript/analyze-trace --skipMillis 100 --forceMillis 300 --expandTypes ts-traces -help: ## Display this help screen. +trace-api: + @echo "===============================================" + @echo "Typscript trace - searching for Api hotspots" + @echo "===============================================" + @cd api && npx tsc -p ./tsconfig.json --generateTrace ts-traces || npx @typescript/analyze-trace --skipMillis 100 --forceMillis 300 --expandTypes ts-traces + +## ------------------------------------------------------------------------------ +## Help +## ------------------------------------------------------------------------------ +help: ## Display this help screen. @grep -h -E '^[0-9a-zA-Z_-]+:.*?##.*$$|^##.*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[33m%-20s\033[0m %s\n", $$1, $$2}' | awk 'BEGIN {FS = "## "}; {printf "\033[36m%-1s\033[0m %s\n", $$2, $$1}' diff --git a/api/.gitignore b/api/.gitignore index c770a37d64..8461edfea1 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -24,3 +24,5 @@ coverage npm-debug.log* yarn-debug.log* yarn-error.log* + +ts-traces diff --git a/api/.pipeline/config.js b/api/.pipeline/config.js index 123529f304..f8af1beb40 100644 --- a/api/.pipeline/config.js +++ b/api/.pipeline/config.js @@ -59,7 +59,6 @@ const phases = { instance: `${name}-build-${changeId}`, version: `${version}-${changeId}`, tag: tag, - env: 'build', tz: config.timezone.api, branch: branch, cpuRequest: '50m', @@ -85,13 +84,14 @@ const phases = { backboneIntakeEnabled: false, bctwApiHost: 'https://moe-bctw-api-dev.apps.silver.devops.gov.bc.ca', critterbaseApiHost: 'https://moe-critterbase-api-dev.apps.silver.devops.gov.bc.ca/api', - env: 'dev', + nodeEnv: 'development', elasticsearchURL: 'http://es01.a0ec71-dev:9200', elasticsearchTaxonomyIndex: 'taxonomy_3.0.0', s3KeyPrefix: (isStaticDeployment && 'sims') || `local/${deployChangeId}/sims`, tz: config.timezone.api, sso: config.sso.dev, - logLevel: 'debug', + logLevel: 'silly', + nodeOptions: '--max_old_space_size=1500', // 75% of memoryLimit (bytes) cpuRequest: '50m', cpuLimit: '400m', memoryRequest: '100Mi', @@ -117,13 +117,14 @@ const phases = { backboneIntakeEnabled: false, 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', - env: 'test', + nodeEnv: 'production', elasticsearchURL: 'http://es01.a0ec71-dev:9200', elasticsearchTaxonomyIndex: 'taxonomy_3.0.0', s3KeyPrefix: 'sims', tz: config.timezone.api, sso: config.sso.test, logLevel: 'info', + nodeOptions: '--max_old_space_size=2250', // 75% of memoryLimit (bytes) cpuRequest: '50m', cpuLimit: '1000m', memoryRequest: '100Mi', @@ -149,13 +150,14 @@ const phases = { backboneIntakeEnabled: false, bctwApiHost: 'https://moe-bctw-api-prod.apps.silver.devops.gov.bc.ca', critterbaseApiHost: 'https://moe-critterbase-api-prod.apps.silver.devops.gov.bc.ca/api', - env: 'prod', + nodeEnv: 'production', elasticsearchURL: 'http://es01.a0ec71-prod:9200', elasticsearchTaxonomyIndex: 'taxonomy_3.0.0', s3KeyPrefix: 'sims', tz: config.timezone.api, sso: config.sso.prod, - logLevel: 'info', + logLevel: 'error', + nodeOptions: '--max_old_space_size=2250', // 75% of memoryLimit (bytes) cpuRequest: '50m', cpuLimit: '1000m', memoryRequest: '100Mi', diff --git a/api/.pipeline/lib/api.build.js b/api/.pipeline/lib/api.build.js index 422d775956..301c0971d5 100644 --- a/api/.pipeline/lib/api.build.js +++ b/api/.pipeline/lib/api.build.js @@ -11,7 +11,7 @@ const path = require('path'); const apiBuild = (settings) => { const phases = settings.phases; const options = settings.options; - const phase = 'build'; + const phase = settings.phase; const oc = new OpenShiftClientX(Object.assign({ namespace: phases[phase].namespace }, options)); diff --git a/api/.pipeline/lib/api.deploy.js b/api/.pipeline/lib/api.deploy.js index 30ddefb8bb..93905c30c7 100644 --- a/api/.pipeline/lib/api.deploy.js +++ b/api/.pipeline/lib/api.deploy.js @@ -12,7 +12,7 @@ const path = require('path'); const apiDeploy = async (settings) => { const phases = settings.phases; const options = settings.options; - const phase = options.env; + const phase = settings.options.env; const oc = new OpenShiftClientX(Object.assign({ namespace: phases[phase].namespace }, options)); @@ -31,7 +31,9 @@ const apiDeploy = async (settings) => { HOST: phases[phase].host, APP_HOST: phases[phase].appHost, CHANGE_ID: phases.build.changeId || changeId, - NODE_ENV: phases[phase].env, + // Node + NODE_ENV: phases[phase].nodeEnv, + NODE_OPTIONS: phases[phase].nodeOptions, // BioHub Platform (aka: Backbone) BACKBONE_API_HOST: phases[phase].backboneApiHost, BACKBONE_INTAKE_PATH: phases[phase].backboneIntakePath, diff --git a/api/.pipeline/lib/clean.js b/api/.pipeline/lib/clean.js index 1b421124a8..f76bfe8129 100644 --- a/api/.pipeline/lib/clean.js +++ b/api/.pipeline/lib/clean.js @@ -10,7 +10,7 @@ const { OpenShiftClientX } = require('pipeline-cli'); const clean = (settings) => { const phases = settings.phases; const options = settings.options; - const target_phase = options.env; + const target_phase = options.phase; const oc = new OpenShiftClientX(Object.assign({ namespace: phases.build.namespace }, options)); diff --git a/api/.pipeline/scripts/api.deploy.js b/api/.pipeline/scripts/api.deploy.js index 34bf88ea7f..47add18a0b 100644 --- a/api/.pipeline/scripts/api.deploy.js +++ b/api/.pipeline/scripts/api.deploy.js @@ -4,7 +4,7 @@ const process = require('process'); const { apiDeploy } = require('../lib/api.deploy.js'); const config = require('../config.js'); -const settings = { ...config, phase: config.options.env }; +const settings = { ...config, phase: config.options.phase }; process.on('unhandledRejection', (reason, promise) => { console.log('api deploy - unhandled rejection:', promise, 'reason:', reason); diff --git a/api/.pipeline/scripts/clean.js b/api/.pipeline/scripts/clean.js index 62c6a35451..5e7fae7b4f 100644 --- a/api/.pipeline/scripts/clean.js +++ b/api/.pipeline/scripts/clean.js @@ -3,7 +3,7 @@ const { clean } = require('../lib/clean.js'); const config = require('../config.js'); -const settings = { ...config, phase: config.options.env }; +const settings = { ...config, phase: config.options.phase }; // Cleans all build and deployment artifacts (pods, etc) clean(settings); diff --git a/api/.pipeline/templates/api.dc.yaml b/api/.pipeline/templates/api.dc.yaml index c2766a8145..683a8b632a 100644 --- a/api/.pipeline/templates/api.dc.yaml +++ b/api/.pipeline/templates/api.dc.yaml @@ -25,7 +25,8 @@ parameters: - name: NODE_ENV description: Application Environment type variable required: true - value: 'dev' + value: 'development' + - name: NODE_OPTIONS - name: API_PORT_DEFAULT value: '6100' - name: API_PORT_DEFAULT_NAME @@ -206,7 +207,8 @@ objects: role: api spec: containers: - - env: + - name: api + env: - name: API_HOST value: ${HOST} - name: API_PORT @@ -219,6 +221,8 @@ objects: value: ${CHANGE_ID} - name: NODE_ENV value: ${NODE_ENV} + - name: NODE_OPTIONS + value: ${NODE_OPTIONS} # BioHub Platform (aka: Backbone) - name: BACKBONE_API_HOST value: ${BACKBONE_API_HOST} @@ -342,7 +346,6 @@ objects: value: ${GCNOTIFY_SMS_URL} image: ' ' imagePullPolicy: Always - name: api ports: - containerPort: ${{API_PORT_DEFAULT}} protocol: TCP diff --git a/api/package.json b/api/package.json index 3e13ffcbd6..a29ff578ed 100644 --- a/api/package.json +++ b/api/package.json @@ -19,7 +19,8 @@ "lint": "eslint . --ignore-pattern 'node_modules' --ext .ts", "lint-fix": "eslint . --fix --ignore-pattern 'node_modules' --ext .ts", "format": "prettier --check \"./src/**/*.{js,jsx,ts,tsx,css,scss}\"", - "format-fix": "prettier --write \"./src/**/*.{js,jsx,ts,tsx,json,css,scss}\"" + "format-fix": "prettier --write \"./src/**/*.{js,jsx,ts,tsx,json,css,scss}\"", + "fix": "npm-run-all -l -s lint-fix format-fix" }, "engines": { "node": ">= 14.0.0", @@ -63,7 +64,7 @@ "winston": "~3.3.3", "xlsx": "https://cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz", "xml2js": "~0.4.23", - "zod": "^3.21.4" + "zod": "~3.21.4" }, "devDependencies": { "@istanbuljs/nyc-config-typescript": "~1.0.1", @@ -97,7 +98,7 @@ "gulp-typescript": "~5.0.1", "mocha": "~8.4.0", "nodemon": "~2.0.14", - "npm-run-all": "~4.1.5", + "npm-run-all": "^4.1.5", "nyc": "~15.1.0", "prettier": "~2.2.1", "prettier-plugin-organize-imports": "~2.3.4", diff --git a/api/src/__mocks__/db.ts b/api/src/__mocks__/db.ts index 752f61fe0a..8a84bae832 100644 --- a/api/src/__mocks__/db.ts +++ b/api/src/__mocks__/db.ts @@ -84,6 +84,13 @@ export class MockRes { return this; }); + + headerValue: any; + setHeader = sinon.fake((header: any) => { + this.headerValue = header; + + return this; + }); } /** diff --git a/api/src/database/db-utils.ts b/api/src/database/db-utils.ts index f6f1030d4e..947cce5224 100644 --- a/api/src/database/db-utils.ts +++ b/api/src/database/db-utils.ts @@ -5,6 +5,7 @@ import { isBceidBusinessUserInformation, isDatabaseUserInformation, isIdirUserInformation, + isServiceClientUserInformation, KeycloakUserInformation } from '../utils/keycloak-utils'; @@ -115,8 +116,8 @@ export const getGenericizedKeycloakUserInformation = ( ): GenericizedKeycloakUserInformation | null => { let data: GenericizedKeycloakUserInformation | null; - if (isDatabaseUserInformation(keycloakUserInformation)) { - // Don't patch internal database user records + if (isDatabaseUserInformation(keycloakUserInformation) || isServiceClientUserInformation(keycloakUserInformation)) { + // Don't patch internal database/service client user records return null; } diff --git a/api/src/database/db.test.ts b/api/src/database/db.test.ts index 12412602c8..c0897e09aa 100644 --- a/api/src/database/db.test.ts +++ b/api/src/database/db.test.ts @@ -6,6 +6,7 @@ import SQL from 'sql-template-strings'; import { SOURCE_SYSTEM, SYSTEM_IDENTITY_SOURCE } from '../constants/database'; import { ApiExecuteSQLError } from '../errors/api-error'; import { HTTPError } from '../errors/http-error'; +import { DatabaseUserInformation, IdirUserInformation, KeycloakUserInformation } from '../utils/keycloak-utils'; import * as db from './db'; import { getAPIUserDBConnection, @@ -42,7 +43,7 @@ describe('db', () => { describe('getDBConnection', () => { it('throws an error if keycloak token is undefined', () => { try { - getDBConnection((null as unknown) as object); + getDBConnection((null as unknown) as KeycloakUserInformation); expect.fail(); } catch (actualError) { @@ -51,7 +52,7 @@ describe('db', () => { }); it('returns a database connection instance', () => { - const connection = getDBConnection({}); + const connection = getDBConnection({} as DatabaseUserInformation); expect(connection).not.to.be.null; }); @@ -59,7 +60,7 @@ describe('db', () => { describe('DBConnection', () => { const sinonSandbox = Sinon.createSandbox(); - const mockKeycloakToken = { + const mockKeycloakToken: IdirUserInformation = { idir_user_guid: 'testguid', identity_provider: 'idir', idir_username: 'testuser', diff --git a/api/src/database/db.ts b/api/src/database/db.ts index 27b5d38a54..396daf5b1e 100644 --- a/api/src/database/db.ts +++ b/api/src/database/db.ts @@ -3,10 +3,9 @@ import * as pg from 'pg'; import SQL, { SQLStatement } from 'sql-template-strings'; import { z } from 'zod'; import { SOURCE_SYSTEM, SYSTEM_IDENTITY_SOURCE } from '../constants/database'; -import { ApiExecuteSQLError, ApiGeneralError } from '../errors/api-error'; +import { ApiExecuteSQLError } from '../errors/api-error'; import { DatabaseUserInformation, - getKeycloakUserInformationFromKeycloakToken, getUserGuid, getUserIdentitySource, KeycloakUserInformation, @@ -212,7 +211,7 @@ export interface IDBConnection { * @param {object} keycloakToken * @return {*} {IDBConnection} */ -export const getDBConnection = function (keycloakToken: object): IDBConnection { +export const getDBConnection = function (keycloakToken: KeycloakUserInformation): IDBConnection { if (!keycloakToken) { throw Error('Keycloak token is undefined'); } @@ -335,14 +334,32 @@ export const getDBConnection = function (keycloakToken: object): IDBConnection { sqlStatement: SQLStatement, zodSchema?: z.Schema ): Promise> => { + if (process.env.NODE_ENV === 'production') { + // Don't run timers or zod schemas in production + return _query(sqlStatement.text, sqlStatement.values); + } + + const queryStart = Date.now(); const response = await _query(sqlStatement.text, sqlStatement.values); + const queryEnd = Date.now(); - if (zodSchema) { - // Validate the response against the zod schema - return getZodQueryResult(zodSchema).parseAsync(response); + if (!zodSchema) { + defaultLog.silly({ label: '_sql', message: sqlStatement.text, queryExecutionTime: queryEnd - queryStart }); + return response; } - return response; + // Validate the response against the zod schema + const zodStart = Date.now(); + const validatedResponse = getZodQueryResult(zodSchema).parseAsync(response); + const zodEnd = Date.now(); + + defaultLog.silly({ + label: '_sq + zod', + message: sqlStatement.text, + queryExecutionTime: queryEnd - queryStart, + zodExecutionTime: zodEnd - zodStart + }); + return validatedResponse; }; /** @@ -360,14 +377,32 @@ export const getDBConnection = function (keycloakToken: object): IDBConnection { ) => { const { sql, bindings } = queryBuilder.toSQL().toNative(); + if (process.env.NODE_ENV === 'production') { + // Don't run timers or zod schemas in production + return _query(sql, bindings as any[]); + } + + const queryStart = Date.now(); const response = await _query(sql, bindings as any[]); + const queryEnd = Date.now(); - if (zodSchema) { - // Validate the response against the zod schema - return getZodQueryResult(zodSchema).parseAsync(response); + if (!zodSchema) { + defaultLog.silly({ label: '_knex', message: sql, queryExecutionTime: queryEnd - queryStart }); + return response; } - return response; + // Validate the response against the zod schema + const zodStart = Date.now(); + const validatedResponse = getZodQueryResult(zodSchema).parseAsync(response); + const zodEnd = Date.now(); + + defaultLog.silly({ + label: '_knex + zod', + message: sql, + queryExecutionTime: queryEnd - queryStart, + zodExecutionTime: zodEnd - zodStart + }); + return validatedResponse; }; /** @@ -378,24 +413,15 @@ export const getDBConnection = function (keycloakToken: object): IDBConnection { * @return {*} {Promise} */ const _setUserContext = async (): Promise => { - const keycloakUserInformation = getKeycloakUserInformationFromKeycloakToken(_token); - - if (!keycloakUserInformation) { - throw new ApiGeneralError('Failed to identify authenticated user'); - } - - defaultLog.debug({ label: '_setUserContext', keycloakUserInformation }); + defaultLog.debug({ label: '_setUserContext', _token }); // Update the logged in user with their latest information from Keycloak (if it has changed) - await _updateSystemUserInformation(keycloakUserInformation); + await _updateSystemUserInformation(_token); try { // Set the user context in the database, so database queries are aware of the calling user when writing to audit // tables, etc. - _systemUserId = await _setSystemUserContext( - getUserGuid(keycloakUserInformation), - getUserIdentitySource(keycloakUserInformation) - ); + _systemUserId = await _setSystemUserContext(getUserGuid(_token), getUserIdentitySource(_token)); } catch (error) { throw new ApiExecuteSQLError('Failed to set user context', [error as object]); } diff --git a/api/src/models/biohub-create.test.ts b/api/src/models/biohub-create.test.ts new file mode 100644 index 0000000000..92216be922 --- /dev/null +++ b/api/src/models/biohub-create.test.ts @@ -0,0 +1,146 @@ +import { expect } from 'chai'; +import { describe } from 'mocha'; +import { ObservationRecord } from '../repositories/observation-repository'; +import { PostSurveyObservationToBiohubObject, PostSurveyToBiohubObject } from './biohub-create'; +import { GetSurveyData } from './survey-view'; + +describe('PostSurveyObservationToBiohubObject', () => { + describe('All values provided', () => { + let data: PostSurveyObservationToBiohubObject; + + const obj = { + survey_observation_id: 1, + survey_id: 1, + wldtaxonomic_units_id: 1, + survey_sample_site_id: 1, + survey_sample_method_id: 1, + survey_sample_period_id: 1, + latitude: 1, + longitude: 1, + count: 1, + observation_time: 'observation_time', + observation_date: 'observation_date', + create_date: 'create_date', + create_user: 1, + update_date: 'update_date', + update_user: 1, + revision_count: 1 + } as ObservationRecord; + + before(() => { + data = new PostSurveyObservationToBiohubObject(obj); + }); + + it('sets id', () => { + expect(data.id).to.equal('1'); + }); + + it('sets type', () => { + expect(data.type).to.equal('observation'); + }); + + it('sets properties', () => { + expect(data.properties).to.eql({ + survey_id: 1, + taxonomy: 1, + survey_sample_site_id: 1, + survey_sample_method_id: 1, + survey_sample_period_id: 1, + latitude: 1, + longitude: 1, + count: 1, + observation_time: 'observation_time', + observation_date: 'observation_date' + }); + }); + }); +}); + +describe('PostSurveyToBiohubObject', () => { + describe('All values provided', () => { + let data: PostSurveyToBiohubObject; + + const observation_obj = { + survey_observation_id: 1, + survey_id: 1, + wldtaxonomic_units_id: 1, + survey_sample_site_id: 1, + survey_sample_method_id: 1, + survey_sample_period_id: 1, + latitude: 1, + longitude: 1, + count: 1, + observation_time: 'observation_time', + observation_date: 'observation_date', + create_date: 'create_date', + create_user: 1, + update_date: 'update_date', + update_user: 1, + revision_count: 1 + } as ObservationRecord; + + const survey_obj = { + id: 1, + uuid: '1', + project_id: 1, + survey_name: 'survey_name', + start_date: 'start_date', + end_date: 'end_date', + survey_types: [9], + revision_count: 1, + create_date: 'create_date', + create_user: 1, + update_date: 'update_date', + update_user: 1, + geometry: [] + } as GetSurveyData; + + before(() => { + data = new PostSurveyToBiohubObject(survey_obj, [observation_obj], 'additionalInformation'); + }); + + it('sets id', () => { + expect(data.id).to.equal('1'); + }); + + it('sets type', () => { + expect(data.type).to.equal('dataset'); + }); + + it('sets properties', () => { + expect(data.properties).to.eql({ + additional_information: 'additionalInformation', + survey_id: 1, + project_id: 1, + name: 'survey_name', + start_date: 'start_date', + end_date: 'end_date', + survey_types: [9], + revision_count: 1, + geometry: [] + }); + }); + + it('sets features', () => { + expect(data.features).to.eql([ + { + id: '1', + type: 'observation', + properties: { + survey_id: 1, + taxonomy: 1, + survey_sample_site_id: 1, + survey_sample_method_id: 1, + survey_sample_period_id: 1, + latitude: 1, + longitude: 1, + count: 1, + observation_time: 'observation_time', + observation_date: 'observation_date' + }, + features: [] + } + ]); + }); + }); +}); diff --git a/api/src/models/biohub-create.ts b/api/src/models/biohub-create.ts new file mode 100644 index 0000000000..292c68269f --- /dev/null +++ b/api/src/models/biohub-create.ts @@ -0,0 +1,75 @@ +import { ObservationRecord } from '../repositories/observation-repository'; +import { getLogger } from '../utils/logger'; +import { GetSurveyData } from './survey-view'; + +const defaultLog = getLogger('models/biohub-create'); + +/** + * Object to be sent to Biohub API for creating an observation. + * + * @export + * @class PostSurveyObservationToBiohubObject + */ +export class PostSurveyObservationToBiohubObject { + id: string; + type: string; + properties: object; + features: []; + + constructor(observationRecord: ObservationRecord) { + defaultLog.debug({ label: 'PostSurveyObservationToBiohubObject', message: 'params', observationRecord }); + + this.id = String(observationRecord.survey_observation_id); + this.type = BiohubFeatureType.OBSERVATION; + this.properties = { + survey_id: observationRecord.survey_id, + taxonomy: observationRecord.wldtaxonomic_units_id, + survey_sample_site_id: observationRecord?.survey_sample_site_id || null, + survey_sample_method_id: observationRecord?.survey_sample_method_id || null, + survey_sample_period_id: observationRecord?.survey_sample_period_id || null, + latitude: observationRecord.latitude, + longitude: observationRecord.longitude, + count: observationRecord.count, + observation_time: observationRecord.observation_time, + observation_date: observationRecord.observation_date + }; + this.features = []; + } +} + +/** + * Object to be sent to Biohub API for creating a survey. + * + * @export + * @class PostSurveyToBiohubObject + */ +export class PostSurveyToBiohubObject { + id: string; + type: string; + properties: object; + features: PostSurveyObservationToBiohubObject[]; + + constructor(surveyData: GetSurveyData, observationRecords: ObservationRecord[], additionalInformation?: string) { + defaultLog.debug({ label: 'PostSurveyToBiohubObject', message: 'params', surveyData }); + + this.id = surveyData.uuid; + this.type = BiohubFeatureType.DATASET; + this.properties = { + additional_information: additionalInformation ?? null, + survey_id: surveyData.id, + project_id: surveyData.project_id, + name: surveyData.survey_name, + start_date: surveyData.start_date, + end_date: surveyData.end_date, + survey_types: surveyData.survey_types, + revision_count: surveyData.revision_count, + geometry: surveyData.geometry + }; + this.features = observationRecords.map((observation) => new PostSurveyObservationToBiohubObject(observation)); + } +} + +export enum BiohubFeatureType { + DATASET = 'dataset', + OBSERVATION = 'observation' +} diff --git a/api/src/models/project-create.test.ts b/api/src/models/project-create.test.ts index 41c926f648..584d233fcf 100644 --- a/api/src/models/project-create.test.ts +++ b/api/src/models/project-create.test.ts @@ -1,12 +1,6 @@ import { expect } from 'chai'; import { describe } from 'mocha'; -import { - PostIUCNData, - PostLocationData, - PostObjectivesData, - PostProjectData, - PostProjectObject -} from './project-create'; +import { PostIUCNData, PostObjectivesData, PostProjectData, PostProjectObject } from './project-create'; describe('PostProjectObject', () => { describe('No values provided', () => { @@ -24,10 +18,6 @@ describe('PostProjectObject', () => { expect(projectPostObject.objectives).to.equal(null); }); - it('sets location', function () { - expect(projectPostObject.location).to.equal(null); - }); - it('sets iucn', function () { expect(projectPostObject.iucn).to.equal(null); }); @@ -165,58 +155,3 @@ describe('PostIUCNData', () => { }); }); }); - -describe('PostLocationData', () => { - describe('No values provided', () => { - let projectLocationData: PostLocationData; - - before(() => { - projectLocationData = new PostLocationData(null); - }); - - it('sets location_description', function () { - expect(projectLocationData.location_description).to.be.undefined; - }); - - it('sets geometry', function () { - expect(projectLocationData.geometry).to.eql([]); - }); - }); - - describe('All values provided', () => { - let projectLocationData: PostLocationData; - - const obj = { - location_description: 'a location description', - geometry: [ - { - type: 'Polygon', - coordinates: [ - [ - [-128, 55], - [-128, 55.5], - [-128, 56], - [-126, 58], - [-128, 55] - ] - ], - properties: { - name: 'Biohub Islands' - } - } - ] - }; - - before(() => { - projectLocationData = new PostLocationData(obj); - }); - - it('sets location_description', function () { - expect(projectLocationData.location_description).to.equal('a location description'); - }); - - it('sets the geometry', function () { - expect(projectLocationData.geometry).to.eql(obj.geometry); - }); - }); -}); diff --git a/api/src/models/project-create.ts b/api/src/models/project-create.ts index 3c52222f07..a59d86420c 100644 --- a/api/src/models/project-create.ts +++ b/api/src/models/project-create.ts @@ -1,4 +1,3 @@ -import { Feature } from 'geojson'; import { SYSTEM_IDENTITY_SOURCE } from '../constants/database'; import { PROJECT_ROLE } from '../constants/roles'; import { getLogger } from '../utils/logger'; @@ -14,7 +13,6 @@ const defaultLog = getLogger('models/project-create'); export class PostProjectObject { project: PostProjectData; objectives: PostObjectivesData; - location: PostLocationData; iucn: PostIUCNData; participants: PostParticipantData[]; @@ -23,7 +21,6 @@ export class PostProjectObject { this.project = (obj?.project && new PostProjectData(obj.project)) || null; this.objectives = (obj?.project && new PostObjectivesData(obj.objectives)) || null; - this.location = (obj?.location && new PostLocationData(obj.location)) || null; this.iucn = (obj?.iucn && new PostIUCNData(obj.iucn)) || null; this.participants = obj?.participants || []; } @@ -69,33 +66,6 @@ export class PostObjectivesData { } } -/** - * Processes POST /project location data - * - * @export - * @class PostLocationData - */ -export class PostLocationData { - location_description: string; - geometry: Feature[]; - - constructor(obj?: any) { - defaultLog.debug({ - label: 'PostLocationData', - message: 'params', - obj: { - ...obj, - geometry: obj?.geometry?.map((item: any) => { - return { ...item, geometry: 'Too big to print' }; - }) - } - }); - - this.location_description = obj?.location_description; - this.geometry = (obj?.geometry?.length && obj.geometry) || []; - } -} - export interface IPostIUCN { classification: number; subClassification1: number; diff --git a/api/src/models/project-update.test.ts b/api/src/models/project-update.test.ts index ffa6914c29..91770246fb 100644 --- a/api/src/models/project-update.test.ts +++ b/api/src/models/project-update.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { describe } from 'mocha'; -import { PutIUCNData, PutLocationData, PutObjectivesData, PutProjectData } from './project-update'; +import { PutIUCNData, PutObjectivesData, PutProjectData } from './project-update'; describe('PutProjectData', () => { describe('No values provided', () => { @@ -107,70 +107,6 @@ describe('PutObjectivesData', () => { }); }); -describe('PutLocationData', () => { - describe('No values provided', () => { - let data: PutLocationData; - - before(() => { - data = new PutLocationData(null); - }); - - it('sets location_description', () => { - expect(data.location_description).to.undefined; - }); - - it('sets geometry', () => { - expect(data.geometry).to.eql([]); - }); - - it('sets revision_count', () => { - expect(data.revision_count).to.eql(null); - }); - }); - - describe('All values provided', () => { - let data: PutLocationData; - - const obj = { - location_description: 'location', - geometry: [ - { - type: 'Polygon', - coordinates: [ - [ - [-128, 55], - [-128, 55.5], - [-128, 56], - [-126, 58], - [-128, 55] - ] - ], - properties: { - name: 'Biohub Islands' - } - } - ], - revision_count: 1 - }; - - before(() => { - data = new PutLocationData(obj); - }); - - it('sets location_description', () => { - expect(data.location_description).to.equal(obj.location_description); - }); - - it('sets geometry', () => { - expect(data.geometry).to.eql(obj.geometry); - }); - - it('sets revision_count', () => { - expect(data.revision_count).to.eql(obj.revision_count); - }); - }); -}); - describe('PutIUCNData', () => { describe('No values provided', () => { let data: PutIUCNData; diff --git a/api/src/models/project-update.ts b/api/src/models/project-update.ts index 871ac566f6..f66364a269 100644 --- a/api/src/models/project-update.ts +++ b/api/src/models/project-update.ts @@ -1,4 +1,3 @@ -import { Feature } from 'geojson'; import { getLogger } from '../utils/logger'; const defaultLog = getLogger('models/project-update'); @@ -32,30 +31,6 @@ export class PutObjectivesData { this.revision_count = obj?.revision_count ?? null; } } - -export class PutLocationData { - location_description: string; - geometry: Feature[]; - revision_count: number; - - constructor(obj?: any) { - defaultLog.debug({ - label: 'PutLocationData', - message: 'params', - obj: { - ...obj, - geometry: obj?.geometry?.map((item: any) => { - return { ...item, geometry: 'Too big to print' }; - }) - } - }); - - this.location_description = obj?.location_description; - this.geometry = (obj?.geometry?.length && obj.geometry) || []; - this.revision_count = obj?.revision_count ?? null; - } -} - export interface IPutIUCN { classification: number; subClassification1: number; diff --git a/api/src/models/project-view.test.ts b/api/src/models/project-view.test.ts index 1e9f77a635..c66afe337d 100644 --- a/api/src/models/project-view.test.ts +++ b/api/src/models/project-view.test.ts @@ -3,7 +3,6 @@ import { describe } from 'mocha'; import { GetAttachmentsData, GetIUCNClassificationData, - GetLocationData, GetObjectivesData, GetReportAttachmentsData, ProjectData @@ -129,87 +128,6 @@ describe('GetObjectivesData', () => { }); }); -describe('GetLocationData', () => { - describe('No values provided', () => { - let locationData: GetLocationData; - - before(() => { - locationData = new GetLocationData(null); - }); - - it('sets location_description', function () { - expect(locationData.location_description).to.equal(''); - }); - - it('sets the geometry', function () { - expect(locationData.geometry).to.eql([]); - }); - }); - - describe('Empty array values provided', () => { - let locationData: GetLocationData; - - before(() => { - locationData = new GetLocationData([]); - }); - - it('sets location_description', function () { - expect(locationData.location_description).to.equal(''); - }); - - it('sets the geometry', function () { - expect(locationData.geometry).to.eql([]); - }); - }); - - describe('All values provided', () => { - let locationData: GetLocationData; - - const location_description = 'location description'; - const geometry = [ - { - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [125.6, 10.1] - }, - properties: { - name: 'Dinagat Islands' - } - } - ]; - - const locationDataObj = [ - { - location_description, - geometry, - revision_count: 'count' - }, - { - location_description, - geometry, - revision_count: 'count' - } - ]; - - before(() => { - locationData = new GetLocationData(locationDataObj); - }); - - it('sets location_description', function () { - expect(locationData.location_description).to.equal(location_description); - }); - - it('sets the geometry', function () { - expect(locationData.geometry).to.eql(geometry); - }); - - it('sets revision_count', function () { - expect(locationData.revision_count).to.equal('count'); - }); - }); -}); - describe('GetIUCNClassificationData', () => { describe('No values provided', () => { let iucnClassificationData: GetIUCNClassificationData; diff --git a/api/src/models/project-view.ts b/api/src/models/project-view.ts index 31e02ef6a6..d6580bc9bb 100644 --- a/api/src/models/project-view.ts +++ b/api/src/models/project-view.ts @@ -1,4 +1,3 @@ -import { Feature } from 'geojson'; import { z } from 'zod'; import { ProjectMetadataPublish } from '../repositories/history-publish-repository'; import { ProjectUser } from '../repositories/project-participation-repository'; @@ -18,7 +17,6 @@ export interface IGetProject { project: ProjectData; objectives: GetObjectivesData; participants: (ProjectUser & SystemUser)[]; - location: GetLocationData; iucn: GetIUCNClassificationData; } @@ -63,26 +61,6 @@ export class GetObjectivesData { } } -/** - * Pre-processes GET /projects/{id} location data - * - * @export - * @class GetLocationData - */ -export class GetLocationData { - location_description: string; - geometry?: Feature[]; - revision_count: number; - - constructor(locationData?: any) { - const locationDataItem = locationData?.length && locationData[0]; - - this.location_description = locationDataItem?.location_description || ''; - this.geometry = (locationDataItem?.geometry?.length && locationDataItem.geometry) || []; - this.revision_count = locationDataItem?.revision_count ?? null; - } -} - interface IGetIUCN { classification: number; subClassification1: number; diff --git a/api/src/models/survey-create.test.ts b/api/src/models/survey-create.test.ts index dd24d63f85..3326bdff85 100644 --- a/api/src/models/survey-create.test.ts +++ b/api/src/models/survey-create.test.ts @@ -2,7 +2,6 @@ import { expect } from 'chai'; import { describe } from 'mocha'; import { PostAgreementsData, - PostLocationData, PostPartnershipsData, PostPermitData, PostProprietorData, @@ -362,54 +361,6 @@ describe('PostProprietorData', () => { }); }); -describe('PostLocationData', () => { - describe('No values provided', () => { - let data: PostLocationData; - - before(() => { - data = new PostLocationData(null); - }); - - it('sets name', () => { - expect(data.name).to.equal(null); - }); - - it('sets description', () => { - expect(data.description).to.equal(null); - }); - - it('sets geojson', () => { - expect(data.geojson).to.eql([]); - }); - }); - - describe('All values provided with first nations id', () => { - let data: PostLocationData; - - const obj = { - name: 'area name', - description: 'area description', - geojson: [{}] - }; - - before(() => { - data = new PostLocationData(obj); - }); - - it('sets name', () => { - expect(data.name).to.equal(obj.name); - }); - - it('sets description', () => { - expect(data.description).to.equal(obj.description); - }); - - it('sets geojson', () => { - expect(data.geojson).to.eql(obj.geojson); - }); - }); -}); - describe('PostPurposeAndMethodologyData', () => { describe('No values provided', () => { let data: PostPurposeAndMethodologyData; @@ -419,21 +370,13 @@ describe('PostPurposeAndMethodologyData', () => { }); it('sets intended_outcome_id', () => { - expect(data.intended_outcome_id).to.equal(null); + expect(data.intended_outcome_ids).to.eql([]); }); it('sets additional_details', () => { expect(data.additional_details).to.equal(null); }); - it('sets field_method_id', () => { - expect(data.field_method_id).to.equal(null); - }); - - it('sets ecological_season_id', () => { - expect(data.ecological_season_id).to.equal(null); - }); - it('sets vantage_code_ids', () => { expect(data.vantage_code_ids).to.eql([]); }); @@ -447,10 +390,8 @@ describe('PostPurposeAndMethodologyData', () => { let data: PostPurposeAndMethodologyData; const obj = { - intended_outcome_id: 1, + intended_outcome_ids: [1], additional_details: 'additional_detail', - field_method_id: 2, - ecological_season_id: 3, vantage_code_ids: [4, 5], surveyed_all_areas: true }; @@ -460,21 +401,13 @@ describe('PostPurposeAndMethodologyData', () => { }); it('sets intended_outcome_id', () => { - expect(data.intended_outcome_id).to.equal(obj.intended_outcome_id); + expect(data.intended_outcome_ids).to.equal(obj.intended_outcome_ids); }); it('sets additional_details', () => { expect(data.additional_details).to.eql(obj.additional_details); }); - it('sets field_method_id', () => { - expect(data.field_method_id).to.eql(obj.field_method_id); - }); - - it('sets ecological_season_id', () => { - expect(data.ecological_season_id).to.eql(obj.ecological_season_id); - }); - 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 4d1edffe16..deeeec215e 100644 --- a/api/src/models/survey-create.ts +++ b/api/src/models/survey-create.ts @@ -1,6 +1,6 @@ -import { Feature } from 'geojson'; import { SurveyStratum } from '../repositories/site-selection-strategy-repository'; import { PostSurveyBlock } from '../repositories/survey-block-repository'; +import { PostSurveyLocationData } from './survey-update'; export class PostSurveyObject { survey_details: PostSurveyDetailsData; @@ -9,7 +9,7 @@ export class PostSurveyObject { funding_sources: PostFundingSourceData[]; proprietor: PostProprietorData; purpose_and_methodology: PostPurposeAndMethodologyData; - locations: PostLocationData[]; + locations: PostSurveyLocationData[]; agreements: PostAgreementsData; participants: PostParticipationData[]; partnerships: PostPartnershipsData; @@ -29,7 +29,7 @@ export class PostSurveyObject { this.participants = (obj?.participants?.length && obj.participants.map((p: any) => new PostParticipationData(p))) || []; this.partnerships = (obj?.partnerships && new PostPartnershipsData(obj.partnerships)) || null; - this.locations = (obj?.locations && obj.locations.map((p: any) => new PostLocationData(p))) || []; + this.locations = (obj?.locations && obj.locations.map((p: any) => new PostSurveyLocationData(p))) || []; this.site_selection = (obj?.site_selection && new PostSiteSelectionData(obj)) || null; this.blocks = (obj?.blocks && obj.blocks.map((p: any) => p as PostSurveyBlock)) || []; } @@ -67,7 +67,7 @@ export class PostFundingSourceData { constructor(obj?: any) { this.funding_source_id = obj?.funding_source_id || null; - this.amount = obj?.amount || null; + this.amount = obj?.amount ?? null; } } @@ -120,31 +120,15 @@ export class PostProprietorData { } } -export class PostLocationData { - name: string; - description: string; - geojson: Feature[]; - - constructor(obj?: any) { - this.name = obj?.name || null; - this.description = obj?.description || null; - this.geojson = (obj?.geojson?.length && obj.geojson) || []; - } -} - export class PostPurposeAndMethodologyData { - intended_outcome_id: number; + intended_outcome_ids: number[]; additional_details: string; - field_method_id: number; - ecological_season_id: number; vantage_code_ids: number[]; surveyed_all_areas: boolean; constructor(obj?: any) { - this.intended_outcome_id = obj?.intended_outcome_id || null; + this.intended_outcome_ids = obj?.intended_outcome_ids || []; this.additional_details = obj?.additional_details || null; - this.field_method_id = obj?.field_method_id || null; - this.ecological_season_id = obj?.ecological_season_id || null; this.vantage_code_ids = obj?.vantage_code_ids || []; this.surveyed_all_areas = obj?.surveyed_all_areas || null; } diff --git a/api/src/models/survey-update.test.ts b/api/src/models/survey-update.test.ts index d3e393eca3..8d0cdcfd84 100644 --- a/api/src/models/survey-update.test.ts +++ b/api/src/models/survey-update.test.ts @@ -1,9 +1,9 @@ import { expect } from 'chai'; import { describe } from 'mocha'; import { + PostSurveyLocationData, PutPartnershipsData, PutSurveyDetailsData, - PutSurveyLocationData, PutSurveyObject, PutSurveyPermitData, PutSurveyProprietorData, @@ -333,21 +333,13 @@ describe('PutPurposeAndMethodologyData', () => { }); it('sets intended_outcome_id', () => { - expect(data.intended_outcome_id).to.equal(null); + expect(data.intended_outcome_ids).to.eql([]); }); it('sets additional_details', () => { expect(data.additional_details).to.equal(null); }); - it('sets field_method_id', () => { - expect(data.field_method_id).to.equal(null); - }); - - it('sets ecological_season_id', () => { - expect(data.ecological_season_id).to.equal(null); - }); - it('sets vantage_code_ids', () => { expect(data.vantage_code_ids).to.eql([]); }); @@ -365,10 +357,8 @@ describe('PutPurposeAndMethodologyData', () => { let data: PutSurveyPurposeAndMethodologyData; const obj = { - intended_outcome_id: 1, + intended_outcome_ids: [1], additional_details: 'additional_detail', - field_method_id: 2, - ecological_season_id: 3, vantage_code_ids: [4, 5], surveyed_all_areas: 'true', revision_count: 0 @@ -379,21 +369,13 @@ describe('PutPurposeAndMethodologyData', () => { }); it('sets intended_outcome_id', () => { - expect(data.intended_outcome_id).to.equal(obj.intended_outcome_id); + expect(data.intended_outcome_ids).to.equal(obj.intended_outcome_ids); }); it('sets additional_details', () => { expect(data.additional_details).to.equal(obj.additional_details); }); - it('sets field_method_id', () => { - expect(data.field_method_id).to.equal(obj.field_method_id); - }); - - it('sets ecological_season_id', () => { - expect(data.ecological_season_id).to.equal(obj.ecological_season_id); - }); - it('sets vantage_code_ids', () => { expect(data.vantage_code_ids).to.eql(obj.vantage_code_ids); }); @@ -410,10 +392,10 @@ describe('PutPurposeAndMethodologyData', () => { describe('PutLocationData', () => { describe('No values provided', () => { - let data: PutSurveyLocationData; + let data: PostSurveyLocationData; before(() => { - data = new PutSurveyLocationData(null); + data = new PostSurveyLocationData(null); }); it('sets name', () => { @@ -421,7 +403,7 @@ describe('PutLocationData', () => { }); it('sets description', () => { - expect(data.description).to.equal(null); + expect(data.description).to.equal(''); }); it('sets geojson', () => { @@ -434,7 +416,7 @@ describe('PutLocationData', () => { }); describe('All values provided with first nations id', () => { - let data: PutSurveyLocationData; + let data: PostSurveyLocationData; const obj = { name: 'area name', @@ -444,7 +426,7 @@ describe('PutLocationData', () => { }; before(() => { - data = new PutSurveyLocationData(obj); + data = new PostSurveyLocationData(obj); }); it('sets name', () => { diff --git a/api/src/models/survey-update.ts b/api/src/models/survey-update.ts index 301fc2a1a0..5cccffca42 100644 --- a/api/src/models/survey-update.ts +++ b/api/src/models/survey-update.ts @@ -9,7 +9,7 @@ export class PutSurveyObject { funding_sources: PutFundingSourceData[]; proprietor: PutSurveyProprietorData; purpose_and_methodology: PutSurveyPurposeAndMethodologyData; - locations: PutSurveyLocationData[]; + locations: PostSurveyLocationData[]; participants: PutSurveyParticipantsData[]; partnerships: PutPartnershipsData; site_selection: PutSiteSelectionData; @@ -26,7 +26,7 @@ export class PutSurveyObject { (obj?.purpose_and_methodology && new PutSurveyPurposeAndMethodologyData(obj.purpose_and_methodology)) || null; this.participants = (obj?.participants?.length && obj.participants.map((p: any) => new PutSurveyParticipantsData(p))) || []; - this.locations = (obj?.locations && obj.locations.map((p: any) => new PutSurveyLocationData(p))) || []; + this.locations = (obj?.locations && obj.locations.map((p: any) => new PostSurveyLocationData(p))) || []; this.partnerships = (obj?.partnerships && new PutPartnershipsData(obj.partnerships)) || null; this.site_selection = (obj?.site_selection && new PutSiteSelectionData(obj)) || null; this.blocks = (obj?.blocks && obj.blocks.map((p: any) => p as PostSurveyBlock)) || []; @@ -74,7 +74,7 @@ export class PutFundingSourceData { constructor(obj?: any) { this.survey_funding_source_id = obj?.survey_funding_source_id || null; this.funding_source_id = obj?.funding_source_id || null; - this.amount = obj?.amount || null; + this.amount = obj?.amount ?? null; this.revision_count = obj?.revision_count || 0; } } @@ -131,36 +131,33 @@ export class PutSurveyProprietorData { } } export class PutSurveyPurposeAndMethodologyData { - intended_outcome_id: number; - field_method_id: number; + intended_outcome_ids: number[]; additional_details: string; - ecological_season_id: number; vantage_code_ids: number[]; surveyed_all_areas: boolean; revision_count: number; constructor(obj?: any) { - this.intended_outcome_id = obj?.intended_outcome_id || null; - this.field_method_id = obj?.field_method_id || null; + this.intended_outcome_ids = (obj?.intended_outcome_ids?.length && obj?.intended_outcome_ids) || []; this.additional_details = obj?.additional_details || null; - this.ecological_season_id = obj?.ecological_season_id || null; this.vantage_code_ids = (obj?.vantage_code_ids?.length && obj.vantage_code_ids) || []; this.surveyed_all_areas = obj?.surveyed_all_areas === 'true' || false; this.revision_count = obj?.revision_count ?? null; } } -export class PutSurveyLocationData { - survey_location_id: number; +// This class is used for both insert and updating a survey location +export class PostSurveyLocationData { + survey_location_id: number | undefined; name: string; description: string; geojson: Feature[]; - revision_count: number; + revision_count: number | undefined; constructor(obj?: any) { this.survey_location_id = obj?.survey_location_id || null; this.name = obj?.name || null; - this.description = obj?.description || null; + this.description = obj?.description || ''; this.geojson = (obj?.geojson?.length && obj.geojson) || []; 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 2c057bd8c7..ba2d42033d 100644 --- a/api/src/models/survey-view.test.ts +++ b/api/src/models/survey-view.test.ts @@ -337,21 +337,13 @@ describe('GetSurveyPurposeAndMethodologyData', () => { }); it('sets intended_outcome_id', () => { - expect(data.intended_outcome_id).to.equal(null); + expect(data.intended_outcome_ids).to.eql([]); }); it('sets additional_details', () => { expect(data.additional_details).to.equal(''); }); - it('sets field_method_id', () => { - expect(data.field_method_id).to.equal(null); - }); - - it('sets ecological_season_id', () => { - expect(data.ecological_season_id).to.equal(null); - }); - it('sets vantage_code_ids', () => { expect(data.vantage_code_ids).to.eql([]); }); @@ -361,10 +353,8 @@ describe('GetSurveyPurposeAndMethodologyData', () => { let data: GetSurveyPurposeAndMethodologyData; const obj = { - intended_outcome_id: 1, + intended_outcome_ids: [1], additional_details: 'additional_detail', - field_method_id: 2, - ecological_season_id: 3, vantage_ids: [4, 5], revision_count: 'count' }; @@ -374,21 +364,13 @@ describe('GetSurveyPurposeAndMethodologyData', () => { }); it('sets intended_outcome_id', () => { - expect(data.intended_outcome_id).to.equal(obj.intended_outcome_id); + expect(data.intended_outcome_ids).to.equal(obj.intended_outcome_ids); }); it('sets additional_details', () => { expect(data.additional_details).to.eql(obj.additional_details); }); - it('sets field_method_id', () => { - expect(data.field_method_id).to.eql(obj.field_method_id); - }); - - it('sets ecological_season_id', () => { - expect(data.ecological_season_id).to.eql(obj.ecological_season_id); - }); - it('sets vantage_code_ids', () => { expect(data.vantage_code_ids).to.eql(obj.vantage_ids); }); diff --git a/api/src/models/survey-view.ts b/api/src/models/survey-view.ts index ca6285cd67..fe6506cd5b 100644 --- a/api/src/models/survey-view.ts +++ b/api/src/models/survey-view.ts @@ -65,7 +65,7 @@ export class GetSurveyFundingSourceData { this.survey_funding_source_id = obj?.survey_funding_source_id || null; this.funding_source_id = obj?.funding_source_id || null; this.survey_id = obj?.survey_id || null; - this.amount = obj?.amount || null; + this.amount = obj?.amount ?? null; this.revision_count = obj?.revision_count || 0; this.funding_source_name = obj?.funding_source_name || null; this.start_date = obj?.start_date || null; @@ -123,18 +123,14 @@ export class GetPermitData { } export class GetSurveyPurposeAndMethodologyData { - intended_outcome_id: number; + intended_outcome_ids: number[]; additional_details: string; - field_method_id: number; - ecological_season_id: number; revision_count: number; vantage_code_ids: number[]; constructor(obj?: any) { - this.intended_outcome_id = obj?.intended_outcome_id || null; + this.intended_outcome_ids = (obj?.intended_outcome_ids?.length && obj?.intended_outcome_ids) || []; this.additional_details = obj?.additional_details || ''; - this.field_method_id = obj?.field_method_id || null; - this.ecological_season_id = obj?.ecological_season_id || null; this.vantage_code_ids = (obj?.vantage_ids?.length && obj.vantage_ids) || []; this.revision_count = obj?.revision_count ?? 0; } diff --git a/api/src/openapi/schemas/project.ts b/api/src/openapi/schemas/project.ts index 31a521e5c7..770f1b7b21 100644 --- a/api/src/openapi/schemas/project.ts +++ b/api/src/openapi/schemas/project.ts @@ -6,7 +6,7 @@ import { PROJECT_ROLE } from '../../constants/roles'; export const projectCreatePostRequestObject = { title: 'Project post request object', type: 'object', - required: ['project', 'location', 'iucn', 'participants'], + required: ['project', 'iucn', 'participants'], properties: { project: { title: 'Project details', @@ -33,16 +33,6 @@ export const projectCreatePostRequestObject = { } } }, - location: { - title: 'Location', - type: 'object', - properties: { - location_description: { - type: 'string', - description: 'Location description' - } - } - }, iucn: { title: 'Project IUCN classifications', type: 'object', @@ -93,7 +83,6 @@ export const projectCreatePostRequestObject = { const projectUpdateProperties = { project: { type: 'object', properties: {} }, objectives: { type: 'object', properties: {} }, - location: { type: 'object', properties: {} }, iucn: { type: 'object', properties: { diff --git a/api/src/paths/codes.ts b/api/src/paths/codes.ts index f9fecdd770..f747e4069e 100644 --- a/api/src/paths/codes.ts +++ b/api/src/paths/codes.ts @@ -33,8 +33,6 @@ GET.apiDoc = { 'system_roles', 'project_roles', 'administrative_activity_status_type', - 'field_methods', - 'ecological_seasons', 'intended_outcomes', 'vantage_codes', 'site_selection_strategies' @@ -263,34 +261,6 @@ GET.apiDoc = { } } }, - field_methods: { - type: 'array', - items: { - type: 'object', - properties: { - id: { - type: 'number' - }, - name: { - type: 'string' - } - } - } - }, - ecological_seasons: { - type: 'array', - items: { - type: 'object', - properties: { - id: { - type: 'number' - }, - name: { - type: 'string' - } - } - } - }, intended_outcomes: { type: 'array', items: { diff --git a/api/src/paths/funding-sources/index.ts b/api/src/paths/funding-sources/index.ts index da974d9d6e..e126d75a9b 100644 --- a/api/src/paths/funding-sources/index.ts +++ b/api/src/paths/funding-sources/index.ts @@ -6,6 +6,7 @@ import { FundingSource, FundingSourceSupplementaryData } from '../../repositorie import { SystemUser } from '../../repositories/user-repository'; import { authorizeRequestHandler } from '../../request-handlers/security/authorization'; import { FundingSourceService, IFundingSourceSearchParams } from '../../services/funding-source-service'; +import { UserService } from '../../services/user-service'; import { getLogger } from '../../utils/logger'; const defaultLog = getLogger('paths/funding-sources/index'); @@ -121,7 +122,7 @@ export function getFundingSources(): RequestHandler { await connection.commit(); const systemUserObject: SystemUser = req['system_user']; - if (!isAdmin(systemUserObject)) { + if (!UserService.isAdmin(systemUserObject)) { // User is not an admin, strip sensitive fields from response response = removeNonAdminFieldsFromFundingSourcesResponse(response); } @@ -137,19 +138,6 @@ export function getFundingSources(): RequestHandler { }; } -/** - * Checks if the system user is an admin (has an admin level system role). - * - * @param {SystemUser} systemUserObject - * @return {*} {boolean} `true` if the user is an admin, `false` otherwise. - */ -function isAdmin(systemUserObject: SystemUser): boolean { - return ( - systemUserObject.role_names.includes(SYSTEM_ROLE.SYSTEM_ADMIN) || - systemUserObject.role_names.includes(SYSTEM_ROLE.DATA_ADMINISTRATOR) - ); -} - /** * Removes sensitive (admin-only) fields from the funding sources response, returning a new sanitized array. * diff --git a/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.ts b/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.ts index 2d643a2b2f..846ec36cd3 100644 --- a/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.ts +++ b/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.ts @@ -5,6 +5,7 @@ import { getDBConnection } from '../../../../../database/db'; import { SystemUser } from '../../../../../repositories/user-repository'; import { authorizeRequestHandler } from '../../../../../request-handlers/security/authorization'; import { AttachmentService } from '../../../../../services/attachment-service'; +import { UserService } from '../../../../../services/user-service'; import { getLogger } from '../../../../../utils/logger'; import { attachmentApiDocObject } from '../../../../../utils/shared-api-docs'; @@ -104,15 +105,12 @@ export function deleteAttachment(): RequestHandler { const attachmentService = new AttachmentService(connection); const systemUserObject: SystemUser = req['system_user']; - const isAdmin = - systemUserObject.role_names.includes(SYSTEM_ROLE.SYSTEM_ADMIN) || - systemUserObject.role_names.includes(SYSTEM_ROLE.DATA_ADMINISTRATOR); await attachmentService.handleDeleteProjectAttachment( Number(req.params.projectId), Number(req.params.attachmentId), req.body.attachmentType, - isAdmin + UserService.isAdmin(systemUserObject) ); await connection.commit(); diff --git a/api/src/paths/project/{projectId}/survey/create.ts b/api/src/paths/project/{projectId}/survey/create.ts index 424e13f02e..9527201084 100644 --- a/api/src/paths/project/{projectId}/survey/create.ts +++ b/api/src/paths/project/{projectId}/survey/create.ts @@ -195,29 +195,28 @@ POST.apiDoc = { purpose_and_methodology: { type: 'object', properties: { - intended_outcome_id: { - type: 'number' + intended_outcome_ids: { + type: 'array', + minItems: 1, + items: { + type: 'integer' + } }, additional_details: { type: 'string' }, - field_method_id: { - type: 'number' - }, vantage_code_ids: { type: 'array', items: { type: 'number' } - }, - ecological_season_id: { - type: 'number' } } }, locations: { description: 'Survey location data', type: 'array', + minItems: 1, items: { type: 'object', required: ['name', 'description', 'geojson'], @@ -245,6 +244,7 @@ POST.apiDoc = { properties: { strategies: { type: 'array', + minItems: 1, items: { type: 'string' } diff --git a/api/src/paths/project/{projectId}/survey/index.test.ts b/api/src/paths/project/{projectId}/survey/index.test.ts new file mode 100644 index 0000000000..e85216e530 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/index.test.ts @@ -0,0 +1,106 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getSurveys } from '.'; +import * as db from '../../../../database/db'; +import { HTTPError } from '../../../../errors/http-error'; +import { PublishStatus } from '../../../../repositories/history-publish-repository'; +import { SurveyService } from '../../../../services/survey-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../__mocks__/db'; + +chai.use(sinonChai); + +describe('survey list', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should catch and re-throw an error if fetching surveys throws an error', async () => { + const dbConnectionObj = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const expectedError = new Error('an error'); + sinon.stub(SurveyService.prototype, 'getSurveysBasicFieldsByProjectId').rejects(expectedError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq['keycloak_token'] = {}; + mockReq.params = { + projectId: '1' + }; + + try { + const result = getSurveys(); + + await result(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect((actualError as HTTPError).message).to.equal(expectedError.message); + } + }); + + it('should return an array of surveys', async () => { + const dbConnectionObj = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const surveyId1 = 1; + const mockSurveyResponse1 = { + survey_id: surveyId1, + name: 'Survey 1', + start_date: '2023-01-01', + end_date: null, + focal_species: [1], + focal_species_names: ['Species 1'] + }; + + const surveyId2 = 2; + const mockSurveyResponse2 = { + survey_id: surveyId2, + name: 'Survey 2', + start_date: '2023-04-04', + end_date: '2024-05-05', + focal_species: [1, 2], + focal_species_names: ['Species 1', 'Species 2'] + }; + + const getSurveysBasicFieldsByProjectIdStub = sinon + .stub(SurveyService.prototype, 'getSurveysBasicFieldsByProjectId') + .resolves([mockSurveyResponse1, mockSurveyResponse2]); + + const surveyPublishStatusStub = sinon + .stub(SurveyService.prototype, 'surveyPublishStatus') + .onFirstCall() + .resolves(PublishStatus.SUBMITTED) + .onSecondCall() + .resolves(PublishStatus.UNSUBMITTED); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const projectId = 3; + mockReq['keycloak_token'] = {}; + mockReq.params = { + projectId: String(projectId) + }; + + const expectedResponse = [ + { + surveyData: mockSurveyResponse1, + surveySupplementaryData: { publishStatus: PublishStatus.SUBMITTED } + }, + { + surveyData: mockSurveyResponse2, + surveySupplementaryData: { publishStatus: PublishStatus.UNSUBMITTED } + } + ]; + + const result = getSurveys(); + + await result(mockReq, mockRes, mockNext); + + expect(getSurveysBasicFieldsByProjectIdStub).to.be.calledOnceWith(projectId); + expect(surveyPublishStatusStub).to.be.calledWith(surveyId1); + expect(surveyPublishStatusStub).to.be.calledWith(surveyId2); + expect(mockRes.jsonValue).to.eql(expectedResponse); + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/index.ts b/api/src/paths/project/{projectId}/survey/index.ts new file mode 100644 index 0000000000..9e22291932 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/index.ts @@ -0,0 +1,172 @@ +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 { SurveyService } from '../../../../services/survey-service'; +import { getLogger } from '../../../../utils/logger'; + +const defaultLog = getLogger('paths/project/{projectId}/survey/index'); + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [ + PROJECT_PERMISSION.COORDINATOR, + PROJECT_PERMISSION.COLLABORATOR, + PROJECT_PERMISSION.OBSERVER + ], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getSurveys() +]; + +GET.apiDoc = { + description: 'Fetches a subset of survey fields for all surveys under a project.', + tags: ['survey'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + } + ], + responses: { + 200: { + description: 'Survey list response object.', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + required: ['surveyData', 'surveySupplementaryData'], + properties: { + surveyData: { + type: 'object', + required: ['survey_id', 'name', 'start_date', 'end_date', 'focal_species', 'focal_species_names'], + properties: { + survey_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string', + maxLength: 300 + }, + start_date: { + type: 'string', + format: 'date', + description: 'ISO 8601 date string' + }, + end_date: { + type: 'string', + format: 'date', + description: 'ISO 8601 date string', + nullable: true + }, + focal_species: { + type: 'array', + items: { + type: 'integer' + } + }, + focal_species_names: { + type: 'array', + items: { + type: 'string' + } + } + } + }, + surveySupplementaryData: { + type: 'object', + required: ['publishStatus'], + properties: { + publishStatus: { + type: 'string', + enum: ['NO_DATA', 'UNSUBMITTED', 'SUBMITTED'] + } + } + } + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Get a subset of survey fields for all surveys under a project. + * + * @returns {RequestHandler} + */ +export function getSurveys(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + const surveyService = new SurveyService(connection); + + const surveys = await surveyService.getSurveysBasicFieldsByProjectId(Number(req.params.projectId)); + + const response = await Promise.all( + surveys.map(async (survey) => { + const surveyPublishStatus = await surveyService.surveyPublishStatus(survey.survey_id); + + return { + surveyData: survey, + surveySupplementaryData: { publishStatus: surveyPublishStatus } + }; + }) + ); + + await connection.commit(); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'getSurveyList', message: 'error', error }); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/list.test.ts b/api/src/paths/project/{projectId}/survey/list.test.ts deleted file mode 100644 index dd3555bb89..0000000000 --- a/api/src/paths/project/{projectId}/survey/list.test.ts +++ /dev/null @@ -1,116 +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 { SurveyObject } from '../../../../models/survey-view'; -import { PublishStatus } from '../../../../repositories/history-publish-repository'; -import { SurveyService } from '../../../../services/survey-service'; -import { getMockDBConnection } from '../../../../__mocks__/db'; -import * as surveys from './list'; - -chai.use(sinonChai); - -describe('survey list', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should throw a 400 error when projectId is missing in Path', async () => { - try { - const sampleReq = { - keycloak_token: {}, - body: {}, - params: { - projectId: null - } - } as any; - - const result = surveys.getSurveyList(); - - await result(sampleReq, (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('Missing required path param `projectId`'); - } - }); - - it('should throw an error when a failure occurs', async () => { - const dbConnectionObj = getMockDBConnection(); - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - - const expectedError = new Error('cannot process request'); - sinon.stub(SurveyService.prototype, 'getSurveyIdsByProjectId').rejects(expectedError); - - const sampleReq = { - keycloak_token: {}, - body: {}, - params: { - projectId: 1 - } - } as any; - - try { - const result = surveys.getSurveyList(); - - await result(sampleReq, (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 Id', async () => { - const dbConnectionObj = getMockDBConnection(); - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - - const getSurveyIdsByProjectIdStub = sinon - .stub(SurveyService.prototype, 'getSurveyIdsByProjectId') - .resolves([{ id: 1 }]); - - const getSurveysByIdsStub = sinon - .stub(SurveyService.prototype, 'getSurveyById') - .resolves(({ survey_details: { id: 1 } } as unknown) as SurveyObject); - - const getSurveysPublishStub = sinon - .stub(SurveyService.prototype, 'surveyPublishStatus') - .resolves(PublishStatus.SUBMITTED); - - const sampleReq = { - keycloak_token: {}, - body: {}, - params: { - projectId: 1 - } - } as any; - - const expectedResponse = [ - { - surveyData: { survey_details: { id: 1 } }, - surveySupplementaryData: { publishStatus: 'SUBMITTED' } - } - ]; - - let actualResult: any = null; - const sampleRes = { - status: () => { - return { - json: (response: any) => { - actualResult = response; - } - }; - } - }; - - const result = surveys.getSurveyList(); - - await result(sampleReq, (sampleRes as unknown) as any, (null as unknown) as any); - - expect(actualResult).to.eql(expectedResponse); - expect(getSurveyIdsByProjectIdStub).to.be.calledOnce; - expect(getSurveysByIdsStub).to.be.calledOnce; - expect(getSurveysPublishStub).to.be.calledOnce; - }); -}); diff --git a/api/src/paths/project/{projectId}/survey/list.ts b/api/src/paths/project/{projectId}/survey/list.ts deleted file mode 100644 index c585548b4a..0000000000 --- a/api/src/paths/project/{projectId}/survey/list.ts +++ /dev/null @@ -1,370 +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 { HTTP400 } from '../../../../errors/http-error'; -import { GeoJSONFeature } from '../../../../openapi/schemas/geoJson'; -import { authorizeRequestHandler } from '../../../../request-handlers/security/authorization'; -import { SurveyService } from '../../../../services/survey-service'; -import { getLogger } from '../../../../utils/logger'; - -const defaultLog = getLogger('paths/project/{projectId}/survey/list'); - -export const GET: Operation = [ - authorizeRequestHandler((req) => { - return { - or: [ - { - validProjectPermissions: [ - PROJECT_PERMISSION.COORDINATOR, - PROJECT_PERMISSION.COLLABORATOR, - PROJECT_PERMISSION.OBSERVER - ], - projectId: Number(req.params.projectId), - discriminator: 'ProjectPermission' - }, - { - validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], - discriminator: 'SystemRole' - } - ] - }; - }), - getSurveyList() -]; - -GET.apiDoc = { - description: 'Get all Surveys.', - tags: ['survey'], - security: [ - { - Bearer: [] - } - ], - parameters: [ - { - in: 'path', - name: 'projectId', - schema: { - type: 'number' - }, - required: true - } - ], - responses: { - 200: { - description: 'Survey list response object.', - content: { - 'application/json': { - schema: { - type: 'array', - items: { - title: 'Survey get response object, for view purposes', - type: 'object', - required: ['surveyData', 'surveySupplementaryData'], - properties: { - surveyData: { - type: 'object', - required: [ - 'survey_details', - 'species', - 'permit', - 'proprietor', - 'purpose_and_methodology', - 'locations' - ], - properties: { - survey_details: { - description: 'Survey Details', - type: 'object', - required: ['survey_name', 'survey_types', 'revision_count'], - properties: { - survey_name: { - type: 'string' - }, - start_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the funding end_date', - nullable: true - }, - end_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - nullable: true, - description: 'ISO 8601 date string for the funding end_date' - }, - survey_types: { - type: 'array', - items: { - type: 'integer', - minimum: 1 - } - }, - revision_count: { - type: 'number' - } - } - }, - species: { - description: 'Survey Species', - type: 'object', - required: [ - 'focal_species', - 'focal_species_names', - 'ancillary_species', - 'ancillary_species_names' - ], - properties: { - ancillary_species: { - nullable: true, - type: 'array', - items: { - type: 'number' - } - }, - ancillary_species_names: { - nullable: true, - type: 'array', - items: { - type: 'string' - } - }, - focal_species: { - type: 'array', - items: { - type: 'number' - } - }, - focal_species_names: { - type: 'array', - items: { - type: 'string' - } - } - } - }, - permit: { - description: 'Survey Permit', - type: 'object', - properties: { - permits: { - type: 'array', - items: { - type: 'object', - required: ['permit_id', 'permit_number', 'permit_type'], - properties: { - permit_id: { - type: 'number', - minimum: 1 - }, - permit_number: { - type: 'string' - }, - permit_type: { - type: 'string' - } - } - } - } - } - }, - purpose_and_methodology: { - description: 'Survey Details', - type: 'object', - required: [ - 'field_method_id', - 'additional_details', - 'intended_outcome_id', - 'ecological_season_id', - 'vantage_code_ids', - 'revision_count' - ], - properties: { - field_method_id: { - type: 'number' - }, - additional_details: { - type: 'string', - nullable: true - }, - intended_outcome_id: { - type: 'number', - nullable: true - }, - ecological_season_id: { - type: 'number', - nullable: true - }, - vantage_code_ids: { - type: 'array', - items: { - type: 'number' - } - } - } - }, - proprietor: { - description: 'Survey Proprietor Details', - type: 'object', - nullable: true, - required: [ - 'category_rationale', - 'disa_required', - 'first_nations_id', - 'first_nations_name', - 'proprietor_name', - 'proprietor_type_id', - 'proprietor_type_name' - ], - properties: { - category_rationale: { - type: 'string' - }, - disa_required: { - type: 'boolean' - }, - first_nations_id: { - type: 'number', - nullable: true - }, - first_nations_name: { - type: 'string', - nullable: true - }, - proprietor_name: { - type: 'string' - }, - proprietor_type_id: { - type: 'number' - }, - proprietor_type_name: { - type: 'string' - } - } - }, - locations: { - description: 'Survey location data', - type: 'array', - items: { - type: 'object', - required: [ - 'survey_location_id', - 'name', - 'description', - 'geometry', - 'geography', - 'geojson', - 'revision_count' - ], - properties: { - survey_location_id: { - type: 'integer', - minimum: 1 - }, - name: { - type: 'string', - maxLength: 100 - }, - description: { - type: 'string', - maxLength: 250 - }, - geometry: { - type: 'string', - nullable: true - }, - geography: { - type: 'string' - }, - geojson: { - type: 'array', - items: { - ...(GeoJSONFeature as object) - } - }, - revision_count: { - type: 'integer', - minimum: 0 - } - } - } - } - } - }, - surveySupplementaryData: { - type: 'object', - required: ['publishStatus'], - properties: { - publishStatus: { - type: 'string', - enum: ['NO_DATA', 'UNSUBMITTED', 'SUBMITTED'] - } - } - } - } - } - } - } - } - }, - 400: { - $ref: '#/components/responses/400' - }, - 401: { - $ref: '#/components/responses/401' - }, - 403: { - $ref: '#/components/responses/401' - }, - 500: { - $ref: '#/components/responses/500' - }, - default: { - $ref: '#/components/responses/default' - } - } -}; - -/** - * Get all surveys. - * - * @returns {RequestHandler} - */ -export function getSurveyList(): RequestHandler { - return async (req, res) => { - const connection = getDBConnection(req['keycloak_token']); - if (!req.params.projectId) { - throw new HTTP400('Missing required path param `projectId`'); - } - - try { - await connection.open(); - - const surveyService = new SurveyService(connection); - - const surveyIdsResponse = await surveyService.getSurveyIdsByProjectId(Number(req.params.projectId)); - - const surveyIds = surveyIdsResponse.map((item: { id: any }) => item.id); - - const surveys = await Promise.all( - surveyIds.map(async (surveyId) => { - const survey = await surveyService.getSurveyById(surveyId); - const surveyPublishStatus = await surveyService.surveyPublishStatus(surveyId); - - return { - surveyData: survey, - surveySupplementaryData: { publishStatus: surveyPublishStatus } - }; - }) - ); - - await connection.commit(); - - return res.status(200).json(surveys); - } catch (error) { - defaultLog.error({ label: 'getSurveyList', message: 'error', error }); - throw error; - } finally { - connection.release(); - } - }; -} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/delete.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/delete.ts index 393b117676..ea625b0462 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/delete.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/delete.ts @@ -5,6 +5,7 @@ import { getDBConnection } from '../../../../../../../database/db'; import { SystemUser } from '../../../../../../../repositories/user-repository'; import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; import { AttachmentService } from '../../../../../../../services/attachment-service'; +import { UserService } from '../../../../../../../services/user-service'; import { getLogger } from '../../../../../../../utils/logger'; import { attachmentApiDocObject } from '../../../../../../../utils/shared-api-docs'; @@ -110,15 +111,12 @@ export function deleteAttachment(): RequestHandler { const attachmentService = new AttachmentService(connection); const systemUserObject: SystemUser = req['system_user']; - const isAdmin = - systemUserObject.role_names.includes(SYSTEM_ROLE.SYSTEM_ADMIN) || - systemUserObject.role_names.includes(SYSTEM_ROLE.DATA_ADMINISTRATOR); await attachmentService.handleDeleteSurveyAttachment( Number(req.params.surveyId), Number(req.params.attachmentId), req.body.attachmentType, - isAdmin + UserService.isAdmin(systemUserObject) ); await connection.commit(); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/get.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/get.test.ts similarity index 99% rename from api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/get.test.ts rename to api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/get.test.ts index be326f100d..202c81908b 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/get.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/get.test.ts @@ -8,16 +8,16 @@ import { MESSAGE_CLASS_NAME, SUBMISSION_MESSAGE_TYPE, SUBMISSION_STATUS_TYPE -} from '../../../../../../../constants/status'; -import * as db from '../../../../../../../database/db'; -import { OccurrenceSubmissionPublish } from '../../../../../../../repositories/history-publish-repository'; +} from '../../../../../../../../constants/status'; +import * as db from '../../../../../../../../database/db'; +import { OccurrenceSubmissionPublish } from '../../../../../../../../repositories/history-publish-repository'; import { IGetLatestSurveyOccurrenceSubmission, SurveyRepository -} from '../../../../../../../repositories/survey-repository'; -import { HistoryPublishService } from '../../../../../../../services/history-publish-service'; -import { SurveyService } from '../../../../../../../services/survey-service'; -import { getMockDBConnection } from '../../../../../../../__mocks__/db'; +} from '../../../../../../../../repositories/survey-repository'; +import { HistoryPublishService } from '../../../../../../../../services/history-publish-service'; +import { SurveyService } from '../../../../../../../../services/survey-service'; +import { getMockDBConnection } from '../../../../../../../../__mocks__/db'; import * as observationSubmission from './get'; chai.use(sinonChai); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/get.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/get.ts similarity index 95% rename from api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/get.ts rename to api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/get.ts index 163cb3044a..02d467da67 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/get.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/get.ts @@ -1,14 +1,14 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; -import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; -import { SUBMISSION_STATUS_TYPE } from '../../../../../../../constants/status'; -import { getDBConnection } from '../../../../../../../database/db'; -import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; -import { HistoryPublishService } from '../../../../../../../services/history-publish-service'; -import { IMessageTypeGroup, SurveyService } from '../../../../../../../services/survey-service'; -import { getLogger } from '../../../../../../../utils/logger'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../../constants/roles'; +import { SUBMISSION_STATUS_TYPE } from '../../../../../../../../constants/status'; +import { getDBConnection } from '../../../../../../../../database/db'; +import { authorizeRequestHandler } from '../../../../../../../../request-handlers/security/authorization'; +import { HistoryPublishService } from '../../../../../../../../services/history-publish-service'; +import { IMessageTypeGroup, SurveyService } from '../../../../../../../../services/survey-service'; +import { getLogger } from '../../../../../../../../utils/logger'; -const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation/submission/get'); +const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/dwca/observations/submission/get'); export const GET: Operation = [ authorizeRequestHandler((req) => { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/upload.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/upload.test.ts similarity index 96% rename from api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/upload.test.ts rename to api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/upload.test.ts index 3bb88c2db7..25061bfc31 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/upload.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/upload.test.ts @@ -2,11 +2,11 @@ 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 { SurveyService } from '../../../../../../../services/survey-service'; -import * as file_utils from '../../../../../../../utils/file-utils'; -import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../__mocks__/db'; +import * as db from '../../../../../../../../database/db'; +import { HTTPError } from '../../../../../../../../errors/http-error'; +import { SurveyService } from '../../../../../../../../services/survey-service'; +import * as file_utils from '../../../../../../../../utils/file-utils'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../../__mocks__/db'; import * as upload from './upload'; chai.use(sinonChai); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/upload.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/upload.ts similarity index 90% rename from api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/upload.ts rename to api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/upload.ts index 0c924350ce..2e37a3e294 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/upload.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/upload.ts @@ -1,14 +1,14 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; -import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; -import { getDBConnection, IDBConnection } from '../../../../../../../database/db'; -import { HTTP400 } from '../../../../../../../errors/http-error'; -import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; -import { SurveyService } from '../../../../../../../services/survey-service'; -import { generateS3FileKey, scanFileForVirus, uploadFileToS3 } from '../../../../../../../utils/file-utils'; -import { getLogger } from '../../../../../../../utils/logger'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../../constants/roles'; +import { getDBConnection, IDBConnection } from '../../../../../../../../database/db'; +import { HTTP400 } from '../../../../../../../../errors/http-error'; +import { authorizeRequestHandler } from '../../../../../../../../request-handlers/security/authorization'; +import { SurveyService } from '../../../../../../../../services/survey-service'; +import { generateS3FileKey, scanFileForVirus, uploadFileToS3 } from '../../../../../../../../utils/file-utils'; +import { getLogger } from '../../../../../../../../utils/logger'; -const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation/submission/upload'); +const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/dwca/observations/submission/upload'); export const POST: Operation = [ authorizeRequestHandler((req) => { @@ -109,7 +109,7 @@ export function uploadMedia(): RequestHandler { return async (req, res) => { const rawMediaArray: Express.Multer.File[] = req.files as Express.Multer.File[]; - if (!rawMediaArray || !rawMediaArray.length) { + if (!rawMediaArray?.length) { // no media objects included, skipping media upload step throw new HTTP400('Missing upload data'); } @@ -172,8 +172,8 @@ export function uploadMedia(): RequestHandler { const metadata = { filename: rawMediaFile.originalname, - username: (req['auth_payload'] && req['auth_payload'].preferred_username) || '', - email: (req['auth_payload'] && req['auth_payload'].email) || '' + username: req['auth_payload']?.preferred_username ?? '', + email: req['auth_payload']?.email ?? '' }; await uploadFileToS3(rawMediaFile, inputKey, metadata); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/delete.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/{submissionId}/delete.test.ts similarity index 87% rename from api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/delete.test.ts rename to api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/{submissionId}/delete.test.ts index d914bea946..9b77151f14 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/delete.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/{submissionId}/delete.test.ts @@ -2,9 +2,9 @@ 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 { OccurrenceService } from '../../../../../../../../services/occurrence-service'; -import { getMockDBConnection } from '../../../../../../../../__mocks__/db'; +import * as db from '../../../../../../../../../database/db'; +import { OccurrenceService } from '../../../../../../../../../services/occurrence-service'; +import { getMockDBConnection } from '../../../../../../../../../__mocks__/db'; import * as delete_submission from './delete'; chai.use(sinonChai); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/delete.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/{submissionId}/delete.ts similarity index 85% rename from api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/delete.ts rename to api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/{submissionId}/delete.ts index 6beefc8c3e..e43201abce 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/delete.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/{submissionId}/delete.ts @@ -1,12 +1,14 @@ 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 { OccurrenceService } from '../../../../../../../../services/occurrence-service'; -import { getLogger } from '../../../../../../../../utils/logger'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../../../database/db'; +import { authorizeRequestHandler } from '../../../../../../../../../request-handlers/security/authorization'; +import { OccurrenceService } from '../../../../../../../../../services/occurrence-service'; +import { getLogger } from '../../../../../../../../../utils/logger'; -const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/delete'); +const defaultLog = getLogger( + '/api/project/{projectId}/survey/{surveyId}/dwca/observations/submission/{submissionId}/delete' +); export const DELETE: Operation = [ authorizeRequestHandler((req) => { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/getSignedUrl.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/{submissionId}/getSignedUrl.test.ts similarity index 88% rename from api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/getSignedUrl.test.ts rename to api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/{submissionId}/getSignedUrl.test.ts index 183d8a5e76..7a650f550c 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/getSignedUrl.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/{submissionId}/getSignedUrl.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 * as db from '../../../../../../../../database/db'; -import { HTTPError } from '../../../../../../../../errors/http-error'; -import { IOccurrenceSubmission } from '../../../../../../../../repositories/occurrence-repository'; -import { OccurrenceService } from '../../../../../../../../services/occurrence-service'; -import * as file_utils from '../../../../../../../../utils/file-utils'; -import { getMockDBConnection } from '../../../../../../../../__mocks__/db'; +import * as db from '../../../../../../../../../database/db'; +import { HTTPError } from '../../../../../../../../../errors/http-error'; +import { IOccurrenceSubmission } from '../../../../../../../../../repositories/occurrence-repository'; +import { OccurrenceService } from '../../../../../../../../../services/occurrence-service'; +import * as file_utils from '../../../../../../../../../utils/file-utils'; +import { getMockDBConnection } from '../../../../../../../../../__mocks__/db'; import * as get_signed_url from './getSignedUrl'; chai.use(sinonChai); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/getSignedUrl.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/{submissionId}/getSignedUrl.ts similarity index 83% rename from api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/getSignedUrl.ts rename to api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/{submissionId}/getSignedUrl.ts index 53d66b1902..b7af8ad131 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/getSignedUrl.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/dwca/observations/submission/{submissionId}/getSignedUrl.ts @@ -1,16 +1,16 @@ 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 { authorizeRequestHandler } from '../../../../../../../../request-handlers/security/authorization'; -import { OccurrenceService } from '../../../../../../../../services/occurrence-service'; -import { getS3SignedURL } from '../../../../../../../../utils/file-utils'; -import { getLogger } from '../../../../../../../../utils/logger'; -import { attachmentApiDocObject } from '../../../../../../../../utils/shared-api-docs'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../../../database/db'; +import { HTTP400 } from '../../../../../../../../../errors/http-error'; +import { authorizeRequestHandler } from '../../../../../../../../../request-handlers/security/authorization'; +import { OccurrenceService } from '../../../../../../../../../services/occurrence-service'; +import { getS3SignedURL } from '../../../../../../../../../utils/file-utils'; +import { getLogger } from '../../../../../../../../../utils/logger'; +import { attachmentApiDocObject } from '../../../../../../../../../utils/shared-api-docs'; const defaultLog = getLogger( - '/api/project/{projectId}/survey/{surveyId}/observation/submission/{submissionId}/getSignedUrl' + '/api/project/{projectId}/survey/{surveyId}/dwca/observations/submission/{submissionId}/getSignedUrl' ); export const GET: Operation = [ diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/delete.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/delete.ts new file mode 100644 index 0000000000..8ddb8820d5 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/delete.ts @@ -0,0 +1,146 @@ +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 { getLogger } from '../../../../../../utils/logger'; +import { surveyObservationsSupplementaryData } from './index'; + +const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation/delete'); + +export const POST: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + deleteSurveyObservations() +]; + +POST.apiDoc = { + description: 'Delete survey observations.', + tags: ['observation'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + required: true + }, + { + in: 'path', + name: 'surveyId', + required: true + } + ], + requestBody: { + description: 'Survey observation record data', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + surveyObservationIds: { + type: 'array', + minItems: 1, + items: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'string' + } + ] + } + } + } + } + } + } + }, + responses: { + 200: { + description: 'Delete OK', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['supplementaryObservationData'], + properties: { + supplementaryObservationData: { ...surveyObservationsSupplementaryData } + } + } + } + } + }, + 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 all observations for a survey. + * + * @export + * @return {*} {RequestHandler} + */ +export function deleteSurveyObservations(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + + defaultLog.debug({ label: 'deleteSurveyObservations', surveyId }); + + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + const observationService = new ObservationService(connection); + + const deleteObservationIds = + req.body?.surveyObservationIds?.map((observationId: string | number) => Number(observationId)) ?? []; + + await observationService.deleteObservationsByIds(deleteObservationIds); + const supplementaryObservationData = await observationService.getSurveyObservationsSupplementaryData(surveyId); + + await connection.commit(); + + return res.status(200).json({ supplementaryObservationData }); + } catch (error) { + defaultLog.error({ label: 'deleteSurveyObservations', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts similarity index 94% rename from api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.test.ts rename to api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts index 24a4cad381..5770de3c8d 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts @@ -443,7 +443,7 @@ describe('insertUpdateSurveyObservations', () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); const insertUpdateSurveyObservationsStub = sinon - .stub(ObservationService.prototype, 'insertUpdateDeleteSurveyObservations') + .stub(ObservationService.prototype, 'insertUpdateSurveyObservations') .resolves(([{ survey_observation_id: 1 }, { survey_observation_id: 2 }] as unknown) as ObservationRecord[]); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -481,7 +481,7 @@ describe('insertUpdateSurveyObservations', () => { ] }; - const requestHandler = observationRecords.insertUpdateDeleteSurveyObservations(); + const requestHandler = observationRecords.insertUpdateSurveyObservations(); await requestHandler(mockReq, mockRes, mockNext); expect(insertUpdateSurveyObservationsStub).to.have.been.calledOnceWith(2, [ @@ -521,7 +521,7 @@ describe('insertUpdateSurveyObservations', () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - sinon.stub(ObservationService.prototype, 'insertUpdateDeleteSurveyObservations').rejects(new Error('a test error')); + sinon.stub(ObservationService.prototype, 'insertUpdateSurveyObservations').rejects(new Error('a test error')); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -545,7 +545,7 @@ describe('insertUpdateSurveyObservations', () => { }; try { - const requestHandler = observationRecords.insertUpdateDeleteSurveyObservations(); + const requestHandler = observationRecords.insertUpdateSurveyObservations(); await requestHandler(mockReq, mockRes, mockNext); expect.fail(); @@ -557,7 +557,7 @@ describe('insertUpdateSurveyObservations', () => { }); }); -describe('getSurveyObservations', () => { +describe('getSurveyObservationsWithSupplementaryData', () => { afterEach(() => { sinon.restore(); }); @@ -585,7 +585,8 @@ describe('getSurveyObservations', () => { observation_date: '1970-01-01', observation_time: '00:00:00' } - ] + ], + supplementaryObservationData: { observationCount: 1 } } }; @@ -614,7 +615,8 @@ describe('getSurveyObservations', () => { observation_date: '1970-01-01', observation_time: '00:00:00' } - ] + ], + supplementaryObservationData: { observationCount: 1 } } }; @@ -635,7 +637,8 @@ describe('getSurveyObservations', () => { describe('should succeed when', () => { it('returns an empty array', () => { const apiResponse = { - surveyObservations: [] + surveyObservations: [], + supplementaryObservationData: { observationCount: 0 } }; const response = responseValidator.validateResponse(200, apiResponse); @@ -660,7 +663,8 @@ describe('getSurveyObservations', () => { update_date: '1970-01-01', revision_count: 1 } - ] + ], + supplementaryObservationData: { observationCount: 1 } }; const response = responseValidator.validateResponse(200, apiResponse); @@ -687,7 +691,8 @@ describe('getSurveyObservations', () => { update_date: '1970-01-01', revision_count: 1 } - ] + ], + supplementaryObservationData: { observationCount: 1 } }; const response = responseValidator.validateResponse(200, apiResponse); @@ -715,7 +720,8 @@ describe('getSurveyObservations', () => { update_date: '1970-01-01', revision_count: 1 } - ] + ], + supplementaryObservationData: { observationCount: 1 } }; const response = responseValidator.validateResponse(200, apiResponse); @@ -743,7 +749,8 @@ describe('getSurveyObservations', () => { update_date: '1970-01-01', revision_count: 1 } - ] + ], + supplementaryObservationData: { observationCount: 1 } }; const response = responseValidator.validateResponse(200, apiResponse); @@ -771,7 +778,8 @@ describe('getSurveyObservations', () => { update_date: '1970-01-01', revision_count: 1 } - ] + ], + supplementaryObservationData: { observationCount: 1 } }; const response = responseValidator.validateResponse(200, apiResponse); @@ -799,7 +807,8 @@ describe('getSurveyObservations', () => { update_date: '1970-01-01', revision_count: 1 } - ] + ], + supplementaryObservationData: { observationCount: 1 } }; const response = responseValidator.validateResponse(200, apiResponse); @@ -819,8 +828,14 @@ describe('getSurveyObservations', () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); const getSurveyObservationsStub = sinon - .stub(ObservationService.prototype, 'getSurveyObservations') - .resolves(([{ survey_observation_id: 1 }, { survey_observation_id: 2 }] as unknown) as ObservationRecord[]); + .stub(ObservationService.prototype, 'getSurveyObservationsWithSupplementaryData') + .resolves({ + surveyObservations: ([ + { survey_observation_id: 1 }, + { survey_observation_id: 2 } + ] as unknown) as ObservationRecord[], + supplementaryObservationData: { observationCount: 2 } + }); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -835,7 +850,8 @@ describe('getSurveyObservations', () => { expect(getSurveyObservationsStub).to.have.been.calledOnceWith(2); expect(mockRes.statusValue).to.equal(200); expect(mockRes.jsonValue).to.eql({ - surveyObservations: [{ survey_observation_id: 1 }, { survey_observation_id: 2 }] + surveyObservations: [{ survey_observation_id: 1 }, { survey_observation_id: 2 }], + supplementaryObservationData: { observationCount: 2 } }); }); @@ -844,7 +860,9 @@ describe('getSurveyObservations', () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - sinon.stub(ObservationService.prototype, 'getSurveyObservations').rejects(new Error('a test error')); + sinon + .stub(ObservationService.prototype, 'getSurveyObservationsWithSupplementaryData') + .rejects(new Error('a test error')); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts similarity index 87% rename from api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts rename to api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts index 3a3dbd1683..530908ddc6 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observation/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts @@ -49,11 +49,21 @@ export const PUT: Operation = [ ] }; }), - insertUpdateDeleteSurveyObservations() + insertUpdateSurveyObservations() ]; -const surveyObservationsResponseSchema: SchemaObject = { - title: 'Survey get response object, for view purposes', +export const surveyObservationsSupplementaryData: SchemaObject = { + type: 'object', + required: ['observationCount'], + properties: { + observationCount: { + type: 'integer', + minimum: 0 + } + } +}; + +export const surveyObservationsResponseSchema: SchemaObject = { type: 'object', nullable: true, required: ['surveyObservations'], @@ -159,7 +169,15 @@ GET.apiDoc = { description: 'Survey Observations get response.', content: { 'application/json': { - schema: { ...surveyObservationsResponseSchema } + schema: { + ...surveyObservationsResponseSchema, + required: ['surveyObservations', 'supplementaryObservationData'], + properties: { + ...surveyObservationsResponseSchema.properties, + supplementaryObservationData: { ...surveyObservationsSupplementaryData } + }, + title: 'Survey get response object, for view purposes' + } } } }, @@ -209,7 +227,7 @@ PUT.apiDoc = { type: 'object', properties: { surveyObservations: { - description: 'Survey observation reords.', + description: 'Survey observation records.', type: 'array', items: { type: 'object', @@ -294,8 +312,8 @@ export function getSurveyObservations(): RequestHandler { const observationService = new ObservationService(connection); - const surveyObservations = await observationService.getSurveyObservations(surveyId); - return res.status(200).json({ surveyObservations }); + const observationData = await observationService.getSurveyObservationsWithSupplementaryData(surveyId); + return res.status(200).json(observationData); } catch (error) { defaultLog.error({ label: 'getSurveyObservations', message: 'error', error }); await connection.rollback(); @@ -309,16 +327,15 @@ export function getSurveyObservations(): RequestHandler { /** * Inserts new observation records. * Updates existing observation records. - * Deletes missing observation records. * * @export * @return {*} {RequestHandler} */ -export function insertUpdateDeleteSurveyObservations(): RequestHandler { +export function insertUpdateSurveyObservations(): RequestHandler { return async (req, res) => { const surveyId = Number(req.params.surveyId); - defaultLog.debug({ label: 'insertUpdateDeleteSurveyObservations', surveyId }); + defaultLog.debug({ label: 'insertUpdateSurveyObservations', surveyId }); const connection = getDBConnection(req['keycloak_token']); @@ -343,13 +360,13 @@ export function insertUpdateDeleteSurveyObservations(): RequestHandler { } as InsertObservation | UpdateObservation; }); - const surveyObservations = await observationService.insertUpdateDeleteSurveyObservations(surveyId, records); + const surveyObservations = await observationService.insertUpdateSurveyObservations(surveyId, records); await connection.commit(); return res.status(200).json({ surveyObservations }); } catch (error) { - defaultLog.error({ label: 'insertUpdateDeleteSurveyObservations', message: 'error', error }); + defaultLog.error({ label: 'insertUpdateSurveyObservations', message: 'error', error }); await connection.rollback(); throw error; } finally { 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 new file mode 100644 index 0000000000..ee32fe4d0e --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.test.ts @@ -0,0 +1,67 @@ +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 { ObservationService } from '../../../../../../services/observation-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; +import * as process from './process'; + +chai.use(sinonChai); + +describe('processFile', () => { + afterEach(() => { + sinon.restore(); + }); + + const dbConnectionObj = getMockDBConnection(); + + const mockReq = { + keycloak_token: {}, + params: { + projectId: 1, + surveyId: 2 + }, + body: {} + } as any; + + it('should throw an error if failure occurs', async () => { + sinon.stub(db, 'getDBConnection').returns({ + ...dbConnectionObj, + systemUserId: () => { + return 20; + } + }); + + const expectedError = new Error('Error'); + sinon.stub(ObservationService.prototype, 'processObservationCsvSubmission').rejects(expectedError); + + try { + const result = process.processFile(); + + 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 () => { + const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns({ + ...dbConnectionObj, + systemUserId: () => { + return 20; + } + }); + sinon.stub(ObservationService.prototype, 'processObservationCsvSubmission').resolves({} as any); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = process.processFile(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockRes.status).to.be.calledWith(200); + expect(mockRes.json).to.be.calledWith({ surveyObservations: {} }); + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.ts new file mode 100644 index 0000000000..a92a18df06 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.ts @@ -0,0 +1,120 @@ +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 { getLogger } from '../../../../../../utils/logger'; +import { surveyObservationsResponseSchema } from './index'; + +const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation/process'); + +export const POST: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.body.project_id), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + processFile() +]; + +POST.apiDoc = { + description: 'Processes and validates observation CSV submission', + tags: ['survey', 'observation', 'csv'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + required: true + }, + { + in: 'path', + name: 'surveyId', + required: true + } + ], + requestBody: { + description: 'Request body', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['observation_submission_id'], + properties: { + observation_submission_id: { + description: 'The ID of the submission to validate', + type: 'integer' + } + } + } + } + } + }, + responses: { + 200: { + description: 'Validation results of the observation submission', + content: { + 'application/json': { + schema: { + ...surveyObservationsResponseSchema + } + } + } + }, + 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 processFile(): RequestHandler { + return async (req, res) => { + const submissionId = req.body.observation_submission_id; + + const connection = getDBConnection(req['keycloak_token']); + try { + await connection.open(); + + const observationService = new ObservationService(connection); + + const response = await observationService.processObservationCsvSubmission(submissionId); + + res.status(200).json({ surveyObservations: response }); + + await connection.commit(); + } catch (error) { + defaultLog.error({ label: 'processFile', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/upload.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/upload.test.ts new file mode 100644 index 0000000000..edbca8cde4 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/upload.test.ts @@ -0,0 +1,154 @@ +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 { ObservationService } from '../../../../../../services/observation-service'; +import * as file_utils from '../../../../../../utils/file-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: {}, + params: { + projectId: 1, + surveyId: 2 + }, + files: [ + { + fieldname: 'media', + originalname: 'test.csv', + encoding: '7bit', + mimetype: 'text/plain', + size: 340 + } + ], + body: {} + } as any; + + it('should throw an error when files are missing', async () => { + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + try { + const result = upload.uploadMedia(); + + await result({ ...mockReq, files: [] }, (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('Missing upload data'); + } + }); + + it('should throw an error when file format incorrect', async () => { + sinon.stub(db, 'getDBConnection').returns({ + ...dbConnectionObj, + systemUserId: () => { + return 20; + } + }); + + try { + const result = upload.uploadMedia(); + + await result( + { ...mockReq, files: [{ originalname: 'file.txt' }] }, + (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('Invalid file type, expected a CSV file.'); + } + }); + + 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.uploadMedia(); + + 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 if failure occurs', async () => { + sinon.stub(db, 'getDBConnection').returns({ + ...dbConnectionObj, + systemUserId: () => { + return 20; + } + }); + + sinon.stub(file_utils, 'scanFileForVirus').resolves(true); + + const expectedError = new Error('cannot process request'); + sinon.stub(ObservationService.prototype, 'insertSurveyObservationSubmission').rejects(expectedError); + + try { + const result = upload.uploadMedia(); + + 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); + sinon.stub(file_utils, 'uploadFileToS3').resolves(); + + const expectedResponse = { submissionId: 1 }; + + let actualResult: any = null; + const sampleRes = { + status: () => { + return { + json: (response: any) => { + actualResult = response; + } + }; + } + }; + + const upsertSurveyAttachmentStub = sinon + .stub(ObservationService.prototype, 'insertSurveyObservationSubmission') + .resolves({ submission_id: 1, key: 'string' }); + + const result = upload.uploadMedia(); + + await result(mockReq, (sampleRes as unknown) as any, (null as unknown) as any); + expect(actualResult).to.eql(expectedResponse); + expect(upsertSurveyAttachmentStub).to.be.calledOnce; + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/upload.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/upload.ts new file mode 100644 index 0000000000..e238f3c640 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/upload.ts @@ -0,0 +1,170 @@ +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 { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; +import { ObservationService } from '../../../../../../services/observation-service'; +import { scanFileForVirus, uploadFileToS3 } from '../../../../../../utils/file-utils'; +import { getLogger } from '../../../../../../utils/logger'; + +const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation/upload'); + +export const POST: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + uploadMedia() +]; + +POST.apiDoc = { + description: 'Upload survey observation submission file.', + tags: ['observations'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + required: true + }, + { + in: 'path', + name: 'surveyId', + required: true + } + ], + requestBody: { + description: 'Survey observation submission file to upload', + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + media: { + description: 'A survey observation submission file.', + type: 'string', + format: 'binary' + } + } + } + } + } + }, + responses: { + 200: { + description: 'Upload OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + submissionId: { + type: 'number' + } + } + } + } + } + }, + 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' + } + } +}; + +/** + * Uploads a media file to S3 and inserts a matching record in the `survey_observation_submission` table. + * + * @return {*} {RequestHandler} + */ +export function uploadMedia(): RequestHandler { + return async (req, res) => { + const rawMediaArray: Express.Multer.File[] = req.files as Express.Multer.File[]; + + if (!rawMediaArray?.length) { + // no media objects included, skipping media upload step + throw new HTTP400('Missing upload data'); + } + + if (rawMediaArray.length !== 1) { + // no media objects included + throw new HTTP400('Too many files uploaded, expected 1'); + } + + const connection = getDBConnection(req['keycloak_token']); + + try { + const rawMediaFile = rawMediaArray[0]; + + if (!rawMediaFile?.originalname.endsWith('.csv')) { + throw new HTTP400('Invalid file type, expected a CSV file.'); + } + + await connection.open(); + + // Scan file for viruses using ClamAV + const virusScanResult = await scanFileForVirus(rawMediaFile); + + if (!virusScanResult) { + throw new HTTP400('Malicious content detected, upload cancelled'); + } + + // Insert a new record in the `survey_observation_submission` table + const observationService = new ObservationService(connection); + const { submission_id: submissionId, key } = await observationService.insertSurveyObservationSubmission( + rawMediaFile, + Number(req.params.projectId), + Number(req.params.surveyId) + ); + + // Upload file to S3 + const metadata = { + filename: rawMediaFile.originalname, + username: req['auth_payload']?.preferred_username ?? '', + email: req['auth_payload']?.email ?? '' + }; + + const result = await uploadFileToS3(rawMediaFile, key, metadata); + + defaultLog.debug({ label: 'uploadMedia', message: 'result', result }); + + await connection.commit(); + + return res.status(200).json({ submissionId }); + } 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}/sample-site/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts index d469a1e47b..434df86b98 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts @@ -233,14 +233,8 @@ POST.apiDoc = { 'application/json': { schema: { type: 'object', - required: ['name', 'description', 'methods', 'survey_sample_sites'], + required: ['methods', 'survey_sample_sites'], properties: { - name: { - type: 'string' - }, - description: { - type: 'string' - }, methods: { type: 'array', minItems: 1, @@ -266,6 +260,14 @@ POST.apiDoc = { }, end_date: { type: 'string' + }, + start_time: { + type: 'string', + nullable: true + }, + end_time: { + type: 'string', + nullable: true } } } @@ -277,7 +279,16 @@ POST.apiDoc = { type: 'array', minItems: 1, items: { - ...(GeoJSONFeature as object) + type: 'object', + properties: { + name: { + type: 'string' + }, + description: { + type: 'string' + }, + feature: { ...(GeoJSONFeature as object) } + } } } } diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts index 21a03ef2f2..52ad761c80 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts @@ -4,6 +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 { SampleLocationService } from '../../../../../../../services/sample-location-service'; import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../__mocks__/db'; import * as delete_survey_sample_site_record from './index'; @@ -198,6 +199,10 @@ describe('deleteSurveySampleSiteRecord', () => { it('should work', async () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + const getObservationsCountBySampleSiteIdStub = sinon + .stub(ObservationService.prototype, 'getObservationsCountBySampleSiteId') + .resolves({ observationCount: 0 }); + const deleteSampleLocationRecordStub = sinon .stub(SampleLocationService.prototype, 'deleteSampleLocationRecord') .resolves(); @@ -219,5 +224,6 @@ describe('deleteSurveySampleSiteRecord', () => { expect(mockRes.status).to.have.been.calledWith(204); expect(deleteSampleLocationRecordStub).to.have.been.calledOnce; + expect(getObservationsCountBySampleSiteIdStub).to.have.been.calledOnce; }); }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts index 35a5fd7431..2d1c321a1c 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts @@ -5,6 +5,7 @@ import { getDBConnection } from '../../../../../../../database/db'; import { HTTP400 } from '../../../../../../../errors/http-error'; import { GeoJSONFeature } from '../../../../../../../openapi/schemas/geoJson'; import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; +import { ObservationService } from '../../../../../../../services/observation-service'; import { SampleLocationService } from '../../../../../../../services/sample-location-service'; import { getLogger } from '../../../../../../../utils/logger'; @@ -112,6 +113,14 @@ PUT.apiDoc = { }, end_date: { type: 'string' + }, + start_time: { + type: 'string', + nullable: true + }, + end_time: { + type: 'string', + nullable: true } } } @@ -268,6 +277,7 @@ DELETE.apiDoc = { export function deleteSurveySampleSiteRecord(): RequestHandler { return async (req, res) => { + const surveyId = Number(req.params.surveyId); const surveySampleSiteId = Number(req.params.surveySampleSiteId); if (!surveySampleSiteId) { @@ -279,6 +289,14 @@ export function deleteSurveySampleSiteRecord(): RequestHandler { try { await connection.open(); + const observationService = new ObservationService(connection); + + if ( + (await observationService.getObservationsCountBySampleSiteId(surveyId, surveySampleSiteId)).observationCount > 0 + ) { + throw new HTTP400('Cannot delete a sample site that is associated with an observation'); + } + const sampleLocationService = new SampleLocationService(connection); await sampleLocationService.deleteSampleLocationRecord(surveySampleSiteId); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/telemetry/upload.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/telemetry/upload.ts new file mode 100644 index 0000000000..787532c510 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/telemetry/upload.ts @@ -0,0 +1,170 @@ +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 { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; +import { TelemetryService } from '../../../../../../services/telemetry-service'; +import { scanFileForVirus, uploadFileToS3 } from '../../../../../../utils/file-utils'; +import { getLogger } from '../../../../../../utils/logger'; + +const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/telemetry/upload'); + +export const POST: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + uploadMedia() +]; + +POST.apiDoc = { + description: 'Upload survey telemetry submission file.', + tags: ['telemetry'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + required: true + }, + { + in: 'path', + name: 'surveyId', + required: true + } + ], + requestBody: { + description: 'Survey telemetry submission file to upload', + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + media: { + description: 'A survey telemetry submission file.', + type: 'string', + format: 'binary' + } + } + } + } + } + }, + responses: { + 200: { + description: 'Upload OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + submissionId: { + type: 'number' + } + } + } + } + } + }, + 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' + } + } +}; + +/** + * Uploads a media file to S3 and inserts a matching record in the `survey_telemetry_submission` table. + * + * @return {*} {RequestHandler} + */ +export function uploadMedia(): RequestHandler { + return async (req, res) => { + const rawMediaArray: Express.Multer.File[] = req.files as Express.Multer.File[]; + + if (!rawMediaArray?.length) { + // no media objects included, skipping media upload step + throw new HTTP400('Missing upload data'); + } + + if (rawMediaArray.length !== 1) { + // no media objects included + throw new HTTP400('Too many files uploaded, expected 1'); + } + + const connection = getDBConnection(req['keycloak_token']); + + try { + const rawMediaFile = rawMediaArray[0]; + + if (!rawMediaFile?.originalname.endsWith('.csv')) { + throw new HTTP400('Invalid file type, expected a CSV file.'); + } + + await connection.open(); + + // Scan file for viruses using ClamAV + const virusScanResult = await scanFileForVirus(rawMediaFile); + + if (!virusScanResult) { + throw new HTTP400('Malicious content detected, upload cancelled'); + } + + // Insert a new record in the `survey_telemetry_submission` table + const service = new TelemetryService(connection); + const { submission_id: submissionId, key } = await service.insertSurveyTelemetrySubmission( + rawMediaFile, + Number(req.params.projectId), + Number(req.params.surveyId) + ); + + // Upload file to S3 + const metadata = { + filename: rawMediaFile.originalname, + username: req['auth_payload']?.preferred_username ?? '', + email: req['auth_payload']?.email ?? '' + }; + + const result = await uploadFileToS3(rawMediaFile, key, metadata); + + defaultLog.debug({ label: 'uploadMedia', message: 'result', result }); + + await connection.commit(); + + return res.status(200).json({ submissionId }); + } 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}/update.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts index 2cb9c2648f..59ba306c19 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts @@ -210,33 +210,24 @@ PUT.apiDoc = { }, purpose_and_methodology: { type: 'object', - required: [ - 'intended_outcome_id', - 'additional_details', - 'field_method_id', - 'vantage_code_ids', - 'ecological_season_id', - 'revision_count' - ], + required: ['intended_outcome_ids', 'additional_details', 'vantage_code_ids', 'revision_count'], properties: { - intended_outcome_id: { - type: 'number' + intended_outcome_ids: { + type: 'array', + minItems: 1, + items: { + type: 'integer' + } }, additional_details: { type: 'string' }, - field_method_id: { - type: 'number' - }, vantage_code_ids: { type: 'array', items: { type: 'number' } }, - ecological_season_id: { - type: 'number' - }, revision_count: { type: 'number' } @@ -245,6 +236,7 @@ PUT.apiDoc = { locations: { description: 'Survey location data', type: 'array', + minItems: 1, items: { type: 'object', required: ['name', 'description', 'geojson'], @@ -280,6 +272,7 @@ PUT.apiDoc = { properties: { strategies: { type: 'array', + minItems: 1, items: { type: 'string' } diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.ts index 18ca7576ba..2ea2762e13 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.ts @@ -209,28 +209,17 @@ GET.apiDoc = { purpose_and_methodology: { description: 'Survey Details', type: 'object', - required: [ - 'field_method_id', - 'additional_details', - 'intended_outcome_id', - 'ecological_season_id', - 'vantage_code_ids', - 'revision_count' - ], + required: ['additional_details', 'intended_outcome_ids', 'vantage_code_ids', 'revision_count'], properties: { - field_method_id: { - type: 'number' - }, additional_details: { type: 'string', nullable: true }, - intended_outcome_id: { - type: 'number', - nullable: true - }, - ecological_season_id: { - type: 'number', + intended_outcome_ids: { + type: 'array', + items: { + type: 'number' + }, nullable: true }, vantage_code_ids: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/view.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/view.test.ts index 2bb8d6ec27..7220bdc93a 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/view.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/view.test.ts @@ -39,10 +39,8 @@ describe('survey/{surveyId}/view', () => { }, funding_sources: [], purpose_and_methodology: { - field_method_id: 1, additional_details: 'details', - intended_outcome_id: 8, - ecological_season_id: 1, + intended_outcome_ids: [8], vantage_code_ids: [1, 2], surveyed_all_areas: 'true', revision_count: 0 @@ -118,10 +116,8 @@ describe('survey/{surveyId}/view', () => { }, funding_sources: [], purpose_and_methodology: { - field_method_id: 1, additional_details: null, - intended_outcome_id: null, - ecological_season_id: null, + intended_outcome_ids: [], vantage_code_ids: [], surveyed_all_areas: 'false', revision_count: 0 diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts index 6b3d466ddf..5de7b548ab 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts @@ -230,29 +230,17 @@ GET.apiDoc = { purpose_and_methodology: { description: 'Survey Details', type: 'object', - required: [ - 'field_method_id', - 'additional_details', - 'intended_outcome_id', - 'ecological_season_id', - 'vantage_code_ids', - 'revision_count' - ], + required: ['additional_details', 'intended_outcome_ids', 'vantage_code_ids', 'revision_count'], properties: { - field_method_id: { - type: 'number' - }, additional_details: { type: 'string', nullable: true }, - intended_outcome_id: { - type: 'number', - nullable: true - }, - ecological_season_id: { - type: 'number', - nullable: true + intended_outcome_ids: { + type: 'array', + items: { + type: 'number' + } }, vantage_code_ids: { type: 'array', diff --git a/api/src/paths/project/{projectId}/update.ts b/api/src/paths/project/{projectId}/update.ts index 32b21cef25..9a2cb22cab 100644 --- a/api/src/paths/project/{projectId}/update.ts +++ b/api/src/paths/project/{projectId}/update.ts @@ -1,11 +1,9 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; -import { Feature } from 'geojson'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../constants/roles'; import { getDBConnection } from '../../../database/db'; import { HTTP400 } from '../../../errors/http-error'; import { PostParticipantData } from '../../../models/project-create'; -import { GeoJSONFeature } from '../../../openapi/schemas/geoJson'; import { projectUpdatePutRequestObject } from '../../../openapi/schemas/project'; import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; import { ProjectService } from '../../../services/project-service'; @@ -35,7 +33,6 @@ export const GET: Operation = [ export enum GET_ENTITIES { project = 'project', objectives = 'objectives', - location = 'location', iucn = 'iucn', participants = 'participants' } @@ -122,23 +119,6 @@ GET.apiDoc = { } } }, - location: { - description: 'The project location object', - type: 'object', - required: ['location_description', 'geometry'], - nullable: true, - properties: { - location_description: { - type: 'string' - }, - geometry: { - type: 'array', - items: { - ...(GeoJSONFeature as object) - } - } - } - }, iucn: { description: 'The International Union for Conservation of Nature number', type: 'object', @@ -360,7 +340,6 @@ PUT.apiDoc = { export interface IUpdateProject { project: any | null; objectives: any | null; - location: { geometry: Feature[]; location_description: string } | null; iucn: any | null; participants: PostParticipantData[] | null; } diff --git a/api/src/paths/project/{projectId}/view.ts b/api/src/paths/project/{projectId}/view.ts index aab169d805..37b7fe1a3f 100644 --- a/api/src/paths/project/{projectId}/view.ts +++ b/api/src/paths/project/{projectId}/view.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 { GeoJSONFeature } from '../../../openapi/schemas/geoJson'; import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; import { ProjectService } from '../../../services/project-service'; import { getLogger } from '../../../utils/logger'; @@ -63,7 +62,7 @@ GET.apiDoc = { properties: { projectData: { type: 'object', - required: ['project', 'participants', 'objectives', 'location', 'iucn'], + required: ['project', 'participants', 'objectives', 'iucn'], properties: { project: { description: 'Basic project metadata', @@ -155,22 +154,6 @@ GET.apiDoc = { } } }, - location: { - description: 'The project location object', - type: 'object', - required: ['location_description', 'geometry'], - properties: { - location_description: { - type: 'string' - }, - geometry: { - type: 'array', - items: { - ...(GeoJSONFeature as object) - } - } - } - }, iucn: { description: 'The International Union for Conservation of Nature number', type: 'object', diff --git a/api/src/paths/publish/survey.test.ts b/api/src/paths/publish/survey.test.ts index 3607a8849e..f04c2e168a 100644 --- a/api/src/paths/publish/survey.test.ts +++ b/api/src/paths/publish/survey.test.ts @@ -30,18 +30,14 @@ describe('survey', () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - sinon.stub(PlatformService.prototype, 'submitSurveyDataToBioHub').resolves({ uuid: 'test-uuid' }); + sinon.stub(PlatformService.prototype, 'submitSurveyToBioHub').resolves({ submission_id: 1 }); const sampleReq = { keycloak_token: {}, body: { - projectId: 1, surveyId: 1, data: { - observations: [], - summary: [], - reports: [], - attachments: [] + additionalInformation: 'test' } }, params: {} @@ -64,7 +60,7 @@ describe('survey', () => { await requestHandler(sampleReq, (sampleRes as unknown) as any, mockNext); - expect(actualResult).to.eql({ uuid: 'test-uuid' }); + expect(actualResult).to.eql({ submission_id: 1 }); }); it('catches error, calls rollback, and re-throws error', async () => { @@ -72,7 +68,7 @@ describe('survey', () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - sinon.stub(PlatformService.prototype, 'submitSurveyDataToBioHub').rejects(new Error('a test error')); + sinon.stub(PlatformService.prototype, 'submitSurveyToBioHub').rejects(new Error('a test error')); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); diff --git a/api/src/paths/publish/survey.ts b/api/src/paths/publish/survey.ts index c0f79ef82e..6e5903b7aa 100644 --- a/api/src/paths/publish/survey.ts +++ b/api/src/paths/publish/survey.ts @@ -29,7 +29,7 @@ export const POST: Operation = [ POST.apiDoc = { description: 'Publish Survey data to Biohub.', - tags: ['survey', 'dwca', 'biohub'], + tags: ['survey', 'biohub'], security: [ { Bearer: [] @@ -41,46 +41,19 @@ POST.apiDoc = { 'application/json': { schema: { type: 'object', - required: ['projectId', 'surveyId', 'data'], + required: ['surveyId', 'data'], properties: { - projectId: { - type: 'number' - }, surveyId: { type: 'number' }, data: { description: 'All survey data to upload', type: 'object', - required: ['observations', 'summary', 'reports', 'attachments'], + required: ['additionalInformation'], properties: { - observations: { - type: 'array', - items: { - type: 'object', - properties: {} - } - }, - summary: { - type: 'array', - items: { - type: 'object', - properties: {} - } - }, - reports: { - type: 'array', - items: { - type: 'object', - properties: {} - } - }, - attachments: { - type: 'array', - items: { - type: 'object', - properties: {} - } + additionalInformation: { + type: 'string', + description: 'Additional information to include in the upload' } } } @@ -97,9 +70,8 @@ POST.apiDoc = { schema: { type: 'object', properties: { - uuid: { - type: 'string', - format: 'uuid' + submission_id: { + type: 'number' } } } @@ -139,7 +111,7 @@ export function publishSurvey(): RequestHandler { await connection.open(); const platformService = new PlatformService(connection); - const response = await platformService.submitSurveyDataToBioHub(surveyId, data); + const response = await platformService.submitSurveyToBioHub(surveyId, data); await connection.commit(); diff --git a/api/src/paths/search.test.ts b/api/src/paths/search.test.ts deleted file mode 100644 index c0972f6084..0000000000 --- a/api/src/paths/search.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import chai, { expect } from 'chai'; -import { describe } from 'mocha'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { SYSTEM_ROLE } from '../constants/roles'; -import * as db from '../database/db'; -import * as authorization from '../request-handlers/security/authorization'; -import { getMockDBConnection, getRequestHandlerMocks } from '../__mocks__/db'; -import * as search from './search'; - -chai.use(sinonChai); - -describe('search', () => { - describe('getSearchResults', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should return null when no response returned from getSpatialSearchResultsSQL', async () => { - const dbConnectionObj = getMockDBConnection(); - - const mockQuery = sinon.stub(); - - mockQuery.resolves({ rows: [], rowCount: 0 }); - - sinon.stub(db, 'getDBConnection').returns({ - ...dbConnectionObj, - systemUserId: () => { - return 20; - }, - sql: mockQuery - }); - sinon.stub(authorization, 'userHasValidRole').returns(true); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq['keycloak_token'] = {}; - mockReq['system_user'] = { - role_names: [SYSTEM_ROLE.SYSTEM_ADMIN] - }; - - const result = search.getSearchResults(); - - await result(mockReq, mockRes, mockNext); - - expect(mockRes.jsonValue).to.eql([]); - }); - - it('should return rows on success when result is empty', async () => { - const dbConnectionObj = getMockDBConnection(); - - const mockQuery = sinon.stub(); - - mockQuery.resolves({ rows: [], rowCount: 0 }); - - sinon.stub(db, 'getDBConnection').returns({ - ...dbConnectionObj, - systemUserId: () => { - return 20; - }, - sql: mockQuery - }); - sinon.stub(authorization, 'userHasValidRole').returns(false); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq['keycloak_token'] = {}; - mockReq['system_user'] = { - role_names: [SYSTEM_ROLE.SYSTEM_ADMIN] - }; - - const result = search.getSearchResults(); - - await result(mockReq, mockRes, mockNext); - - expect(mockRes.jsonValue).to.eql([]); - }); - - it('should return rows on success', async () => { - const dbConnectionObj = getMockDBConnection(); - - const searchList = [ - { - id: 1, - name: 'name', - geometry: '{"type":"Point","coordinates":[50.7,60.9]}' - } - ]; - - const mockQuery = sinon.stub(); - - mockQuery.resolves({ rows: searchList }); - - sinon.stub(db, 'getDBConnection').returns({ - ...dbConnectionObj, - systemUserId: () => { - return 20; - }, - sql: mockQuery - }); - sinon.stub(authorization, 'userHasValidRole').returns(true); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq['keycloak_token'] = {}; - mockReq['system_user'] = { - role_names: [SYSTEM_ROLE.SYSTEM_ADMIN] - }; - - const result = search.getSearchResults(); - - await result(mockReq, mockRes, mockNext); - - expect(mockRes.jsonValue).to.eql([ - { - id: searchList[0].id, - name: searchList[0].name, - geometry: [ - { - type: 'Point', - coordinates: [50.7, 60.9] - } - ] - } - ]); - }); - }); -}); diff --git a/api/src/paths/search.ts b/api/src/paths/search.ts deleted file mode 100644 index 112c4a22b0..0000000000 --- a/api/src/paths/search.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { RequestHandler } from 'express'; -import { Operation } from 'express-openapi'; -import SQL, { SQLStatement } from 'sql-template-strings'; -import { SYSTEM_ROLE } from '../constants/roles'; -import { getDBConnection } from '../database/db'; -import { searchResponseObject } from '../openapi/schemas/search'; -import { authorizeRequestHandler, userHasValidRole } from '../request-handlers/security/authorization'; -import { getLogger } from '../utils/logger'; - -const defaultLog = getLogger('paths/search'); - -export const GET: Operation = [ - authorizeRequestHandler(() => { - return { - and: [ - { - discriminator: 'SystemUser' - } - ] - }; - }), - getSearchResults() -]; - -GET.apiDoc = { - description: 'Gets a list of project geometries for given systemUserId', - tags: ['projects'], - security: [ - { - Bearer: [] - } - ], - responses: { - 200: { - description: 'Spatial search response object.', - content: { - 'application/json': { - schema: { - type: 'array', - items: { - ...(searchResponseObject as object) - } - } - } - } - }, - 401: { - $ref: '#/components/responses/401' - }, - default: { - $ref: '#/components/responses/default' - } - } -}; - -/** - * Get search results by system user id (spatially based on boundary). - * - * @returns {RequestHandler} - */ -export function getSearchResults(): RequestHandler { - return async (req, res) => { - const connection = getDBConnection(req['keycloak_token']); - - try { - await connection.open(); - - const systemUserId = connection.systemUserId(); - const isUserAdmin = userHasValidRole( - [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], - req['system_user']['role_names'] - ); - - const getSpatialSearchResultsSQLStatement = getSpatialSearchResultsSQL(isUserAdmin, systemUserId); - - const response = await connection.sql(getSpatialSearchResultsSQLStatement); - - await connection.commit(); - - const result: any[] = _extractResults(response.rows); - - return res.status(200).json(result); - } catch (error) { - defaultLog.error({ label: 'getSearchResults', message: 'error', error }); - throw error; - } finally { - connection.release(); - } - }; -} - -/** - * SQL query to get project geometries - * - * @param {boolean} isUserAdmin - * @param {(number | null)} systemUserId - * @returns {SQLStatement} sql query object - */ -export function getSpatialSearchResultsSQL(isUserAdmin: boolean, systemUserId: number | null): SQLStatement { - const sqlStatement = SQL` - SELECT - p.project_id as id, - p.name, - public.ST_asGeoJSON(p.geography) as geometry - from - project as p - `; - - if (!isUserAdmin) { - sqlStatement.append(SQL`WHERE p.create_user = ${systemUserId};`); - } - - sqlStatement.append(SQL`;`); - - return sqlStatement; -} - -/** - * Extract an array of search result data from DB query. - * - * @export - * @param {any[]} rows DB query result rows - * @return {any[]} An array of search result data - */ -export function _extractResults(rows: any[]): any[] { - if (!rows || !rows.length) { - return []; - } - - const searchResults: any[] = []; - - rows.forEach((row) => { - const result: any = { - id: row.id, - name: row.name, - geometry: row.geometry && [JSON.parse(row.geometry)] - }; - - searchResults.push(result); - }); - - return searchResults; -} diff --git a/api/src/paths/taxonomy/species/list.ts b/api/src/paths/taxonomy/species/list.ts index 518c69dade..8ec7934b7f 100644 --- a/api/src/paths/taxonomy/species/list.ts +++ b/api/src/paths/taxonomy/species/list.ts @@ -78,6 +78,9 @@ export function getSpeciesFromIds(): RequestHandler { const taxonomyService = new TaxonomyService(); const response = await taxonomyService.getSpeciesFromIds(ids as string[]); + // Overwrite default cache-control header, allow caching up to 7 days + res.setHeader('Cache-Control', 'max-age=604800'); + res.status(200).json({ searchResponse: response }); } catch (error) { defaultLog.error({ label: 'getSearchResults', message: 'error', error }); diff --git a/api/src/paths/taxonomy/species/search.ts b/api/src/paths/taxonomy/species/search.ts index 67155601f0..97830535e4 100644 --- a/api/src/paths/taxonomy/species/search.ts +++ b/api/src/paths/taxonomy/species/search.ts @@ -72,10 +72,14 @@ export function searchSpecies(): RequestHandler { defaultLog.debug({ label: 'getSearchResults', message: 'request params', req_params: req.query.terms }); const term = String(req.query.terms) || ''; + try { const taxonomyService = new TaxonomyService(); const response = await taxonomyService.searchSpecies(term.toLowerCase()); + // Overwrite default cache-control header, allow caching up to 7 days + res.setHeader('Cache-Control', 'max-age=604800'); + res.status(200).json({ searchResponse: response }); } catch (error) { defaultLog.error({ label: 'getSearchResults', message: 'error', error }); diff --git a/api/src/paths/telemetry/deployments.test.ts b/api/src/paths/telemetry/deployments.test.ts new file mode 100644 index 0000000000..9766e989d9 --- /dev/null +++ b/api/src/paths/telemetry/deployments.test.ts @@ -0,0 +1,48 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { BctwService, IManualTelemetry } from '../../services/bctw-service'; +import { getRequestHandlerMocks } from '../../__mocks__/db'; +import { getAllTelemetryByDeploymentIds } from './deployments'; + +const mockTelemetry = ([ + { + telemetry_manual_id: 1 + }, + { + telemetry_manual_id: 2 + } +] as unknown[]) as IManualTelemetry[]; + +describe('getAllTelemetryByDeploymentIds', () => { + afterEach(() => { + sinon.restore(); + }); + it('should retrieve both manual and vendor telemetry', async () => { + const mockGetTelemetry = sinon + .stub(BctwService.prototype, 'getAllTelemetryByDeploymentIds') + .resolves(mockTelemetry); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getAllTelemetryByDeploymentIds(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql(mockTelemetry); + expect(mockRes.statusValue).to.equal(200); + expect(mockGetTelemetry).to.have.been.calledOnce; + }); + it('should catch error', async () => { + const mockError = new Error('test error'); + const mockGetTelemetry = sinon.stub(BctwService.prototype, 'getAllTelemetryByDeploymentIds').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getAllTelemetryByDeploymentIds(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + } catch (err) { + expect(err).to.equal(mockError); + expect(mockGetTelemetry).to.have.been.calledOnce; + } + }); +}); diff --git a/api/src/paths/telemetry/deployments.ts b/api/src/paths/telemetry/deployments.ts new file mode 100644 index 0000000000..0bb81d0b6f --- /dev/null +++ b/api/src/paths/telemetry/deployments.ts @@ -0,0 +1,103 @@ +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/manual'); + +export const POST: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + getAllTelemetryByDeploymentIds() +]; + +POST.apiDoc = { + description: 'Get list of manual and vendor telemetry by deployment ids', + tags: ['telemetry'], + security: [ + { + Bearer: [] + } + ], + responses: { + 200: { + description: 'Manual and Vendor telemetry response object', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + 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' } + } + } + } + } + } + }, + 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' + } + }, + + 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); + try { + const result = await bctwService.getAllTelemetryByDeploymentIds(req.body); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getAllTelemetryByDeploymentIds', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/telemetry/manual/delete.test.ts b/api/src/paths/telemetry/manual/delete.test.ts new file mode 100644 index 0000000000..cd2a0ba632 --- /dev/null +++ b/api/src/paths/telemetry/manual/delete.test.ts @@ -0,0 +1,46 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { BctwService, IManualTelemetry } from '../../../services/bctw-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; +import { deleteManualTelemetry } from './delete'; + +const mockTelemetry = ([ + { + telemetry_manual_id: 1 + }, + { + telemetry_manual_id: 2 + } +] as unknown[]) as IManualTelemetry[]; + +describe('deleteManualTelemetry', () => { + afterEach(() => { + sinon.restore(); + }); + it('should retrieve all manual telemetry', async () => { + const mockGetTelemetry = sinon.stub(BctwService.prototype, 'deleteManualTelemetry').resolves(mockTelemetry); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = deleteManualTelemetry(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql(mockTelemetry); + expect(mockRes.statusValue).to.equal(200); + expect(mockGetTelemetry).to.have.been.calledOnce; + }); + it('should catch error', async () => { + const mockError = new Error('test error'); + const mockGetTelemetry = sinon.stub(BctwService.prototype, 'deleteManualTelemetry').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = deleteManualTelemetry(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + } catch (err) { + expect(err).to.equal(mockError); + expect(mockGetTelemetry).to.have.been.calledOnce; + } + }); +}); diff --git a/api/src/paths/telemetry/manual/delete.ts b/api/src/paths/telemetry/manual/delete.ts new file mode 100644 index 0000000000..2fd6b68173 --- /dev/null +++ b/api/src/paths/telemetry/manual/delete.ts @@ -0,0 +1,66 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { manual_telemetry_responses } from '.'; +import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; +import { BctwService, IBctwUser } from '../../../services/bctw-service'; +import { getLogger } from '../../../utils/logger'; +const defaultLog = getLogger('paths/telemetry/manual/delete'); + +export const POST: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + deleteManualTelemetry() +]; + +POST.apiDoc = { + description: 'Delete manual telemetry records', + tags: ['telemetry'], + security: [ + { + Bearer: [] + } + ], + requestBody: { + description: 'Request body', + required: true, + content: { + 'application/json': { + schema: { + title: 'Manual Telemetry ids to delete', + type: 'array', + minItems: 1, + items: { + title: 'telemetry manual ids', + type: 'string', + format: 'uuid' + } + } + } + } + }, + responses: manual_telemetry_responses +}; + +export function deleteManualTelemetry(): RequestHandler { + return async (req, res) => { + const user: IBctwUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + const bctwService = new BctwService(user); + try { + const result = await bctwService.deleteManualTelemetry(req.body); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'deleteManualTelemetry', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/telemetry/manual/deployments.test.ts b/api/src/paths/telemetry/manual/deployments.test.ts new file mode 100644 index 0000000000..ecf325a43c --- /dev/null +++ b/api/src/paths/telemetry/manual/deployments.test.ts @@ -0,0 +1,48 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { BctwService, IManualTelemetry } from '../../../services/bctw-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; +import { getManualTelemetryByDeploymentIds } from './deployments'; + +const mockTelemetry = ([ + { + telemetry_manual_id: 1 + }, + { + telemetry_manual_id: 2 + } +] as unknown[]) as IManualTelemetry[]; + +describe('getManualTelemetryByDeploymentIds', () => { + afterEach(() => { + sinon.restore(); + }); + it('should retrieve all manual telemetry', async () => { + const mockGetTelemetry = sinon + .stub(BctwService.prototype, 'getManualTelemetryByDeploymentIds') + .resolves(mockTelemetry); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getManualTelemetryByDeploymentIds(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql(mockTelemetry); + expect(mockRes.statusValue).to.equal(200); + expect(mockGetTelemetry).to.have.been.calledOnce; + }); + it('should catch error', async () => { + const mockError = new Error('test error'); + const mockGetTelemetry = sinon.stub(BctwService.prototype, 'getManualTelemetryByDeploymentIds').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getManualTelemetryByDeploymentIds(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + } catch (err) { + expect(err).to.equal(mockError); + expect(mockGetTelemetry).to.have.been.calledOnce; + } + }); +}); diff --git a/api/src/paths/telemetry/manual/deployments.ts b/api/src/paths/telemetry/manual/deployments.ts new file mode 100644 index 0000000000..8b23fec263 --- /dev/null +++ b/api/src/paths/telemetry/manual/deployments.ts @@ -0,0 +1,64 @@ +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 { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('paths/telemetry/manual'); + +export const POST: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + getManualTelemetryByDeploymentIds() +]; + +POST.apiDoc = { + description: 'Get a list of manually created telemetry by deployment ids', + tags: ['telemetry'], + security: [ + { + Bearer: [] + } + ], + responses: manual_telemetry_responses, + requestBody: { + description: 'Request body', + required: true, + content: { + 'application/json': { + schema: { + title: 'Manual Telemetry deployment ids', + type: 'array', + minItems: 1, + items: { + title: 'Manual telemetry deployment ids', + type: 'string', + format: 'uuid' + } + } + } + } + } +}; + +export function getManualTelemetryByDeploymentIds(): RequestHandler { + return async (req, res) => { + const user = getBctwUser(req); + const bctwService = new BctwService(user); + try { + const result = await bctwService.getManualTelemetryByDeploymentIds(req.body); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getManualTelemetryByDeploymentIds', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/telemetry/manual/index.test.ts b/api/src/paths/telemetry/manual/index.test.ts new file mode 100644 index 0000000000..2d9774b5bc --- /dev/null +++ b/api/src/paths/telemetry/manual/index.test.ts @@ -0,0 +1,127 @@ +import Ajv from 'ajv'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { createManualTelemetry, GET, getManualTelemetry, PATCH, POST, updateManualTelemetry } from '.'; +import { BctwService, IManualTelemetry } from '../../../services/bctw-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; + +const mockTelemetry = ([ + { + telemetry_manual_id: 1 + }, + { + telemetry_manual_id: 2 + } +] as unknown[]) as IManualTelemetry[]; + +describe('manual telemetry endpoints', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('getManualTelemetry', () => { + describe('openapi schema', () => { + it('is valid openapi v3 schema', () => { + const ajv = new Ajv(); + expect(ajv.validateSchema((GET.apiDoc as unknown) as object)).to.be.true; + }); + }); + it('should retrieve all manual telemetry', async () => { + const mockGetTelemetry = sinon.stub(BctwService.prototype, 'getManualTelemetry').resolves(mockTelemetry); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getManualTelemetry(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql(mockTelemetry); + expect(mockRes.statusValue).to.equal(200); + expect(mockGetTelemetry).to.have.been.calledOnce; + }); + + it('should catch error', async () => { + const mockError = new Error('test error'); + const mockGetTelemetry = sinon.stub(BctwService.prototype, 'getManualTelemetry').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getManualTelemetry(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + } catch (err) { + expect(err).to.equal(mockError); + expect(mockGetTelemetry).to.have.been.calledOnce; + } + }); + }); + + describe('createManualTelemetry', () => { + describe('openapi schema', () => { + it('is valid openapi v3 schema', () => { + const ajv = new Ajv(); + expect(ajv.validateSchema((POST.apiDoc as unknown) as object)).to.be.true; + }); + }); + it('should bulk create manual telemetry', async () => { + const mockCreateTelemetry = sinon.stub(BctwService.prototype, 'createManualTelemetry').resolves(mockTelemetry); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = createManualTelemetry(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql(mockTelemetry); + expect(mockRes.statusValue).to.equal(201); + expect(mockCreateTelemetry).to.have.been.calledOnce; + }); + it('should catch error', async () => { + const mockError = new Error('test error'); + const mockGetTelemetry = sinon.stub(BctwService.prototype, 'createManualTelemetry').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = createManualTelemetry(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + } catch (err) { + expect(err).to.equal(mockError); + expect(mockGetTelemetry).to.have.been.calledOnce; + } + }); + }); + + describe('updateManualTelemetry', () => { + describe('openapi schema', () => { + it('is valid openapi v3 schema', () => { + const ajv = new Ajv(); + expect(ajv.validateSchema((PATCH.apiDoc as unknown) as object)).to.be.true; + }); + }); + it('should bulk update manual telemetry', async () => { + const mockCreateTelemetry = sinon.stub(BctwService.prototype, 'updateManualTelemetry').resolves(mockTelemetry); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = updateManualTelemetry(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql(mockTelemetry); + expect(mockRes.statusValue).to.equal(201); + expect(mockCreateTelemetry).to.have.been.calledOnce; + }); + it('should catch error', async () => { + const mockError = new Error('test error'); + const mockGetTelemetry = sinon.stub(BctwService.prototype, 'updateManualTelemetry').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = updateManualTelemetry(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + } catch (err) { + expect(err).to.equal(mockError); + expect(mockGetTelemetry).to.have.been.calledOnce; + } + }); + }); +}); diff --git a/api/src/paths/telemetry/manual/index.ts b/api/src/paths/telemetry/manual/index.ts new file mode 100644 index 0000000000..6cd181a383 --- /dev/null +++ b/api/src/paths/telemetry/manual/index.ts @@ -0,0 +1,230 @@ +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/manual'); + +export const manual_telemetry_responses = { + 200: { + description: 'Manual telemetry response object', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + telemetry_manual_id: { type: 'string' }, + deployment_id: { type: 'string' }, + latitude: { type: 'number' }, + longitude: { type: 'number' }, + acquisition_date: { 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 GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + getManualTelemetry() +]; + +GET.apiDoc = { + description: 'Get a list of manually created telemetry', + tags: ['telemetry'], + security: [ + { + Bearer: [] + } + ], + responses: manual_telemetry_responses +}; + +export function getManualTelemetry(): RequestHandler { + return async (req, res) => { + const user = getBctwUser(req); + const bctwService = new BctwService(user); + try { + const result = await bctwService.getManualTelemetry(); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getManualTelemetry', message: 'error', error }); + throw error; + } + }; +} + +export const POST: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + createManualTelemetry() +]; + +POST.apiDoc = { + description: 'Bulk create Manual Telemetry', + tags: ['telemetry'], + security: [ + { + Bearer: [] + } + ], + responses: manual_telemetry_responses, + requestBody: { + description: 'Request body', + required: true, + content: { + 'application/json': { + schema: { + title: 'Manual Telemetry create objects', + type: 'array', + minItems: 1, + items: { + title: 'manual telemetry records', + type: 'object', + required: ['deployment_id', 'latitude', 'longitude', 'acquisition_date'], + properties: { + deployment_id: { + type: 'string', + format: 'uuid' + }, + latitude: { + type: 'number' + }, + longitude: { + type: 'number' + }, + acquisition_date: { + type: 'string' + } + } + } + } + } + } + } +}; + +export function createManualTelemetry(): RequestHandler { + return async (req, res) => { + const user = getBctwUser(req); + const bctwService = new BctwService(user); + try { + const result = await bctwService.createManualTelemetry(req.body); + return res.status(201).json(result); + } catch (error) { + defaultLog.error({ label: 'createManualTelemetry', message: 'error', error }); + throw error; + } + }; +} + +export const PATCH: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + updateManualTelemetry() +]; + +PATCH.apiDoc = { + description: 'Bulk update Manual Telemetry', + tags: ['telemetry'], + security: [ + { + Bearer: [] + } + ], + responses: manual_telemetry_responses, + requestBody: { + description: 'Request body', + required: true, + content: { + 'application/json': { + schema: { + title: 'Manual Telemetry update objects', + type: 'array', + minItems: 1, + items: { + title: 'manual telemetry records', + type: 'object', + required: ['telemetry_manual_id'], + minProperties: 2, + properties: { + telemetry_manual_id: { + type: 'string', + format: 'uuid' + }, + deployment_id: { + type: 'string', + format: 'uuid' + }, + latitude: { + type: 'number' + }, + longitude: { + type: 'number' + }, + acquisition_date: { + type: 'string' + } + } + } + } + } + } + } +}; + +export function updateManualTelemetry(): RequestHandler { + return async (req, res) => { + const user = getBctwUser(req); + const bctwService = new BctwService(user); + try { + const result = await bctwService.updateManualTelemetry(req.body); + return res.status(201).json(result); + } catch (error) { + defaultLog.error({ label: 'updateManualTelemetry', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/telemetry/manual/process.ts b/api/src/paths/telemetry/manual/process.ts new file mode 100644 index 0000000000..8d681b5b5f --- /dev/null +++ b/api/src/paths/telemetry/manual/process.ts @@ -0,0 +1,120 @@ +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 { TelemetryService } from '../../../services/telemetry-service'; +import { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('/api/telemetry/manual/process'); + +export const POST: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + processFile() +]; + +POST.apiDoc = { + description: 'Processes and validates telemetry CSV submission', + tags: ['survey', 'telemetry', 'csv'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + required: true + }, + { + in: 'path', + name: 'surveyId', + required: true + } + ], + requestBody: { + description: 'Request body', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['submission_id'], + properties: { + submission_id: { + description: 'The ID of the submission to validate', + type: 'integer' + } + } + } + } + } + }, + responses: { + 200: { + description: 'Validation results of the telemetry submission', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'A flag determining if the file was processed' + } + } + } + } + } + }, + 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 processFile(): RequestHandler { + return async (req, res) => { + const submissionId = req.body.submission_id; + + const connection = getDBConnection(req['keycloak_token']); + try { + await connection.open(); + + const service = new TelemetryService(connection); + + await service.processTelemetryCsvSubmission(submissionId); + + res.status(200).json({ success: true }); + + await connection.commit(); + } catch (error) { + defaultLog.error({ label: 'processFile', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/telemetry/vendor/deployments.test.ts b/api/src/paths/telemetry/vendor/deployments.test.ts new file mode 100644 index 0000000000..b01dec5801 --- /dev/null +++ b/api/src/paths/telemetry/vendor/deployments.test.ts @@ -0,0 +1,48 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { BctwService, IManualTelemetry } from '../../../services/bctw-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; +import { getVendorTelemetryByDeploymentIds } from './deployments'; + +const mockTelemetry = ([ + { + telemetry_manual_id: 1 + }, + { + telemetry_manual_id: 2 + } +] as unknown[]) as IManualTelemetry[]; + +describe('getVendorTelemetryByDeploymentIds', () => { + afterEach(() => { + sinon.restore(); + }); + it('should retrieve all vendor telemetry by deployment ids', async () => { + const mockGetTelemetry = sinon + .stub(BctwService.prototype, 'getVendorTelemetryByDeploymentIds') + .resolves(mockTelemetry); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getVendorTelemetryByDeploymentIds(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql(mockTelemetry); + expect(mockRes.statusValue).to.equal(200); + expect(mockGetTelemetry).to.have.been.calledOnce; + }); + it('should catch error', async () => { + const mockError = new Error('test error'); + const mockGetTelemetry = sinon.stub(BctwService.prototype, 'getVendorTelemetryByDeploymentIds').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getVendorTelemetryByDeploymentIds(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + } catch (err) { + expect(err).to.equal(mockError); + expect(mockGetTelemetry).to.have.been.calledOnce; + } + }); +}); diff --git a/api/src/paths/telemetry/vendor/deployments.ts b/api/src/paths/telemetry/vendor/deployments.ts new file mode 100644 index 0000000000..9f3e7eedb3 --- /dev/null +++ b/api/src/paths/telemetry/vendor/deployments.ts @@ -0,0 +1,106 @@ +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/manual'); + +const vendor_telemetry_responses = { + 200: { + description: 'Manual telemetry response object', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + telemetry_id: { type: 'string', format: 'uuid' }, + deployment_id: { type: 'string', format: 'uuid' }, + collar_transaction_id: { type: 'string', format: 'uuid' }, + critter_id: { type: 'string', format: 'uuid' }, + deviceid: { type: 'number' }, + latitude: { type: 'number', nullable: true }, + longitude: { type: 'number', nullable: true }, + elevation: { type: 'number', nullable: true }, + vendor: { type: 'string', nullable: true }, + acquisition_date: { 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 const POST: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + getVendorTelemetryByDeploymentIds() +]; + +POST.apiDoc = { + description: 'Get a list of vendor retrieved telemetry by deployment ids', + tags: ['telemetry'], + security: [ + { + Bearer: [] + } + ], + responses: vendor_telemetry_responses, + requestBody: { + description: 'Request body', + required: true, + content: { + 'application/json': { + schema: { + title: 'Telemetry for Deployment ids', + type: 'array', + minItems: 1, + items: { + title: 'Vendor telemetry deployment ids', + type: 'string', + format: 'uuid' + } + } + } + } + } +}; + +export function getVendorTelemetryByDeploymentIds(): RequestHandler { + return async (req, res) => { + const user = getBctwUser(req); + const bctwService = new BctwService(user); + try { + const result = await bctwService.getVendorTelemetryByDeploymentIds(req.body); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getManualTelemetryByDeploymentIds', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/repositories/administrative-activity-repository.ts b/api/src/repositories/administrative-activity-repository.ts index 0a0dd1dd4c..44f02a3a62 100644 --- a/api/src/repositories/administrative-activity-repository.ts +++ b/api/src/repositories/administrative-activity-repository.ts @@ -5,7 +5,7 @@ import { ADMINISTRATIVE_ACTIVITY_TYPE } from '../constants/administrative-activity'; import { ApiExecuteSQLError } from '../errors/api-error'; -import { jsonSchema } from '../zod-schema/json'; +import { shallowJsonSchema } from '../zod-schema/json'; import { BaseRepository } from './base-repository'; export const IAdministrativeActivityStanding = z.object({ @@ -22,7 +22,7 @@ export const IAdministrativeActivity = z.object({ status: z.number(), status_name: z.string(), description: z.string().nullable(), - data: jsonSchema, + data: shallowJsonSchema, notes: z.string().nullable(), create_date: z.string() }); @@ -199,7 +199,7 @@ export class AdministrativeActivityRepository extends BaseRepository { ON aa.administrative_activity_status_type_id = aast.administrative_activity_status_type_id WHERE - (aa.data -> 'userGuid')::text = '"' || ${userGUID} || '"' + LOWER(aa.data ->> 'userGuid') = LOWER(${userGUID}) AND aast.name = 'Pending' ), @@ -217,7 +217,7 @@ export class AdministrativeActivityRepository extends BaseRepository { ON pp.system_user_id = su.system_user_id WHERE - su.user_guid = ${userGUID} + LOWER(su.user_guid) = LOWER(${userGUID}) ) SELECT * FROM diff --git a/api/src/repositories/code-repository.ts b/api/src/repositories/code-repository.ts index 8a1e6b4fdc..e5d1afc552 100644 --- a/api/src/repositories/code-repository.ts +++ b/api/src/repositories/code-repository.ts @@ -31,8 +31,6 @@ export const IAllCodeSets = z.object({ system_roles: CodeSet(), project_roles: CodeSet(), administrative_activity_status_type: CodeSet(), - field_methods: CodeSet(z.object({ id: z.number(), name: z.string(), description: z.string() }).shape), - ecological_seasons: CodeSet(z.object({ id: z.number(), name: z.string(), description: z.string() }).shape), intended_outcomes: CodeSet(z.object({ id: z.number(), name: z.string(), description: z.string() }).shape), vantage_codes: CodeSet(), survey_jobs: CodeSet(), @@ -161,50 +159,6 @@ export class CodeRepository extends BaseRepository { return response.rows; } - /** - * Fetch field method codes. - * - * @return {*} - * @memberof CodeRepository - */ - async getFieldMethods() { - const sqlStatement = SQL` - SELECT - field_method_id as id, - name, description - FROM - field_method - WHERE - record_end_date is null; - `; - - const response = await this.connection.sql(sqlStatement); - - return response.rows; - } - - /** - * Fetch ecological season codes. - * - * @return {*} - * @memberof CodeRepository - */ - async getEcologicalSeasons() { - const sqlStatement = SQL` - SELECT - ecological_season_id as id, - name, description - FROM - ecological_season - WHERE - record_end_date is null; - `; - - const response = await this.connection.sql(sqlStatement); - - return response.rows; - } - /** * Fetch vantage codes. * diff --git a/api/src/repositories/draft-repository.ts b/api/src/repositories/draft-repository.ts index d54373ee64..3efa134ca8 100644 --- a/api/src/repositories/draft-repository.ts +++ b/api/src/repositories/draft-repository.ts @@ -1,13 +1,13 @@ import SQL, { SQLStatement } from 'sql-template-strings'; import { z } from 'zod'; import { ApiExecuteSQLError } from '../errors/api-error'; -import { jsonSchema } from '../zod-schema/json'; +import { shallowJsonSchema } from '../zod-schema/json'; import { BaseRepository } from './base-repository'; export const WebformDraft = z.object({ webform_draft_id: z.number(), name: z.string(), - data: jsonSchema, + data: shallowJsonSchema, create_date: z.string(), update_date: z.string().nullable() }); diff --git a/api/src/repositories/history-publish-repository.ts b/api/src/repositories/history-publish-repository.ts index 5d60785ce5..5f5e8058fa 100644 --- a/api/src/repositories/history-publish-repository.ts +++ b/api/src/repositories/history-publish-repository.ts @@ -290,8 +290,13 @@ export class HistoryPublishRepository extends BaseRepository { (survey_id, queue_id, event_timestamp) VALUES (${data.survey_id}, ${data.queue_id}, NOW()) + ON CONFLICT (queue_id) DO UPDATE SET event_timestamp = NOW() RETURNING survey_metadata_publish_id; `; + + // NOTE: ON CONFLICT is used to update the timestamp if the same queue_id is used + // to publish the same survey multiple times + const response = await this.connection.sql(sqlStatement); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to insert Survey Metadata Publish record', [ diff --git a/api/src/repositories/observation-repository.test.ts b/api/src/repositories/observation-repository.test.ts index eff7847481..ab065d7cad 100644 --- a/api/src/repositories/observation-repository.test.ts +++ b/api/src/repositories/observation-repository.test.ts @@ -135,4 +135,90 @@ describe('ObservationRepository', () => { expect(response).to.be.eql(mockRows); }); }); + + describe('getSurveyObservationCount', () => { + it('gets the count of survey observations for the given survey', async () => { + const mockQueryResponse = ({ rows: [{ rowCount: 1 }] } as unknown) as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: sinon.stub().resolves(mockQueryResponse) + }); + + const repo = new ObservationRepository(mockDBConnection); + + const response = await repo.getSurveyObservationCount(1); + + expect(response).to.eql(1); + }); + }); + + describe('insertSurveyObservationSubmission', () => { + it('inserts a survey observation submission record', async () => { + const mockQueryResponse = ({ rows: [1] } as unknown) as QueryResult; + + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves(mockQueryResponse) + }); + + const surveyId = 1; + const submissionId = 2; + const key = 'key'; + const original_filename = 'originalFilename'; + + const repo = new ObservationRepository(mockDBConnection); + + const response = await repo.insertSurveyObservationSubmission(submissionId, key, surveyId, original_filename); + + expect(response).to.equal(1); + }); + }); + + describe('getNextSubmissionId', () => { + it('gets the next submission id', async () => { + const mockQueryResponse = ({ rows: [{ submission_id: 1 }], rowCount: 1 } as unknown) as QueryResult; + + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves(mockQueryResponse) + }); + + const repo = new ObservationRepository(mockDBConnection); + + const response = await repo.getNextSubmissionId(); + + expect(response).to.equal(1); + }); + }); + + describe('getObservationSubmissionById', () => { + it('gets a submission by ID', async () => { + const mockQueryResponse = ({ rows: [{ submission_id: 5 }], rowCount: 1 } as unknown) as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: sinon.stub().resolves(mockQueryResponse) + }); + + const repo = new ObservationRepository(mockDBConnection); + + const response = await repo.getObservationSubmissionById(5); + + expect(response).to.eql({ submission_id: 5 }); + }); + + it('throws an error when no submission is found', async () => { + const mockQueryResponse = ({ rows: [], rowCount: 0 } as unknown) as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: sinon.stub().resolves(mockQueryResponse) + }); + + const repo = new ObservationRepository(mockDBConnection); + + try { + await repo.getObservationSubmissionById(5); + expect.fail(); + } catch (error) { + expect((error as Error).message).to.equal('Failed to get observation submission'); + } + }); + }); }); diff --git a/api/src/repositories/observation-repository.ts b/api/src/repositories/observation-repository.ts index 8843051a42..5c4bb91ec7 100644 --- a/api/src/repositories/observation-repository.ts +++ b/api/src/repositories/observation-repository.ts @@ -1,6 +1,8 @@ import SQL from 'sql-template-strings'; import { z } from 'zod'; import { getKnex } from '../database/db'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { getLogger } from '../utils/logger'; import { BaseRepository } from './base-repository'; /** @@ -10,9 +12,9 @@ export const ObservationRecord = z.object({ survey_observation_id: z.number(), survey_id: z.number(), wldtaxonomic_units_id: z.number(), - survey_sample_site_id: z.number(), - survey_sample_method_id: z.number(), - survey_sample_period_id: z.number(), + survey_sample_site_id: z.number().nullable(), + survey_sample_method_id: z.number().nullable(), + survey_sample_period_id: z.number().nullable(), latitude: z.number(), longitude: z.number(), count: z.number(), @@ -61,6 +63,24 @@ export type UpdateObservation = Pick< | 'survey_sample_period_id' >; +/** + * Interface reflecting survey observations retrieved from the database + */ +export const ObservationSubmissionRecord = z.object({ + submission_id: z.number(), + survey_id: z.number(), + key: z.string(), + original_filename: z.string(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable() +}); + +export type ObservationSubmissionRecord = z.infer; + +const defaultLog = getLogger('repositories/observation-repository'); + export class ObservationRepository extends BaseRepository { /** * Deletes all survey observation records associated with the given survey, except @@ -130,7 +150,7 @@ export class ObservationRepository extends BaseRepository { observation_time ) OVERRIDING SYSTEM VALUE - VALUES + VALUES `; sqlStatement.append( @@ -140,9 +160,9 @@ export class ObservationRepository extends BaseRepository { observation['survey_observation_id'] || 'DEFAULT', surveyId, observation.wldtaxonomic_units_id, - observation.survey_sample_site_id, - observation.survey_sample_method_id, - observation.survey_sample_period_id, + observation.survey_sample_site_id ?? 'NULL', + observation.survey_sample_method_id ?? 'NULL', + observation.survey_sample_period_id ?? 'NULL', observation.count, observation.latitude, observation.longitude, @@ -190,4 +210,186 @@ export class ObservationRepository extends BaseRepository { const response = await this.connection.knex(sqlStatement, ObservationRecord); return response.rows; } + + /** + * Retrieves the count of survey observations for the given survey + * + * @param {number} surveyId + * @return {*} {Promise<{ observationCount: number }>} + * @memberof ObservationRepository + */ + async getSurveyObservationCount(surveyId: number): Promise { + const knex = getKnex(); + const sqlStatement = knex + .queryBuilder() + .count('survey_observation_id as rowCount') + .from('survey_observation') + .where('survey_id', surveyId); + + const response = await this.connection.knex(sqlStatement); + + return Number(response.rows[0].rowCount); + } + + /** + * Inserts a survey observation submission record into the database and returns the record + * + * @param {number} submission_id + * @param {string} key + * @param {number} survey_id + * @param {string} original_filename + * @return {*} {Promise} + * @memberof ObservationRepository + */ + async insertSurveyObservationSubmission( + submission_id: number, + key: string, + survey_id: number, + original_filename: string + ): Promise { + defaultLog.debug({ label: 'insertSurveyObservationSubmission' }); + const sqlStatement = SQL` + INSERT INTO + survey_observation_submission + (submission_id, key, survey_id, original_filename) + VALUES + (${submission_id}, ${key}, ${survey_id}, ${original_filename}) + RETURNING *;`; + + const response = await this.connection.sql(sqlStatement, ObservationSubmissionRecord); + + return response.rows[0]; + } + + /** + * Retrieves the next submission ID from the survey_observation_submission_seq sequence + * + * @return {*} {Promise} + * @memberof ObservationRepository + */ + async getNextSubmissionId(): Promise { + const sqlStatement = SQL` + SELECT nextval('biohub.survey_observation_submission_id_seq')::integer as submission_id; + `; + const response = await this.connection.sql<{ submission_id: number }>(sqlStatement); + return response.rows[0].submission_id; + } + + /** + * Retrieves the observation submission record by the given submission ID. + * + * @param {number} submissionId + * @return {*} {Promise} + * @memberof ObservationService + */ + async getObservationSubmissionById(submissionId: number): Promise { + const queryBuilder = getKnex() + .queryBuilder() + .select('*') + .from('survey_observation_submission') + .where('submission_id', submissionId); + + const response = await this.connection.knex(queryBuilder, ObservationSubmissionRecord); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to get observation submission', [ + 'ObservationRepository->getObservationSubmissionById', + 'rowCount was null or undefined, expected rowCount = 1' + ]); + } + + return response.rows[0]; + } + + /** + * Deletes all of the given survey observations by ID. + * + * @param {number[]} observationIds + * @return {*} {Promise} + * @memberof ObservationRepository + */ + async deleteObservationsByIds(observationIds: number[]): Promise { + const queryBuilder = getKnex() + .queryBuilder() + .delete() + .from('survey_observation') + .whereIn('survey_observation_id', observationIds) + .returning('*'); + + const response = await this.connection.knex(queryBuilder, ObservationRecord); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to delete observation records', [ + 'ObservationRepository->deleteObservationsByIds', + 'rowCount was null or undefined, expected rowCount = 1' + ]); + } + + return response.rowCount; + } + + /** + * Retrieves observation records count for the given survey and sample site ids + * + * @param {number} surveyId + * @param {number} sampleSiteId + * @return {*} {Promise<{ observationCount: number }>} + * @memberof ObservationRepository + */ + async getObservationsCountBySampleSiteId( + surveyId: number, + sampleSiteId: number + ): Promise<{ observationCount: number }> { + const knex = getKnex(); + const sqlStatement = knex + .queryBuilder() + .count('survey_observation_id as rowCount') + .from('survey_observation') + .where('survey_id', surveyId) + .where('survey_sample_site_id', sampleSiteId); + + const response = await this.connection.knex(sqlStatement); + const observationCount = Number(response.rows[0].rowCount); + return { observationCount }; + } + + /** + * Retrieves observation records count for the given survey and sample method ids + * + * @param {number} sampleMethodId + * @return {*} {Promise<{ observationCount: number }>} + * @memberof ObservationRepository + */ + async getObservationsCountBySampleMethodId(sampleMethodId: number): Promise<{ observationCount: number }> { + const knex = getKnex(); + const sqlStatement = knex + .queryBuilder() + .count('survey_observation_id as rowCount') + .from('survey_observation') + .where('survey_sample_method_id', sampleMethodId); + + const response = await this.connection.knex(sqlStatement); + const observationCount = Number(response.rows[0].rowCount); + return { observationCount }; + } + + /** + * Retrieves observation records count for the given survey and sample period ids + * + * @param {number} samplePeriodId + * @return {*} {Promise<{ observationCount: number }>} + * @memberof ObservationRepository + */ + async getObservationsCountBySamplePeriodId(samplePeriodId: number): Promise<{ observationCount: number }> { + const knex = getKnex(); + const sqlStatement = knex + .queryBuilder() + .count('survey_observation_id as rowCount') + .from('survey_observation') + .where('survey_sample_period_id', samplePeriodId); + + const response = await this.connection.knex(sqlStatement); + const observationCount = Number(response.rows[0].rowCount); + return { observationCount }; + } } diff --git a/api/src/repositories/project-repository.test.ts b/api/src/repositories/project-repository.test.ts index 5dcfcc4a4e..51f0edf276 100644 --- a/api/src/repositories/project-repository.test.ts +++ b/api/src/repositories/project-repository.test.ts @@ -8,7 +8,6 @@ import { PostProjectObject } from '../models/project-create'; import { GetAttachmentsData, GetIUCNClassificationData, - GetLocationData, GetObjectivesData, GetReportAttachmentsData } from '../models/project-view'; @@ -133,32 +132,6 @@ describe('ProjectRepository', () => { }); }); - describe('getLocationData', () => { - it('should return result', async () => { - const mockResponse = ({ rows: [{ location_description: 'desc' }], rowCount: 1 } as any) as Promise< - QueryResult - >; - const dbConnection = getMockDBConnection({ sql: () => mockResponse }); - - const repository = new ProjectRepository(dbConnection); - - const response = await repository.getLocationData(1); - - expect(response).to.eql(new GetLocationData([{ location_description: 'desc' }])); - }); - - it('should return empty rows', async () => { - const mockResponse = ({ rows: [], rowCount: 1 } as any) as Promise>; - const dbConnection = getMockDBConnection({ sql: () => mockResponse }); - - const repository = new ProjectRepository(dbConnection); - - const response = await repository.getLocationData(1); - - expect(response).to.eql(new GetLocationData([])); - }); - }); - describe('getIUCNClassificationData', () => { it('should return result', async () => { const mockResponse = ({ @@ -252,8 +225,7 @@ describe('ProjectRepository', () => { end_date: 'end_date', comments: 'comments' }, - objectives: { objectives: '' }, - location: { location_description: '', geometry: [{ id: 1 }] } + objectives: { objectives: '' } } as unknown) as PostProjectObject; const response = await repository.insertProject(input); @@ -275,8 +247,7 @@ describe('ProjectRepository', () => { end_date: 'end_date', comments: 'comments' }, - objectives: { objectives: '' }, - location: { location_description: '', geometry: [] } + objectives: { objectives: '' } } as unknown) as PostProjectObject; const response = await repository.insertProject(input); @@ -298,8 +269,7 @@ describe('ProjectRepository', () => { end_date: 'end_date', comments: 'comments' }, - objectives: { objectives: '' }, - location: { location_description: '', geometry: [] } + objectives: { objectives: '' } } as unknown) as PostProjectObject; try { diff --git a/api/src/repositories/project-repository.ts b/api/src/repositories/project-repository.ts index d5299f9af2..d8435f9688 100644 --- a/api/src/repositories/project-repository.ts +++ b/api/src/repositories/project-repository.ts @@ -2,18 +2,16 @@ import { isArray } from 'lodash'; import SQL, { SQLStatement } from 'sql-template-strings'; import { ApiExecuteSQLError } from '../errors/api-error'; import { PostProjectObject } from '../models/project-create'; -import { PutLocationData, PutObjectivesData, PutProjectData } from '../models/project-update'; +import { PutObjectivesData, PutProjectData } from '../models/project-update'; import { GetAttachmentsData, GetIUCNClassificationData, - GetLocationData, GetObjectivesData, GetReportAttachmentsData, IProjectAdvancedFilters, ProjectData, ProjectListData } from '../models/project-view'; -import { generateGeometryCollectionSQL } from '../utils/spatial-utils'; import { BaseRepository } from './base-repository'; /** @@ -153,7 +151,6 @@ export class ProjectRepository extends BaseRepository { p.uuid, p.name as project_name, p.objectives, - p.location_description, p.start_date, p.end_date, p.comments, @@ -213,27 +210,6 @@ export class ProjectRepository extends BaseRepository { return new GetObjectivesData(result); } - async getLocationData(projectId: number): Promise { - const sqlStatement = SQL` - SELECT - p.location_description, - p.geojson as geometry, - p.revision_count - FROM - project p - WHERE - p.project_id = ${projectId} - GROUP BY - p.location_description, - p.geojson, - p.revision_count; - `; - - const response = await this.connection.sql(sqlStatement); - - return new GetLocationData(response.rows); - } - async getIUCNClassificationData(projectId: number): Promise { const sqlStatement = SQL` SELECT @@ -321,48 +297,17 @@ export class ProjectRepository extends BaseRepository { INSERT INTO project ( name, objectives, - location_description, start_date, end_date, - comments, - geojson, - geography + comments ) VALUES ( ${postProjectData.project.name}, ${postProjectData.objectives.objectives}, - ${postProjectData.location.location_description}, ${postProjectData.project.start_date}, ${postProjectData.project.end_date}, - ${postProjectData.project.comments}, - ${JSON.stringify(postProjectData.location.geometry)} - `; - - if (postProjectData?.location?.geometry?.length) { - const geometryCollectionSQL = generateGeometryCollectionSQL(postProjectData.location.geometry); - - sqlStatement.append(SQL` - ,public.geography( - public.ST_Force2D( - public.ST_SetSRID( - `); - - sqlStatement.append(geometryCollectionSQL); - - sqlStatement.append(SQL` - , 4326))) - `); - } else { - sqlStatement.append(SQL` - ,null - `); - } - - sqlStatement.append(SQL` + ${postProjectData.project.comments} ) - RETURNING - project_id as id; - `); - + RETURNING project_id as id;`; const response = await this.connection.sql(sqlStatement); const result = response?.rows?.[0]; @@ -473,11 +418,10 @@ export class ProjectRepository extends BaseRepository { async updateProjectData( projectId: number, project: PutProjectData | null, - location: PutLocationData | null, objectives: PutObjectivesData | null, revision_count: number ): Promise { - if (!project && !location && !objectives) { + if (!project && !objectives) { // Nothing to update throw new ApiExecuteSQLError('Nothing to update for Project Data', [ 'ProjectRepository->updateProjectData', @@ -495,33 +439,6 @@ export class ProjectRepository extends BaseRepository { sqlSetStatements.push(SQL`end_date = ${project.end_date}`); } - if (location) { - sqlSetStatements.push(SQL`location_description = ${location.location_description}`); - sqlSetStatements.push(SQL`geojson = ${JSON.stringify(location.geometry)}`); - - const geometrySQLStatement = SQL`geography = `; - - if (location?.geometry?.length) { - const geometryCollectionSQL = generateGeometryCollectionSQL(location.geometry); - - geometrySQLStatement.append(SQL` - public.geography( - public.ST_Force2D( - public.ST_SetSRID( - `); - - geometrySQLStatement.append(geometryCollectionSQL); - - geometrySQLStatement.append(SQL` - , 4326))) - `); - } else { - geometrySQLStatement.append(SQL`null`); - } - - sqlSetStatements.push(geometrySQLStatement); - } - if (objectives) { sqlSetStatements.push(SQL`objectives = ${objectives.objectives}`); } diff --git a/api/src/repositories/sample-location-repository.test.ts b/api/src/repositories/sample-location-repository.test.ts index 2b0f17de84..272c00c7d8 100644 --- a/api/src/repositories/sample-location-repository.test.ts +++ b/api/src/repositories/sample-location-repository.test.ts @@ -120,7 +120,6 @@ describe('SampleLocationRepository', () => { } catch (error) { expect(dbConnectionObj.sql).to.have.been.calledOnce; expect((error as ApiExecuteSQLError).message).to.be.eql('Failed to insert sample location'); - expect(dbConnectionObj.sql).to.have.been.calledOnce; } }); }); @@ -151,7 +150,6 @@ describe('SampleLocationRepository', () => { } catch (error) { expect(dbConnectionObj.sql).to.have.been.calledOnce; expect((error as ApiExecuteSQLError).message).to.be.eql('Failed to delete survey block record'); - expect(dbConnectionObj.sql).to.have.been.calledOnce; } }); }); diff --git a/api/src/repositories/sample-location-repository.ts b/api/src/repositories/sample-location-repository.ts index 162691b4f9..fa2ec7f2e7 100644 --- a/api/src/repositories/sample-location-repository.ts +++ b/api/src/repositories/sample-location-repository.ts @@ -25,7 +25,9 @@ export const SampleLocationRecord = z.object({ export type SampleLocationRecord = z.infer; // Insert Object for Sample Locations -export type InsertSampleLocationRecord = Pick; +export type InsertSampleLocationRecord = Pick & { + name: string | undefined; +}; // Update Object for Sample Locations export type UpdateSampleLocationRecord = Pick< @@ -161,10 +163,10 @@ export class SampleLocationRepository extends BaseRepository { geography ) VALUES ( ${sample.survey_id}, - (SELECT concat('Sample Site ', (SELECT count(survey_sample_site_id) + 1 FROM survey_sample_site sss WHERE survey_id = ${sample.survey_id}))), + ${sample.name}, ${sample.description}, ${sample.geojson}, - `; + `; const geometryCollectionSQL = generateGeometryCollectionSQL(sample.geojson); sqlStatement.append(SQL` diff --git a/api/src/repositories/sample-method-repository.test.ts b/api/src/repositories/sample-method-repository.test.ts index 67828223d3..6859a6ce41 100644 --- a/api/src/repositories/sample-method-repository.test.ts +++ b/api/src/repositories/sample-method-repository.test.ts @@ -54,8 +54,22 @@ describe('SampleMethodRepository', () => { method_lookup_id: 3, description: 'description', periods: [ - { end_date: '2023-01-02', start_date: '2023-10-02', survey_sample_method_id: 1, survey_sample_period_id: 4 }, - { end_date: '2023-10-03', start_date: '2023-11-05', survey_sample_method_id: 1, survey_sample_period_id: 5 } + { + end_date: '2023-01-02', + start_date: '2023-10-02', + start_time: '12:00:00', + end_time: '13:00:00', + survey_sample_method_id: 1, + survey_sample_period_id: 4 + }, + { + end_date: '2023-10-03', + start_date: '2023-11-05', + start_time: '12:00:00', + end_time: '13:00:00', + survey_sample_method_id: 1, + survey_sample_period_id: 5 + } ] }; const repo = new SampleMethodRepository(dbConnectionObj); @@ -75,8 +89,22 @@ describe('SampleMethodRepository', () => { method_lookup_id: 3, description: 'description', periods: [ - { end_date: '2023-01-02', start_date: '2023-10-02', survey_sample_method_id: 1, survey_sample_period_id: 4 }, - { end_date: '2023-10-03', start_date: '2023-11-05', survey_sample_method_id: 1, survey_sample_period_id: 5 } + { + end_date: '2023-01-02', + start_date: '2023-10-02', + start_time: '12:00:00', + end_time: '13:00:00', + survey_sample_method_id: 1, + survey_sample_period_id: 4 + }, + { + end_date: '2023-10-03', + start_date: '2023-11-05', + start_time: '12:00:00', + end_time: '13:00:00', + survey_sample_method_id: 1, + survey_sample_period_id: 5 + } ] }; const repo = new SampleMethodRepository(dbConnectionObj); @@ -101,8 +129,20 @@ describe('SampleMethodRepository', () => { method_lookup_id: 3, description: 'description', periods: [ - { end_date: '2023-01-02', start_date: '2023-10-02', survey_sample_method_id: 1 }, - { end_date: '2023-10-03', start_date: '2023-11-05', survey_sample_method_id: 1 } + { + end_date: '2023-01-02', + start_date: '2023-10-02', + start_time: '12:00:00', + end_time: '13:00:00', + survey_sample_method_id: 1 + }, + { + end_date: '2023-10-03', + start_date: '2023-11-05', + start_time: '12:00:00', + end_time: '13:00:00', + survey_sample_method_id: 1 + } ] }; const repo = new SampleMethodRepository(dbConnectionObj); @@ -121,8 +161,20 @@ describe('SampleMethodRepository', () => { method_lookup_id: 3, description: 'description', periods: [ - { end_date: '2023-01-02', start_date: '2023-10-02', survey_sample_method_id: 1 }, - { end_date: '2023-10-03', start_date: '2023-11-05', survey_sample_method_id: 1 } + { + end_date: '2023-01-02', + start_date: '2023-10-02', + start_time: '12:00:00', + end_time: '13:00:00', + survey_sample_method_id: 1 + }, + { + end_date: '2023-10-03', + start_date: '2023-11-05', + start_time: '12:00:00', + end_time: '13:00:00', + survey_sample_method_id: 1 + } ] }; const repo = new SampleMethodRepository(dbConnectionObj); @@ -132,7 +184,6 @@ describe('SampleMethodRepository', () => { } catch (error) { expect(dbConnectionObj.sql).to.have.been.calledOnce; expect((error as ApiExecuteSQLError).message).to.be.eql('Failed to insert sample method'); - expect(dbConnectionObj.sql).to.have.been.calledOnce; } }); }); @@ -163,7 +214,6 @@ describe('SampleMethodRepository', () => { } catch (error) { expect(dbConnectionObj.sql).to.have.been.calledOnce; expect((error as ApiExecuteSQLError).message).to.be.eql('Failed to delete sample method'); - expect(dbConnectionObj.sql).to.have.been.calledOnce; } }); }); diff --git a/api/src/repositories/sample-period-repository.test.ts b/api/src/repositories/sample-period-repository.test.ts index 1b2ad3c9f3..287ebeda1e 100644 --- a/api/src/repositories/sample-period-repository.test.ts +++ b/api/src/repositories/sample-period-repository.test.ts @@ -52,7 +52,9 @@ describe('SamplePeriodRepository', () => { survey_sample_method_id: 1, survey_sample_period_id: 2, start_date: '2023-10-02', - end_date: '2023-01-02' + end_date: '2023-01-02', + start_time: '12:00:00', + end_time: '13:00:00' }; const repo = new SamplePeriodRepository(dbConnectionObj); const response = await repo.updateSamplePeriod(samplePeriod); @@ -69,7 +71,9 @@ describe('SamplePeriodRepository', () => { survey_sample_method_id: 1, survey_sample_period_id: 2, start_date: '2023-10-02', - end_date: '2023-01-02' + end_date: '2023-01-02', + start_time: '12:00:00', + end_time: '13:00:00' }; const repo = new SamplePeriodRepository(dbConnectionObj); @@ -91,7 +95,9 @@ describe('SamplePeriodRepository', () => { const samplePeriod: InsertSamplePeriodRecord = { survey_sample_method_id: 1, start_date: '2023-10-02', - end_date: '2023-01-02' + end_date: '2023-01-02', + start_time: '12:00:00', + end_time: '13:00:00' }; const repo = new SamplePeriodRepository(dbConnectionObj); const response = await repo.insertSamplePeriod(samplePeriod); @@ -107,7 +113,9 @@ describe('SamplePeriodRepository', () => { const samplePeriod: InsertSamplePeriodRecord = { survey_sample_method_id: 1, start_date: '2023-10-02', - end_date: '2023-01-02' + end_date: '2023-01-02', + start_time: '12:00:00', + end_time: '13:00:00' }; const repo = new SamplePeriodRepository(dbConnectionObj); @@ -116,7 +124,6 @@ describe('SamplePeriodRepository', () => { } catch (error) { expect(dbConnectionObj.sql).to.have.been.calledOnce; expect((error as ApiExecuteSQLError).message).to.be.eql('Failed to insert sample period'); - expect(dbConnectionObj.sql).to.have.been.calledOnce; } }); }); @@ -147,7 +154,6 @@ describe('SamplePeriodRepository', () => { } catch (error) { expect(dbConnectionObj.sql).to.have.been.calledOnce; expect((error as ApiExecuteSQLError).message).to.be.eql('Failed to delete sample period'); - expect(dbConnectionObj.sql).to.have.been.calledOnce; } }); }); diff --git a/api/src/repositories/sample-period-repository.ts b/api/src/repositories/sample-period-repository.ts index bd6ee6e2a0..13825666c8 100644 --- a/api/src/repositories/sample-period-repository.ts +++ b/api/src/repositories/sample-period-repository.ts @@ -1,13 +1,17 @@ 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'; -export type InsertSamplePeriodRecord = Pick; +export type InsertSamplePeriodRecord = Pick< + SamplePeriodRecord, + 'survey_sample_method_id' | 'start_date' | 'end_date' | 'start_time' | 'end_time' +>; export type UpdateSamplePeriodRecord = Pick< SamplePeriodRecord, - 'survey_sample_period_id' | 'survey_sample_method_id' | 'start_date' | 'end_date' + 'survey_sample_period_id' | 'survey_sample_method_id' | 'start_date' | 'end_date' | 'start_time' | 'end_time' >; // This describes a row in the database for Survey Sample Period @@ -16,6 +20,8 @@ export const SamplePeriodRecord = z.object({ survey_sample_method_id: z.number(), start_date: z.string(), end_date: z.string(), + start_time: z.string().nullable(), + end_time: z.string().nullable(), create_date: z.string(), create_user: z.number(), update_date: z.string().nullable(), @@ -63,7 +69,9 @@ export class SamplePeriodRepository extends BaseRepository { SET survey_sample_method_id=${sample.survey_sample_method_id}, start_date=${sample.start_date}, - end_date=${sample.end_date} + end_date=${sample.end_date}, + start_time=${sample.start_time || null}, + end_time=${sample.end_time || null} WHERE survey_sample_period_id = ${sample.survey_sample_period_id} RETURNING @@ -93,11 +101,15 @@ export class SamplePeriodRepository extends BaseRepository { INSERT INTO survey_sample_period ( survey_sample_method_id, start_date, - end_date + end_date, + start_time, + end_time ) VALUES ( ${sample.survey_sample_method_id}, ${sample.start_date}, - ${sample.end_date} + ${sample.end_date}, + ${sample.start_time || null}, + ${sample.end_time || null} ) RETURNING *;`; @@ -151,19 +163,19 @@ export class SamplePeriodRepository extends BaseRepository { * @memberof SamplePeriodRepository */ async deleteSamplePeriods(periodsToDelete: number[]): Promise { - const sqlStatement = SQL` - DELETE FROM - survey_sample_period - WHERE - survey_sample_period_id IN (${periodsToDelete.join(',')}) - RETURNING - *; - `; + const knex = getKnex(); - const response = await this.connection.sql(sqlStatement, SamplePeriodRecord); + const sqlStatement = knex + .queryBuilder() + .delete() + .from('survey_sample_period') + .whereIn('survey_sample_period_id', periodsToDelete) + .returning('*'); + + const response = await this.connection.knex(sqlStatement, SamplePeriodRecord); if (!response?.rowCount) { - throw new ApiExecuteSQLError('Failed to delete sample period', [ + throw new ApiExecuteSQLError('Failed to delete sample periods', [ 'SamplePeriodRepository->deleteSamplePeriods', 'rows was null or undefined, expected rows != null' ]); diff --git a/api/src/repositories/site-selection-strategy-repository.test.ts b/api/src/repositories/site-selection-strategy-repository.test.ts new file mode 100644 index 0000000000..2b5d7cb0f3 --- /dev/null +++ b/api/src/repositories/site-selection-strategy-repository.test.ts @@ -0,0 +1,318 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import { QueryResult } from 'pg'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { getMockDBConnection } from '../__mocks__/db'; +import { + SiteSelectionStrategyRepository, + SurveyStratum, + SurveyStratumRecord +} from './site-selection-strategy-repository'; + +chai.use(sinonChai); + +describe('SiteSelectionStrategyRepository', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('getSiteSelectionDataBySurveyId', () => { + it('should return non-empty data', async () => { + const mockStrategiesRows: { name: string }[] = [{ name: 'strategy1' }, { name: 'strategy2' }]; + const mockStrategiesResponse = ({ rows: mockStrategiesRows, rowCount: 2 } as any) as Promise>; + + const mockStratumsRows: SurveyStratumRecord[] = [ + { + name: 'stratum1', + description: '', + survey_id: 1, + survey_stratum_id: 2, + revision_count: 0, + update_date: '2023-05-20' + }, + { + name: 'stratum2', + description: '', + survey_id: 1, + survey_stratum_id: 2, + revision_count: 0, + update_date: '2023-05-20' + } + ]; + const mockStratumsResponse = ({ rows: mockStratumsRows, rowCount: 2 } as any) as Promise>; + + const dbConnectionObj = getMockDBConnection({ + knex: sinon.stub().onFirstCall().resolves(mockStrategiesResponse).onSecondCall().resolves(mockStratumsResponse) + }); + + const repo = new SiteSelectionStrategyRepository(dbConnectionObj); + + const surveyId = 1; + + const response = await repo.getSiteSelectionDataBySurveyId(surveyId); + + expect(dbConnectionObj.knex).to.have.been.calledTwice; + expect(response).to.eql({ strategies: ['strategy1', 'strategy2'], stratums: mockStratumsRows }); + }); + + it('should return empty data', async () => { + const mockStrategiesRows: { name: string }[] = []; + const mockStrategiesResponse = ({ rows: mockStrategiesRows, rowCount: 0 } as any) as Promise>; + + const mockStratumsRows: SurveyStratumRecord[] = []; + const mockStratumsResponse = ({ rows: mockStratumsRows, rowCount: 0 } as any) as Promise>; + + const dbConnectionObj = getMockDBConnection({ + knex: sinon.stub().onFirstCall().resolves(mockStrategiesResponse).onSecondCall().resolves(mockStratumsResponse) + }); + + const repo = new SiteSelectionStrategyRepository(dbConnectionObj); + + const surveyId = 1; + + const response = await repo.getSiteSelectionDataBySurveyId(surveyId); + + expect(dbConnectionObj.knex).to.have.been.calledTwice; + expect(response).to.eql({ strategies: [], stratums: mockStratumsRows }); + }); + }); + + describe('deleteSurveySiteSelectionStrategies', () => { + it('should return non-zero rowCount', async () => { + const mockRows: any[] = [{}]; + const rowCount = 1; + const mockResponse = ({ rows: mockRows, rowCount: rowCount } as any) as Promise>; + + const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const repo = new SiteSelectionStrategyRepository(dbConnectionObj); + + const surveyId = 1; + + const response = await repo.deleteSurveySiteSelectionStrategies(surveyId); + + expect(dbConnectionObj.sql).to.have.been.calledOnce; + expect(response).to.equal(rowCount); + }); + + it('should return zero rowCount', async () => { + const mockRows: any[] = []; + const rowCount = 0; + const mockResponse = ({ rows: mockRows, rowCount: rowCount } as any) as Promise>; + + const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const repo = new SiteSelectionStrategyRepository(dbConnectionObj); + + const surveyId = 1; + + const response = await repo.deleteSurveySiteSelectionStrategies(surveyId); + + expect(dbConnectionObj.sql).to.have.been.calledOnce; + expect(response).to.equal(rowCount); + }); + }); + + describe('insertSurveySiteSelectionStrategies', () => { + it('should insert a record and return a single row', async () => { + const mockRows: any[] = [{}, {}]; + const mockResponse = ({ rows: mockRows, rowCount: 2 } as any) as Promise>; + + const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const repo = new SiteSelectionStrategyRepository(dbConnectionObj); + + const surveyId = 1; + const strategies: string[] = ['strategy1', 'strategy2']; + + const response = await repo.insertSurveySiteSelectionStrategies(surveyId, strategies); + + expect(dbConnectionObj.sql).to.have.been.calledOnce; + expect(response).to.be.undefined; + }); + + it('throws an error if rowCount does not equal strategies length', async () => { + const mockRows: any[] = [{}]; + const mockResponse = ({ rows: mockRows, rowCount: 1 } as any) as Promise>; + + const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const repo = new SiteSelectionStrategyRepository(dbConnectionObj); + + const surveyId = 1; + + // strategies length = 2, rowCount = 1 + const strategies: string[] = ['strategy1', 'strategy2']; + + try { + await repo.insertSurveySiteSelectionStrategies(surveyId, strategies); + } catch (error) { + expect(dbConnectionObj.sql).to.have.been.calledOnce; + expect((error as ApiExecuteSQLError).message).to.be.eql('Failed to insert survey site selection strategies'); + } + }); + }); + + describe('deleteSurveyStratums', () => { + it('should delete records and return non-zero rowCount', async () => { + const mockRows: any[] = []; + const rowCount = 3; + const mockResponse = ({ rows: mockRows, rowCount: rowCount } as any) as Promise>; + const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().resolves(mockResponse) }); + + const repo = new SiteSelectionStrategyRepository(dbConnectionObj); + + const stratumIds = [1, 2, 3]; + + const response = await repo.deleteSurveyStratums(stratumIds); + + expect(dbConnectionObj.knex).to.have.been.calledOnce; + expect(response).to.eql(rowCount); + }); + + it('should delete records and return zero rowCount', async () => { + const mockRows: any[] = []; + const rowCount = 0; + const mockResponse = ({ rows: mockRows, rowCount: rowCount } as any) as Promise>; + const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().resolves(mockResponse) }); + + const repo = new SiteSelectionStrategyRepository(dbConnectionObj); + + const stratumIds: number[] = []; + + const response = await repo.deleteSurveyStratums(stratumIds); + + expect(dbConnectionObj.knex).to.have.been.calledOnce; + expect(response).to.eql(rowCount); + }); + }); + + describe('insertSurveyStratums', () => { + it('should insert records and return rows', async () => { + const mockRows: any[] = [{}, {}]; + const mockResponse = ({ rows: mockRows, rowCount: 2 } as any) as Promise>; + + const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().resolves(mockResponse) }); + + const repo = new SiteSelectionStrategyRepository(dbConnectionObj); + + const surveyId = 1; + const stratums: SurveyStratum[] = [ + { name: 'stratum1', description: '' }, + { name: 'stratum2', description: '' } + ]; + + const response = await repo.insertSurveyStratums(surveyId, stratums); + + expect(dbConnectionObj.knex).to.have.been.calledOnce; + expect(response).to.eql(mockRows); + }); + + it('throws an error if rowCount does not equal stratums length', async () => { + const mockRows: any[] = [{}]; + const rowCount = 1; + const mockResponse = ({ rows: mockRows, rowCount: rowCount } as any) as Promise>; + + const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().resolves(mockResponse) }); + + const repo = new SiteSelectionStrategyRepository(dbConnectionObj); + + const surveyId = 1; + + // stratums length = 2, rowCount = 1 + const stratums: SurveyStratum[] = [ + { name: 'stratum1', description: '' }, + { name: 'stratum2', description: '' } + ]; + + try { + await repo.insertSurveyStratums(surveyId, stratums); + } catch (error) { + expect(dbConnectionObj.knex).to.have.been.calledOnce; + expect((error as ApiExecuteSQLError).message).to.be.eql('Failed to insert survey stratums'); + } + }); + }); + + describe('updateSurveyStratums', () => { + it('should insert records and return rows', async () => { + const mockRows1: SurveyStratumRecord[] = [ + { + name: 'stratum1', + description: '', + survey_id: 1, + survey_stratum_id: 1, + revision_count: 1, + update_date: '2023-10-23' + } + ]; + const mockRows2: SurveyStratumRecord[] = [ + { + name: 'stratum1', + description: '', + survey_id: 1, + survey_stratum_id: 2, + revision_count: 1, + update_date: '2023-10-23' + } + ]; + const mockResponse1 = ({ rows: mockRows1, rowCount: 1 } as any) as Promise>; + const mockResponse2 = ({ rows: mockRows2, rowCount: 1 } as any) as Promise>; + + const dbConnectionObj = getMockDBConnection({ + knex: sinon.stub().onFirstCall().resolves(mockResponse1).onSecondCall().resolves(mockResponse2) + }); + + const repo = new SiteSelectionStrategyRepository(dbConnectionObj); + + const surveyId = 1; + const stratums: SurveyStratumRecord[] = [ + { name: 'stratum1', description: '', survey_id: 1, survey_stratum_id: 1, revision_count: 0, update_date: null }, + { name: 'stratum1', description: '', survey_id: 1, survey_stratum_id: 2, revision_count: 0, update_date: null } + ]; + + const response = await repo.updateSurveyStratums(surveyId, stratums); + + expect(dbConnectionObj.knex).to.have.been.calledTwice; + expect(response).to.eql([...mockRows1, ...mockRows2]); + }); + + it('throws an error if rowCount does not equal stratums length', async () => { + const mockRows1: SurveyStratumRecord[] = [ + { + name: 'stratum1', + description: '', + survey_id: 1, + survey_stratum_id: 1, + revision_count: 1, + update_date: '2023-10-23' + } + ]; + const mockResponse1 = ({ rows: mockRows1, rowCount: 1 } as any) as Promise>; + const mockResponse2 = ({ rows: [], rowCount: 0 } as any) as Promise>; + + const dbConnectionObj = getMockDBConnection({ + knex: sinon.stub().onFirstCall().resolves(mockResponse1).onSecondCall().resolves(mockResponse2) + }); + + const repo = new SiteSelectionStrategyRepository(dbConnectionObj); + + const surveyId = 1; + + // stratums length = 2, total rowCount = 1 + const stratums: SurveyStratumRecord[] = [ + { name: 'stratum1', description: '', survey_id: 1, survey_stratum_id: 1, revision_count: 0, update_date: null }, + { name: 'stratum1', description: '', survey_id: 1, survey_stratum_id: 2, revision_count: 0, update_date: null } + ]; + + try { + await repo.updateSurveyStratums(surveyId, stratums); + } catch (error) { + expect(dbConnectionObj.knex).to.have.been.calledTwice; + expect((error as ApiExecuteSQLError).message).to.be.eql('Failed to update survey stratums'); + } + }); + }); +}); diff --git a/api/src/repositories/site-selection-strategy-repository.ts b/api/src/repositories/site-selection-strategy-repository.ts index f0d5443105..f3a0dc05a0 100644 --- a/api/src/repositories/site-selection-strategy-repository.ts +++ b/api/src/repositories/site-selection-strategy-repository.ts @@ -10,6 +10,8 @@ export const SurveyStratum = z.object({ description: z.string() }); +export type SurveyStratum = z.infer; + export const SurveyStratumRecord = z.object({ name: z.string(), description: z.string().nullable(), @@ -21,8 +23,6 @@ export const SurveyStratumRecord = z.object({ export type SurveyStratumRecord = z.infer; -export type SurveyStratum = z.infer; - export const SiteSelectionData = z.object({ strategies: z.array(z.string()), stratums: z.array(SurveyStratumRecord) @@ -56,12 +56,15 @@ export class SiteSelectionStrategyRepository extends BaseRepository { .where('sss.survey_id', surveyId) .leftJoin('site_strategy as ss', 'ss.site_strategy_id', 'sss.site_strategy_id'); - const strategiesResponse = await this.connection.knex<{ name: string }>(strategiesQuery); - const strategies = strategiesResponse.rows.map((row) => row.name); - const stratumsQuery = getKnex().select().from('survey_stratum').where('survey_id', surveyId); - const stratumsResponse = await this.connection.knex(stratumsQuery); + const [strategiesResponse, stratumsResponse] = await Promise.all([ + this.connection.knex(strategiesQuery, z.object({ name: z.string() })), + this.connection.knex(stratumsQuery, SurveyStratumRecord) + ]); + + const strategies = strategiesResponse.rows.map((row) => row.name); + const stratums = stratumsResponse.rows; return { strategies, stratums }; @@ -81,7 +84,7 @@ export class SiteSelectionStrategyRepository extends BaseRepository { DELETE FROM survey_site_strategy WHERE - survey_id = ${surveyId} + survey_id = ${surveyId} RETURNING *; `; @@ -151,10 +154,10 @@ export class SiteSelectionStrategyRepository extends BaseRepository { * Deletes the given survey stratums by ID * * @param {number[]} stratumIds - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SurveyRepository */ - async deleteSurveyStratums(stratumIds: number[]): Promise { + async deleteSurveyStratums(stratumIds: number[]): Promise { defaultLog.debug({ label: 'deleteSurveyStratums', stratumIds }); const deleteQuery = getKnex() @@ -163,7 +166,9 @@ export class SiteSelectionStrategyRepository extends BaseRepository { .whereIn('survey_stratum_id', stratumIds) .returning('*'); - await this.connection.knex(deleteQuery, SurveyStratumRecord); + const response = await this.connection.knex(deleteQuery, SurveyStratumRecord); + + return response.rowCount; } /** @@ -228,17 +233,17 @@ export class SiteSelectionStrategyRepository extends BaseRepository { stratums.map((stratum) => this.connection.knex(makeUpdateQuery(stratum), SurveyStratumRecord)) ); - const records = responses.reduce((acc: SurveyStratumRecord[], queryResult) => { - return [...acc, ...queryResult.rows]; - }, []); + const totalRowCount = responses.reduce((sum, response) => sum + response.rowCount, 0); - if (records.length !== stratums.length) { + if (totalRowCount !== stratums.length) { throw new ApiExecuteSQLError('Failed to update survey stratums', [ 'SurveyRepository->updateSurveyStratums', - `Total rowCount was ${records.length}, expected ${stratums.length} rows` + `Total rowCount was ${totalRowCount}, expected ${stratums.length} rows` ]); } - return records; + return responses.reduce((acc: SurveyStratumRecord[], queryResult) => { + return [...acc, ...queryResult.rows]; + }, []); } } diff --git a/api/src/repositories/survey-location-repository.test.ts b/api/src/repositories/survey-location-repository.test.ts new file mode 100644 index 0000000000..ff04763a3f --- /dev/null +++ b/api/src/repositories/survey-location-repository.test.ts @@ -0,0 +1,119 @@ +import chai, { expect } from 'chai'; +import { QueryResult } from 'pg'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { PostSurveyLocationData } from '../models/survey-update'; +import { getMockDBConnection } from '../__mocks__/db'; +import { SurveyLocationRepository } from './survey-location-repository'; + +chai.use(sinonChai); + +describe('SurveyLocationRepository', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('insertSurveyLocation', () => { + it('should insert a survey location', async () => { + const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const dbConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const repository = new SurveyLocationRepository(dbConnection); + + const dataObj = { + survey_location_id: 1, + name: 'Updated Test Location', + description: 'Updated Test Description', + geojson: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [1, 1] + }, + properties: {} + } + ], + revision_count: 2 + }; + + const data = new PostSurveyLocationData(dataObj); + + await repository.insertSurveyLocation(1, data); + expect(dbConnection.sql).to.have.been.calledOnce; + }); + }); + + describe('updateSurveyLocation', () => { + it('should update a survey location', async () => { + const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const dbConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const repository = new SurveyLocationRepository(dbConnection); + + const dataObj = { + survey_location_id: 1, + name: 'Updated Test Location', + description: 'Updated Test Description', + geojson: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [1, 1] + }, + properties: {} + } + ], + revision_count: 2 + }; + + const data = new PostSurveyLocationData(dataObj); + + await repository.updateSurveyLocation(data); + expect(dbConnection.sql).to.have.been.calledOnce; + }); + }); + + describe('getSurveyLocationsData', () => { + it('should return a list of survey locations', async () => { + const mockSurveyLocation = { survey_location_id: 1, name: 'Test Location', description: 'Test Description' }; + const mockResponse = ({ rows: [mockSurveyLocation], rowCount: 1 } as any) as Promise>; + const dbConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const repository = new SurveyLocationRepository(dbConnection); + + const response = await repository.getSurveyLocationsData(1); + + expect(response).to.eql([mockSurveyLocation]); + expect(dbConnection.sql).to.have.been.calledOnce; + }); + }); + + describe('deleteSurveyLocation', () => { + it('should delete a survey location', async () => { + const mockSurveyLocation = { survey_location_id: 1, name: 'Test Location', description: 'Test Description' }; + const mockResponse = ({ rows: [mockSurveyLocation], rowCount: 1 } as any) as Promise>; + const dbConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const repository = new SurveyLocationRepository(dbConnection); + const response = await repository.deleteSurveyLocation(1); + + expect(response).to.eql(mockSurveyLocation); + expect(dbConnection.sql).to.have.been.calledOnce; + }); + + it('should throw an error when unable to delete the survey location', async () => { + const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const dbConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const repository = new SurveyLocationRepository(dbConnection); + + try { + await repository.deleteSurveyLocation(1); + } catch (error) { + expect(error).to.exist; + } + }); + }); +}); diff --git a/api/src/repositories/survey-location-repository.ts b/api/src/repositories/survey-location-repository.ts index 67616bb327..8445c55d99 100644 --- a/api/src/repositories/survey-location-repository.ts +++ b/api/src/repositories/survey-location-repository.ts @@ -1,9 +1,9 @@ import SQL from 'sql-template-strings'; import { z } from 'zod'; -import { PostLocationData } from '../models/survey-create'; -import { PutSurveyLocationData } from '../models/survey-update'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { PostSurveyLocationData } from '../models/survey-update'; import { generateGeometryCollectionSQL } from '../utils/spatial-utils'; -import { jsonSchema } from '../zod-schema/json'; +import { shallowJsonSchema } from '../zod-schema/json'; import { BaseRepository } from './base-repository'; export const SurveyLocationRecord = z.object({ @@ -12,7 +12,7 @@ export const SurveyLocationRecord = z.object({ description: z.string(), geometry: z.record(z.any()).nullable(), geography: z.string(), - geojson: jsonSchema, + geojson: shallowJsonSchema, revision_count: z.number() }); @@ -22,10 +22,10 @@ export class SurveyLocationRepository extends BaseRepository { * Creates a survey location for a given survey * * @param {number} surveyId - * @param {PostLocationData} data + * @param {PostSurveyLocationData} data * @memberof SurveyLocationRepository */ - async insertSurveyLocation(surveyId: number, data: PostLocationData): Promise { + async insertSurveyLocation(surveyId: number, data: PostSurveyLocationData): Promise { const sqlStatement = SQL` INSERT INTO survey_location ( survey_id, @@ -51,10 +51,10 @@ export class SurveyLocationRepository extends BaseRepository { /** * Updates survey location data * - * @param {PutSurveyLocationData} data + * @param {PostSurveyLocationData} data * @memberof SurveyLocationRepository */ - async updateSurveyLocation(data: PutSurveyLocationData): Promise { + async updateSurveyLocation(data: PostSurveyLocationData): Promise { const sqlStatement = SQL` UPDATE survey_location @@ -94,4 +94,26 @@ export class SurveyLocationRepository extends BaseRepository { const response = await this.connection.sql(sqlStatement, SurveyLocationRecord); return response.rows; } + + /** + * Deletes a survey location for a given survey location id + * + * @param surveyLocationId + * @returns {*} Promise + * @memberof SurveyLocationRepository + */ + async deleteSurveyLocation(surveyLocationId: number): Promise { + const sql = SQL` + DELETE FROM survey_location WHERE survey_location_id = ${surveyLocationId} RETURNING *;`; + const response = await this.connection.sql(sql, SurveyLocationRecord); + + if (!response?.rowCount) { + throw new ApiExecuteSQLError('Failed to delete survey location record', [ + 'SurveyLocationRepository->deleteSurveyLocation', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0]; + } } diff --git a/api/src/repositories/survey-repository.test.ts b/api/src/repositories/survey-repository.test.ts index 842f8c3c80..fcd05cac59 100644 --- a/api/src/repositories/survey-repository.test.ts +++ b/api/src/repositories/survey-repository.test.ts @@ -466,9 +466,7 @@ describe('SurveyRepository', () => { survey_types: [1, 2] }, purpose_and_methodology: { - field_method_id: 1, additional_details: '', - ecological_season_id: 1, intended_outcome_id: 1, surveyed_all_areas: 'Y' }, @@ -494,9 +492,7 @@ describe('SurveyRepository', () => { survey_types: [1, 2] }, purpose_and_methodology: { - field_method_id: 1, additional_details: '', - ecological_season_id: 1, intended_outcome_id: 1, surveyed_all_areas: 'Y' }, @@ -522,9 +518,7 @@ describe('SurveyRepository', () => { survey_types: [1, 2] }, purpose_and_methodology: { - field_method_id: 1, additional_details: '', - ecological_season_id: 1, intended_outcome_id: 1, surveyed_all_areas: 'Y' }, @@ -848,9 +842,7 @@ describe('SurveyRepository', () => { revision_count: 1 }, purpose_and_methodology: { - field_method_id: 1, additional_details: '', - ecological_season_id: 1, intended_outcome_id: 1, surveyed_all_areas: 'Y', revision_count: 1 @@ -877,9 +869,7 @@ describe('SurveyRepository', () => { revision_count: 1 }, purpose_and_methodology: { - field_method_id: 1, additional_details: '', - ecological_season_id: 1, intended_outcome_id: 1, surveyed_all_areas: 'Y', revision_count: 1 @@ -906,9 +896,7 @@ describe('SurveyRepository', () => { revision_count: 1 }, purpose_and_methodology: { - field_method_id: 1, additional_details: '', - ecological_season_id: 1, intended_outcome_id: 1, surveyed_all_areas: 'Y', revision_count: 1 @@ -1053,4 +1041,26 @@ describe('SurveyRepository', () => { } }); }); + + describe('insertManySurveyIntendedOutcomes', () => { + it('should insert intended outcome ids', async () => { + const mockResponse = ({ rowCount: 0 } as any) as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new SurveyRepository(dbConnection); + const repsonse = await repository.insertManySurveyIntendedOutcomes(1, [1, 2]); + expect(repsonse).to.be.undefined; + }); + }); + + describe('deleteManySurveyIntendedOutcomes', () => { + it('should delete intended outcome ids', async () => { + const mockResponse = ({ rowCount: 0 } as any) as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new SurveyRepository(dbConnection); + const repsonse = await repository.deleteManySurveyIntendedOutcomes(1, [1, 2]); + expect(repsonse).to.be.undefined; + }); + }); }); diff --git a/api/src/repositories/survey-repository.ts b/api/src/repositories/survey-repository.ts index ece23b87eb..26f465da15 100644 --- a/api/src/repositories/survey-repository.ts +++ b/api/src/repositories/survey-repository.ts @@ -79,10 +79,7 @@ const SurveyRecord = z.object({ uuid: z.string().nullable(), start_date: z.string(), end_date: z.string().nullable(), - field_method_id: z.number().nullable(), additional_details: z.string().nullable(), - ecological_season_id: z.number().nullable(), - intended_outcome_id: z.number().nullable(), comments: z.string().nullable(), create_date: z.string(), create_user: z.number(), @@ -122,6 +119,17 @@ export type StakeholderPartnershipRecord = z.infer; +export const SurveyBasicFields = z.object({ + survey_id: z.number(), + name: z.string(), + start_date: z.string(), + end_date: z.string().nullable(), + focal_species: z.array(z.number()), + focal_species_names: z.array(z.string()) +}); + +export type SurveyBasicFields = z.infer; + const defaultLog = getLogger('repositories/survey-repository'); export class SurveyRepository extends BaseRepository { @@ -242,24 +250,24 @@ export class SurveyRepository extends BaseRepository { async getSurveyPurposeAndMethodology(surveyId: number): Promise { const sqlStatement = SQL` SELECT - s.field_method_id, s.additional_details, - s.ecological_season_id, - s.intended_outcome_id, - array_remove(array_agg(sv.vantage_id), NULL) as vantage_ids + 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 + FROM survey s LEFT OUTER JOIN survey_vantage sv ON sv.survey_id = s.survey_id + LEFT OUTER JOIN + survey_intended_outcome io + ON + io.survey_id = s.survey_id WHERE s.survey_id = ${surveyId} GROUP BY - s.field_method_id, - s.additional_details, - s.ecological_season_id, - s.intended_outcome_id; + s.additional_details `; const response = await this.connection.sql(sqlStatement); @@ -566,6 +574,40 @@ export class SurveyRepository extends BaseRepository { return new GetReportAttachmentsData(result); } + /** + * Fetches a subset of survey fields for all surveys under a project. + * + * @param {number} projectId + * @return {*} {Promise[]>} + * @memberof SurveyRepository + */ + async getSurveysBasicFieldsByProjectId(projectId: number): Promise[]> { + const knex = getKnex(); + + const queryBuilder = knex + .queryBuilder() + .select( + 'survey.survey_id', + 'survey.name', + 'survey.start_date', + 'survey.end_date', + knex.raw('array_remove(array_agg(study_species.wldtaxonomic_units_id), NULL) AS focal_species') + ) + .from('project') + .leftJoin('survey', 'survey.project_id', 'project.project_id') + .leftJoin('study_species', 'study_species.survey_id', 'survey.survey_id') + .where('project.project_id', projectId) + .where('study_species.is_focal', true) + .groupBy('survey.survey_id') + .groupBy('survey.name') + .groupBy('survey.start_date') + .groupBy('survey.end_date'); + + const response = await this.connection.knex(queryBuilder, SurveyBasicFields.omit({ focal_species_names: true })); + + return response.rows; + } + /** * Inserts a new survey record and returns the new ID * @@ -581,19 +623,13 @@ export class SurveyRepository extends BaseRepository { name, start_date, end_date, - field_method_id, - additional_details, - ecological_season_id, - intended_outcome_id + additional_details ) VALUES ( ${projectId}, ${surveyData.survey_details.survey_name}, ${surveyData.survey_details.start_date}, ${surveyData.survey_details.end_date}, - ${surveyData.purpose_and_methodology.field_method_id}, - ${surveyData.purpose_and_methodology.additional_details}, - ${surveyData.purpose_and_methodology.ecological_season_id}, - ${surveyData.purpose_and_methodology.intended_outcome_id} + ${surveyData.purpose_and_methodology.additional_details} ) RETURNING survey_id as id; @@ -739,6 +775,40 @@ export class SurveyRepository extends BaseRepository { return result.id; } + /** + * Insert many rows associating a survey id to various intended outcome ids. + * + * @param {number} surveyId + * @param {number[]} intendedOutcomeIds + */ + async insertManySurveyIntendedOutcomes(surveyId: number, intendedOutcomeIds: number[]) { + const queryBuilder = getKnex().queryBuilder(); + if (intendedOutcomeIds.length) { + queryBuilder + .insert(intendedOutcomeIds.map((outcomeId) => ({ survey_id: surveyId, intended_outcome_id: outcomeId }))) + .into('survey_intended_outcome'); + await this.connection.knex(queryBuilder); + } + } + + /** + * Delete many rows associating a survey id to various intended outcome ids. + * + * @param {number} surveyId + * @param {number[]} intendedOutcomeIds + */ + async deleteManySurveyIntendedOutcomes(surveyId: number, intendedOutcomeIds: number[]) { + const queryBuilder = getKnex().queryBuilder(); + if (intendedOutcomeIds.length) { + queryBuilder + .delete() + .from('survey_intended_outcome') + .whereIn('intended_outcome_id', intendedOutcomeIds) + .andWhere('survey_id', surveyId); + await this.connection.knex(queryBuilder); + } + } + /** * Inserts a new Survey Proprietor record and returns the new ID * @@ -892,10 +962,7 @@ export class SurveyRepository extends BaseRepository { if (surveyData.purpose_and_methodology) { fieldsToUpdate = { ...fieldsToUpdate, - field_method_id: surveyData.purpose_and_methodology.field_method_id, - additional_details: surveyData.purpose_and_methodology.additional_details, - ecological_season_id: surveyData.purpose_and_methodology.ecological_season_id, - intended_outcome_id: surveyData.purpose_and_methodology.intended_outcome_id + additional_details: surveyData.purpose_and_methodology.additional_details }; } diff --git a/api/src/repositories/telemetry-repository.ts b/api/src/repositories/telemetry-repository.ts new file mode 100644 index 0000000000..012f3f167e --- /dev/null +++ b/api/src/repositories/telemetry-repository.ts @@ -0,0 +1,86 @@ +import SQL from 'sql-template-strings'; +import { z } from 'zod'; +import { getKnex } from '../database/db'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { getLogger } from '../utils/logger'; +import { BaseRepository } from './base-repository'; + +const defaultLog = getLogger('repositories/telemetry-repository'); + +/** + * Interface reflecting survey telemetry retrieved from the database + */ +export const TelemetrySubmissionRecord = z.object({ + survey_telemetry_submission_id: z.number(), + survey_id: z.number(), + key: z.string(), + original_filename: z.string(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable() +}); + +export type TelemetrySubmissionRecord = z.infer; + +export class TelemetryRepository extends BaseRepository { + async insertSurveyTelemetrySubmission( + submission_id: number, + key: string, + survey_id: number, + original_filename: string + ): Promise { + defaultLog.debug({ label: 'insertSurveyTelemetrySubmission' }); + const sqlStatement = SQL` + INSERT INTO + survey_telemetry_submission + (survey_telemetry_submission_id, key, survey_id, original_filename) + VALUES + (${submission_id}, ${key}, ${survey_id}, ${original_filename}) + RETURNING *;`; + + const response = await this.connection.sql(sqlStatement, TelemetrySubmissionRecord); + + return response.rows[0]; + } + + /** + * Retrieves the next submission ID from the survey_telemetry_submission_id_seq sequence + * + * @return {*} {Promise} + * @memberof TelemetryRepository + */ + async getNextSubmissionId(): Promise { + const sqlStatement = SQL` + SELECT nextval('biohub.survey_telemetry_submission_id_seq')::integer as survey_telemetry_submission; + `; + const response = await this.connection.sql<{ survey_telemetry_submission: number }>(sqlStatement); + return response.rows[0].survey_telemetry_submission; + } + + /** + * Retrieves the telemetry submission record by the given submission ID. + * + * @param {number} submissionId + * @return {*} {Promise} + * @memberof TelemetryRepository + */ + async getTelemetrySubmissionById(submissionId: number): Promise { + const queryBuilder = getKnex() + .queryBuilder() + .select('*') + .from('survey_telemetry_submission') + .where('survey_telemetry_submission_id', submissionId); + + const response = await this.connection.knex(queryBuilder, TelemetrySubmissionRecord); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to get telemetry submission', [ + 'TelemetryRepository->getTelemetrySubmissionById', + 'rowCount was null or undefined, expected rowCount = 1' + ]); + } + + return response.rows[0]; + } +} diff --git a/api/src/repositories/user-repository.ts b/api/src/repositories/user-repository.ts index 7a82e47d20..ae9e27195c 100644 --- a/api/src/repositories/user-repository.ts +++ b/api/src/repositories/user-repository.ts @@ -161,7 +161,7 @@ export class UserRepository extends BaseRepository { ON uis.user_identity_source_id = su.user_identity_source_id WHERE - su.user_guid = ${userGuid} + LOWER(su.user_guid) = LOWER(${userGuid}) GROUP BY su.system_user_id, su.record_end_date, @@ -219,7 +219,7 @@ export class UserRepository extends BaseRepository { ON uis.user_identity_source_id = su.user_identity_source_id WHERE - su.user_identifier = ${userIdentifier.toLowerCase()} + LOWER(su.user_identifier) = ${userIdentifier.toLowerCase()} AND uis.name = ${identitySource.toUpperCase()} GROUP BY @@ -285,7 +285,7 @@ export class UserRepository extends BaseRepository { WHERE name = ${identitySource.toUpperCase()} ), - ${userIdentifier.toLowerCase()}, + ${userIdentifier}, ${displayName}, ${givenName || null}, ${familyName || null}, diff --git a/api/src/repositories/validation-repository.test.ts b/api/src/repositories/validation-repository.test.ts index 78d7a2057c..7f6bf8e326 100644 --- a/api/src/repositories/validation-repository.test.ts +++ b/api/src/repositories/validation-repository.test.ts @@ -24,7 +24,6 @@ describe('ValidationRepository', () => { const templateName = 'template Name'; const templateVersion = '1'; - const fieldMethodId = 10; const surveySpecies = [10]; const mockResponse = ({ @@ -43,12 +42,7 @@ describe('ValidationRepository', () => { }); const repo = new ValidationRepository(dbConnection); - const response = await repo.getTemplateMethodologySpeciesRecord( - templateName, - templateVersion, - fieldMethodId, - surveySpecies - ); + const response = await repo.getTemplateMethodologySpeciesRecord(templateName, templateVersion, surveySpecies); expect(response.template_methodology_species_id).to.be.eql(1); expect(response.validation).to.be.eql('{}'); expect(response.transform).to.be.eql('{}'); @@ -65,7 +59,7 @@ describe('ValidationRepository', () => { const repo = new ValidationRepository(dbConnection); try { - await repo.getTemplateMethodologySpeciesRecord('name', 'version', 1, [1]); + await repo.getTemplateMethodologySpeciesRecord('name', 'version', [1]); expect.fail(); } catch (error) { expect((error as HTTP400).message).to.be.eql('Failed to query template methodology species table'); diff --git a/api/src/repositories/validation-repository.ts b/api/src/repositories/validation-repository.ts index 6fe4c4b21d..c04bc77f28 100644 --- a/api/src/repositories/validation-repository.ts +++ b/api/src/repositories/validation-repository.ts @@ -27,7 +27,6 @@ export class ValidationRepository extends BaseRepository { async getTemplateMethodologySpeciesRecord( templateName: string, templateVersion: string, - surveyFieldMethodId: number, surveySpecies: number[] ): Promise { const templateRow = await this.getTemplateNameVersionId(templateName, templateVersion); @@ -50,11 +49,7 @@ export class ValidationRepository extends BaseRepository { const queryBuilder = getKnex() .select('template_methodology_species_id', 'wldtaxonomic_units_id', 'validation', 'transform') .from('template_methodology_species') - .where('template_id', templateRow.template_id) - .and.where(function (qb) { - qb.or.where('field_method_id', surveyFieldMethodId); - qb.or.where('field_method_id', null); - }); + .where('template_id', templateRow.template_id); const response = await this.connection.knex(queryBuilder); diff --git a/api/src/services/authorization-service.test.ts b/api/src/services/authorization-service.test.ts index 70ed6f091a..e8e731a080 100644 --- a/api/src/services/authorization-service.test.ts +++ b/api/src/services/authorization-service.test.ts @@ -16,6 +16,7 @@ import { AuthorizeRule } from '../services/authorization-service'; import { UserService } from '../services/user-service'; +import { KeycloakUserInformation, ServiceClientUserInformation } from '../utils/keycloak-utils'; import { getMockDBConnection } from '../__mocks__/db'; import { ProjectParticipationService } from './project-participation-service'; @@ -312,7 +313,7 @@ describe('AuthorizationService', () => { sinon.stub(db, 'getDBConnection').returns(mockDBConnection); const authorizationService = new AuthorizationService(mockDBConnection, { - keycloakToken: { preferred_username: '' } + keycloakToken: { preferred_username: '' } as KeycloakUserInformation }); const authorizeByServiceClientData = ({ @@ -349,7 +350,7 @@ describe('AuthorizationService', () => { sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockGetSystemUsersObjectResponse); const authorizationService = new AuthorizationService(mockDBConnection, { - keycloakToken: { clientId: SOURCE_SYSTEM['SIMS-SVC-4464'] } + keycloakToken: { clientId: SOURCE_SYSTEM['SIMS-SVC-4464'] } as ServiceClientUserInformation }); const authorizeByServiceClientData = ({ @@ -671,19 +672,6 @@ describe('AuthorizationService', () => { expect(result).to.be.null; }); - it('returns null if the keycloak token is not a valid format (fails the parser)', async function () { - const mockDBConnection = getMockDBConnection(); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - const authorizationService = new AuthorizationService(mockDBConnection, { - keycloakToken: { not: '', valid: '' } - }); - - const result = await authorizationService.getSystemUserWithRoles(); - - expect(result).to.be.null; - }); - it('returns a UserObject', async function () { const mockDBConnection = getMockDBConnection(); sinon.stub(db, 'getDBConnection').returns(mockDBConnection); @@ -830,21 +818,6 @@ describe('AuthorizationService', () => { expect(result).to.be.null; }); - it('returns null if the keycloak token is not a valid format (fails the parser)', async function () { - const mockDBConnection = getMockDBConnection(); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - const authorizationService = new AuthorizationService(mockDBConnection, { - keycloakToken: { not: '', valid: '' } - }); - - const projectId = 1; - - const result = await authorizationService.getProjectUserWithRoles(projectId); - - expect(result).to.be.null; - }); - it('returns a project user when keycloak token is valid', async function () { const mockDBConnection = getMockDBConnection(); sinon.stub(db, 'getDBConnection').returns(mockDBConnection); diff --git a/api/src/services/authorization-service.ts b/api/src/services/authorization-service.ts index 4ed0666189..a5179271e8 100644 --- a/api/src/services/authorization-service.ts +++ b/api/src/services/authorization-service.ts @@ -3,7 +3,7 @@ import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../constants/roles'; import { IDBConnection } from '../database/db'; import { ProjectUser } from '../repositories/project-participation-repository'; import { SystemUser } from '../repositories/user-repository'; -import { getKeycloakSource, getKeycloakUserInformationFromKeycloakToken, getUserGuid } from '../utils/keycloak-utils'; +import { getKeycloakSource, getUserGuid, KeycloakUserInformation } from '../utils/keycloak-utils'; import { DBService } from './db-service'; import { ProjectParticipationService } from './project-participation-service'; import { UserService } from './user-service'; @@ -76,11 +76,11 @@ export class AuthorizationService extends DBService { _projectParticipationService = new ProjectParticipationService(this.connection); _systemUser: SystemUser | undefined = undefined; _projectUser: (ProjectUser & SystemUser) | undefined = undefined; - _keycloakToken: object | undefined = undefined; + _keycloakToken: KeycloakUserInformation | undefined = undefined; constructor( connection: IDBConnection, - init?: { systemUser?: SystemUser; projectUser?: ProjectUser & SystemUser; keycloakToken?: object } + init?: { systemUser?: SystemUser; projectUser?: ProjectUser & SystemUser; keycloakToken?: KeycloakUserInformation } ) { super(connection); @@ -324,13 +324,7 @@ export class AuthorizationService extends DBService { return null; } - const keycloakUserInformation = getKeycloakUserInformationFromKeycloakToken(this._keycloakToken); - - if (!keycloakUserInformation) { - return null; - } - - const userGuid = getUserGuid(keycloakUserInformation); + const userGuid = getUserGuid(this._keycloakToken); return this._userService.getUserByGuid(userGuid); } @@ -368,13 +362,7 @@ export class AuthorizationService extends DBService { return null; } - const keycloakUserInformation = getKeycloakUserInformationFromKeycloakToken(this._keycloakToken); - - if (!keycloakUserInformation) { - return null; - } - - const userGuid = getUserGuid(keycloakUserInformation); + const userGuid = getUserGuid(this._keycloakToken); return this._projectParticipationService.getProjectParticipantByUserGuid(projectId, userGuid); } diff --git a/api/src/services/bctw-service.test.ts b/api/src/services/bctw-service.test.ts index d0687ad0fc..971a2cf6b3 100755 --- a/api/src/services/bctw-service.test.ts +++ b/api/src/services/bctw-service.test.ts @@ -21,8 +21,11 @@ import { HEALTH_ENDPOINT, IDeployDevice, IDeploymentUpdate, + MANUAL_AND_VENDOR_TELEMETRY, + MANUAL_TELEMETRY, UPDATE_DEPLOYMENT_ENDPOINT, - UPSERT_DEVICE_ENDPOINT + UPSERT_DEVICE_ENDPOINT, + VENDOR_TELEMETRY } from './bctw-service'; import { KeycloakService } from './keycloak-service'; @@ -320,5 +323,88 @@ describe('BctwService', () => { 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 index 2e00febcad..d0a8267bd4 100644 --- a/api/src/services/bctw-service.ts +++ b/api/src/services/bctw-service.ts @@ -1,4 +1,5 @@ import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; +import { Request } from 'express'; import FormData from 'form-data'; import { URLSearchParams } from 'url'; import { z } from 'zod'; @@ -85,6 +86,16 @@ export const IKeyXDetails = z.object({ export type IKeyXDetails = 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 IBctwUser = z.object({ keycloak_guid: z.string(), username: z.string() @@ -119,6 +130,15 @@ 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'; + +export const getBctwUser = (req: Request): IBctwUser => ({ + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier +}); export class BctwService { user: IBctwUser; @@ -151,12 +171,14 @@ export class BctwService { new HTTP500('Connection to the BCTW API server was refused. Please try again later.', [error?.message]) ); } + const data = 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}, ${error.response?.data}`, - error?.request?.data + `API request failed with status code ${error?.response?.status}, ${errMsg}`, + Array.isArray(errMsg) ? errMsg : [errMsg] ) ); } @@ -419,4 +441,84 @@ export class BctwService { 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 {Omit} payload + * + * @returns {*} IManualTelemetry[] + **/ + async createManualTelemetry(payload: Omit[]): 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 c7d6f47cb5..102a5db7cf 100644 --- a/api/src/services/code-service.test.ts +++ b/api/src/services/code-service.test.ts @@ -39,8 +39,6 @@ describe('CodeService', () => { 'system_roles', 'project_roles', 'administrative_activity_status_type', - 'ecological_seasons', - 'field_methods', 'intended_outcomes', 'vantage_codes', 'site_selection_strategies', diff --git a/api/src/services/code-service.ts b/api/src/services/code-service.ts index 1ce27d4a7a..7e3f501779 100644 --- a/api/src/services/code-service.ts +++ b/api/src/services/code-service.ts @@ -36,8 +36,6 @@ export class CodeService extends DBService { system_roles, project_roles, administrative_activity_status_type, - field_methods, - ecological_seasons, intended_outcomes, vantage_codes, survey_jobs, @@ -57,8 +55,6 @@ export class CodeService extends DBService { await this.codeRepository.getSystemRoles(), await this.codeRepository.getProjectRoles(), await this.codeRepository.getAdministrativeActivityStatusType(), - await this.codeRepository.getFieldMethods(), - await this.codeRepository.getEcologicalSeasons(), await this.codeRepository.getIntendedOutcomes(), await this.codeRepository.getVantageCodes(), await this.codeRepository.getSurveyJobs(), @@ -80,8 +76,6 @@ export class CodeService extends DBService { system_roles, project_roles, administrative_activity_status_type, - field_methods, - ecological_seasons, intended_outcomes, vantage_codes, survey_jobs, diff --git a/api/src/services/eml-service.test.ts b/api/src/services/eml-service.test.ts index a35bc095b7..9f3fc0e8cf 100644 --- a/api/src/services/eml-service.test.ts +++ b/api/src/services/eml-service.test.ts @@ -122,33 +122,6 @@ describe('EmlPackage', () => { } ]); - sinon.stub(EmlService.prototype, '_getProjectGeographicCoverage').returns({ - geographicCoverage: { - geographicDescription: 'Location Description', - boundingCoordinates: { - westBoundingCoordinate: -121.904297, - eastBoundingCoordinate: -120.19043, - northBoundingCoordinate: 51.971346, - southBoundingCoordinate: 50.930738 - }, - datasetGPolygon: [ - { - datasetGPolygonOuterGRing: [ - { - gRingPoint: [ - { gRingLatitude: 50.930738, gRingLongitude: -121.904297 }, - { gRingLatitude: 51.971346, gRingLongitude: -121.904297 }, - { gRingLatitude: 51.971346, gRingLongitude: -120.19043 }, - { gRingLatitude: 50.930738, gRingLongitude: -120.19043 }, - { gRingLatitude: 50.930738, gRingLongitude: -121.904297 } - ] - } - ] - } - ] - } - }); - sinon.stub(EmlService.prototype, '_getProjectTemporalCoverage').returns({ rangeOfDates: { beginDate: { calendarDate: '2023-01-01' }, @@ -156,7 +129,7 @@ describe('EmlPackage', () => { } }); - const response = emlPackage.withProject(emlService._buildProjectEmlProjectSection(mockProjectData)); + const response = emlPackage.withProject(emlService._buildProjectEmlProjectSection(mockProjectData, [])); expect(response._projectMetadata).to.eql(emlPackage._projectMetadata); expect(response._projectMetadata).to.eql({ @@ -175,30 +148,6 @@ describe('EmlPackage', () => { }, studyAreaDescription: { coverage: { - geographicCoverage: { - geographicDescription: 'Location Description', - boundingCoordinates: { - westBoundingCoordinate: -121.904297, - eastBoundingCoordinate: -120.19043, - northBoundingCoordinate: 51.971346, - southBoundingCoordinate: 50.930738 - }, - datasetGPolygon: [ - { - datasetGPolygonOuterGRing: [ - { - gRingPoint: [ - { gRingLatitude: 50.930738, gRingLongitude: -121.904297 }, - { gRingLatitude: 51.971346, gRingLongitude: -121.904297 }, - { gRingLatitude: 51.971346, gRingLongitude: -120.19043 }, - { gRingLatitude: 50.930738, gRingLongitude: -120.19043 }, - { gRingLatitude: 50.930738, gRingLongitude: -121.904297 } - ] - } - ] - } - ] - }, temporalCoverage: { rangeOfDates: { beginDate: { calendarDate: '2023-01-01' }, @@ -632,10 +581,6 @@ describe.skip('EmlService', () => { title: 'Field Method', para: 'Call Playback' }, - { - title: 'Ecological Season', - para: 'Spring' - }, { title: 'Vantage Codes', para: { @@ -986,8 +931,6 @@ describe.skip('EmlService', () => { system_roles: [], project_roles: [], administrative_activity_status_type: [], - ecological_seasons: [], - field_methods: [], intended_outcomes: [], vantage_codes: [], site_selection_strategies: [], @@ -1228,33 +1171,6 @@ describe.skip('EmlService', () => { } ]); - sinon.stub(EmlService.prototype, '_getProjectGeographicCoverage').returns({ - geographicCoverage: { - geographicDescription: 'Location Description', - boundingCoordinates: { - westBoundingCoordinate: -121.904297, - eastBoundingCoordinate: -120.19043, - northBoundingCoordinate: 51.971346, - southBoundingCoordinate: 50.930738 - }, - datasetGPolygon: [ - { - datasetGPolygonOuterGRing: [ - { - gRingPoint: [ - { gRingLatitude: 50.930738, gRingLongitude: -121.904297 }, - { gRingLatitude: 51.971346, gRingLongitude: -121.904297 }, - { gRingLatitude: 51.971346, gRingLongitude: -120.19043 }, - { gRingLatitude: 50.930738, gRingLongitude: -120.19043 }, - { gRingLatitude: 50.930738, gRingLongitude: -121.904297 } - ] - } - ] - } - ] - } - }); - sinon.stub(EmlService.prototype, '_getProjectTemporalCoverage').returns({ rangeOfDates: { beginDate: { calendarDate: '2023-01-01' }, @@ -1262,7 +1178,7 @@ describe.skip('EmlService', () => { } }); - const response = emlService._buildProjectEmlProjectSection(mockProjectData); + const response = emlService._buildProjectEmlProjectSection(mockProjectData, []); expect(response).to.eql({ $: { id: 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii', system: '' }, @@ -1338,8 +1254,6 @@ describe.skip('EmlService', () => { } ]); - sinon.stub(EmlService.prototype, '_getProjectGeographicCoverage').returns({}); - sinon.stub(EmlService.prototype, '_getProjectTemporalCoverage').returns({ rangeOfDates: { beginDate: { calendarDate: '2023-01-01' }, @@ -1347,7 +1261,7 @@ describe.skip('EmlService', () => { } }); - const response = emlService._buildProjectEmlProjectSection(mockProjectData); + const response = emlService._buildProjectEmlProjectSection(mockProjectData, []); expect(response).to.eql({ $: { id: 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii', system: '' }, diff --git a/api/src/services/eml-service.ts b/api/src/services/eml-service.ts index 1ab6441a1a..3583050404 100644 --- a/api/src/services/eml-service.ts +++ b/api/src/services/eml-service.ts @@ -142,7 +142,7 @@ export class EmlPackage { * @return {*} * @memberof EmlPackage */ - withEml(emlMetadata: Record): EmlPackage { + withEml(emlMetadata: Record): this { this._emlMetadata = emlMetadata; return this; @@ -155,7 +155,7 @@ export class EmlPackage { * @return {*} * @memberof EmlPackage */ - withDataset(datasetMetadata: Record): EmlPackage { + withDataset(datasetMetadata: Record): this { this._datasetMetadata = datasetMetadata; return this; @@ -168,7 +168,7 @@ export class EmlPackage { * @return {*} * @memberof EmlPackage */ - withProject(projectMetadata: Record): EmlPackage { + withProject(projectMetadata: Record): this { this._projectMetadata = projectMetadata; return this; @@ -181,7 +181,7 @@ export class EmlPackage { * @return {*} * @memberof EmlPackage */ - withAdditionalMetadata(additionalMetadata: AdditionalMetadata[]): EmlPackage { + withAdditionalMetadata(additionalMetadata: AdditionalMetadata[]): this { additionalMetadata.forEach((meta) => this._additionalMetadata.push(meta)); return this; @@ -194,7 +194,7 @@ export class EmlPackage { * @return {*} * @memberof EmlPackage */ - withRelatedProjects(relatedProjects: Record[]): EmlPackage { + withRelatedProjects(relatedProjects: Record[]): this { relatedProjects.forEach((project) => this._relatedProjects.push(project)); return this; @@ -206,7 +206,7 @@ export class EmlPackage { * @return {*} {EmlPackage} * @memberof EmlPackage */ - build(): EmlPackage { + build(): this { if (this._data) { // Support subsequent compilations this._data = {}; @@ -328,7 +328,7 @@ export class EmlService extends DBService { .withDataset(this._buildProjectEmlDatasetSection(packageId, projectData)) // Build EML->Dataset->Project field - .withProject(this._buildProjectEmlProjectSection(projectData)) + .withProject(this._buildProjectEmlProjectSection(projectData, surveysData)) // Build EML->Dataset->Project->AdditionalMetadata field .withAdditionalMetadata(await this._getProjectAdditionalMetadata(projectData)) @@ -378,7 +378,7 @@ export class EmlService extends DBService { .withAdditionalMetadata(await this._getSurveyAdditionalMetadata([surveyData])) // Build EML->Dataset->Project->RelatedProject field// - .withRelatedProjects([this._buildProjectEmlProjectSection(projectData)]) + .withRelatedProjects([this._buildProjectEmlProjectSection(projectData, [surveyData])]) // Compile the EML package .build() @@ -508,7 +508,7 @@ export class EmlService extends DBService { * @return {*} {Record} * @memberof EmlService */ - _buildProjectEmlProjectSection(projectData: IGetProject): Record { + _buildProjectEmlProjectSection(projectData: IGetProject, surveys: SurveyObject[]): Record { return { $: { id: projectData.project.uuid, system: EMPTY_STRING }, title: projectData.project.project_name, @@ -518,7 +518,7 @@ export class EmlService extends DBService { }, studyAreaDescription: { coverage: { - ...this._getProjectGeographicCoverage(projectData), + ...this._getProjectGeographicCoverage(surveys), temporalCoverage: this._getProjectTemporalCoverage(projectData) } } @@ -873,32 +873,21 @@ export class EmlService extends DBService { }); } - /** - * Creates an object representing geographic coverage pertaining to the given project - * - * @param {IGetProject} projectData - * @return {*} {Record} - * @memberof EmlService - */ - _getProjectGeographicCoverage(projectData: IGetProject): Record { - if (!projectData.location.geometry) { - return {}; - } - - const polygonFeatures = this._makePolygonFeatures(projectData.location.geometry); - const datasetGPolygons = this._makeDatasetGPolygons(polygonFeatures); - const projectBoundingBox = bbox(featureCollection(polygonFeatures)); + _getBoundingBoxForFeatures(description: string, features: Feature[]): Record { + const polygonFeatures = this._makePolygonFeatures(features); + const datasetPolygons = this._makeDatasetGPolygons(polygonFeatures); + const boundingBox = bbox(featureCollection(polygonFeatures)); return { geographicCoverage: { - geographicDescription: projectData.location.location_description || NOT_SUPPLIED, + geographicDescription: description, boundingCoordinates: { - westBoundingCoordinate: projectBoundingBox[0], - eastBoundingCoordinate: projectBoundingBox[2], - northBoundingCoordinate: projectBoundingBox[3], - southBoundingCoordinate: projectBoundingBox[1] + westBoundingCoordinate: boundingBox[0], + eastBoundingCoordinate: boundingBox[2], + northBoundingCoordinate: boundingBox[3], + southBoundingCoordinate: boundingBox[1] }, - datasetGPolygon: datasetGPolygons + datasetGPolygon: datasetPolygons } }; } @@ -911,28 +900,39 @@ export class EmlService extends DBService { * @memberof EmlService */ _getSurveyGeographicCoverage(surveyData: SurveyObject): Record { - if (!surveyData.locations[0]?.geometry?.length) { + if (!surveyData.locations?.length) { return {}; } - const polygonFeatures = this._makePolygonFeatures( - surveyData.locations[0].geometry as Feature[] - ); - const datasetGPolygons = this._makeDatasetGPolygons(polygonFeatures); - const surveyBoundingBox = bbox(featureCollection(polygonFeatures)); + let features: Feature[] = []; - return { - geographicCoverage: { - geographicDescription: surveyData.locations[0].name, - boundingCoordinates: { - westBoundingCoordinate: surveyBoundingBox[0], - eastBoundingCoordinate: surveyBoundingBox[2], - northBoundingCoordinate: surveyBoundingBox[3], - southBoundingCoordinate: surveyBoundingBox[1] - }, - datasetGPolygon: datasetGPolygons + for (const item of surveyData.locations) { + features = features.concat(item.geometry as Feature[]); + } + + return this._getBoundingBoxForFeatures('Survey location Geographic Coverage', features); + } + + /** + * Creates an object representing geographic coverage pertaining to the given project + * + * @param {IGetProject} projectData + * @return {*} {Record} + * @memberof EmlService + */ + _getProjectGeographicCoverage(surveys: SurveyObject[]): Record { + if (!surveys.length) { + return {}; + } + let features: Feature[] = []; + + for (const survey of surveys) { + for (const location of survey.locations) { + features = features.concat(location.geometry as Feature[]); } - }; + } + + return this._getBoundingBoxForFeatures('Geographic coverage of all underlying project surveys', features); } /** @@ -981,16 +981,6 @@ export class EmlService extends DBService { return { description: { section: [ - { - title: 'Field Method', - para: codes.field_methods.find((code) => code.id === survey.purpose_and_methodology.field_method_id)?.name - }, - { - title: 'Ecological Season', - para: codes.ecological_seasons.find( - (code) => code.id === survey.purpose_and_methodology.ecological_season_id - )?.name - }, { title: 'Vantage Codes', para: { @@ -1037,9 +1027,9 @@ export class EmlService extends DBService { section: [ { title: 'Intended Outcomes', - para: codes.intended_outcomes.find( - (code) => code.id === surveyData.purpose_and_methodology.intended_outcome_id - )?.name + para: surveyData.purpose_and_methodology.intended_outcome_ids + .map((outcomeId) => codes.intended_outcomes.find((code) => code.id === outcomeId)?.name) + .join(', ') }, { title: 'Additional Details', diff --git a/api/src/services/observation-service.test.ts b/api/src/services/observation-service.test.ts index 58ce16e23f..badb229fcf 100644 --- a/api/src/services/observation-service.test.ts +++ b/api/src/services/observation-service.test.ts @@ -7,6 +7,7 @@ import { ObservationRepository, UpdateObservation } from '../repositories/observation-repository'; +import * as file_utils from '../utils/file-utils'; import { getMockDBConnection } from '../__mocks__/db'; import { ObservationService } from './observation-service'; @@ -107,11 +108,11 @@ describe('ObservationService', () => { }); }); - describe('getSurveyObservations', () => { + describe('getSurveyObservationsWithSupplementaryData', () => { it('Gets observations by survey id', async () => { const mockDBConnection = getMockDBConnection(); - const mockGetResponse: ObservationRecord[] = [ + const mockObservations: ObservationRecord[] = [ { survey_observation_id: 11, survey_id: 1, @@ -149,18 +150,78 @@ describe('ObservationService', () => { survey_sample_period_id: 1 } ]; + + const mockSupplementaryData = { + observationCount: 1 + }; + const getSurveyObservationsStub = sinon .stub(ObservationRepository.prototype, 'getSurveyObservations') - .resolves(mockGetResponse); + .resolves(mockObservations); + + const getSurveyObservationSupplementaryDataStub = sinon + .stub(ObservationService.prototype, 'getSurveyObservationsSupplementaryData') + .resolves(mockSupplementaryData); const surveyId = 1; const observationService = new ObservationService(mockDBConnection); - const response = await observationService.getSurveyObservations(surveyId); + const response = await observationService.getSurveyObservationsWithSupplementaryData(surveyId); expect(getSurveyObservationsStub).to.be.calledOnceWith(surveyId); - expect(response).to.eql(mockGetResponse); + expect(getSurveyObservationSupplementaryDataStub).to.be.calledOnceWith(surveyId); + expect(response).to.eql({ + surveyObservations: mockObservations, + supplementaryObservationData: mockSupplementaryData + }); + }); + }); + + describe('insertSurveyObservationSubmission', () => { + it('Inserts a survey observation submission record into the database', async () => { + const mockDBConnection = getMockDBConnection(); + const submission_id = 1; + const key = 'key'; + const survey_id = 1; + const original_filename = 'originalFilename'; + const mockFile = { originalname: original_filename } as Express.Multer.File; + const projectId = 1; + + const mockInsertResponse = { + submission_id, + key, + survey_id, + original_filename, + create_date: '2023-04-04', + create_user: 1, + update_date: null, + update_user: null + }; + const getNextSubmissionIdStub = sinon + .stub(ObservationRepository.prototype, 'getNextSubmissionId') + .resolves(submission_id); + const generateS3FileKeyStub = sinon.stub(file_utils, 'generateS3FileKey').returns(key); + const insertSurveyObservationSubmissionStub = sinon + .stub(ObservationRepository.prototype, 'insertSurveyObservationSubmission') + .resolves(mockInsertResponse); + + const observationService = new ObservationService(mockDBConnection); + + const response = await observationService.insertSurveyObservationSubmission(mockFile, projectId, survey_id); + + expect(getNextSubmissionIdStub).to.be.calledOnce; + expect(generateS3FileKeyStub).to.be.calledOnce; + expect(insertSurveyObservationSubmissionStub).to.be.calledOnceWith( + submission_id, + key, + survey_id, + original_filename + ); + expect(response).to.eql({ + submission_id, + key + }); }); }); }); diff --git a/api/src/services/observation-service.ts b/api/src/services/observation-service.ts index dc68a6bf2b..151c3c4100 100644 --- a/api/src/services/observation-service.ts +++ b/api/src/services/observation-service.ts @@ -1,12 +1,36 @@ +import { z } from 'zod'; import { IDBConnection } from '../database/db'; import { InsertObservation, ObservationRecord, ObservationRepository, + ObservationSubmissionRecord, UpdateObservation } from '../repositories/observation-repository'; +import { generateS3FileKey, getFileFromS3 } from '../utils/file-utils'; +import { getLogger } from '../utils/logger'; +import { parseS3File } from '../utils/media/media-utils'; +import { + constructWorksheets, + constructXLSXWorkbook, + getWorksheetRowObjects, + validateCsvFile +} from '../utils/xlsx-utils/worksheet-utils'; import { DBService } from './db-service'; +const defaultLog = getLogger('services/observation-service'); + +const observationCSVColumnValidator = { + columnNames: ['SPECIES_TAXONOMIC_ID', 'COUNT', 'DATE', 'TIME', 'LATITUDE', 'LONGITUDE'], + columnTypes: ['number', 'number', 'date', 'string', 'number', 'number'] +}; + +export const ObservationSupplementaryData = z.object({ + observationCount: z.number() +}); + +export type ObservationSupplementaryData = z.infer; + export class ObservationService extends DBService { observationRepository: ObservationRepository; @@ -41,13 +65,198 @@ export class ObservationService extends DBService { } /** - * Retrieves all observation records for the given survey + * Performs an upsert for all observation records belonging to the given survey, then + * returns the updated rows. + * + * @param {number} surveyId + * @param {((Observation | ObservationRecord)[])} observations + * @return {*} {Promise} + * @memberof ObservationService + */ + async insertUpdateSurveyObservations( + surveyId: number, + observations: (InsertObservation | UpdateObservation)[] + ): Promise { + return this.observationRepository.insertUpdateSurveyObservations(surveyId, observations); + } + + /** + * Retrieves all observation records for the given survey along with supplementary data + * + * @param {number} surveyId + * @return {*} {Promise<{ surveyObservations: ObservationRecord[]; supplementaryObservationData: ObservationSupplementaryData }>} + * @memberof ObservationService + */ + async getSurveyObservationsWithSupplementaryData( + surveyId: number + ): Promise<{ surveyObservations: ObservationRecord[]; supplementaryObservationData: ObservationSupplementaryData }> { + const surveyObservations = await this.observationRepository.getSurveyObservations(surveyId); + const supplementaryObservationData = await this.getSurveyObservationsSupplementaryData(surveyId); + + return { surveyObservations, supplementaryObservationData }; + } + + /** + * Retrieves all supplementary data for the given survey's observations + * + * @param {number} surveyId + * @return {*} {Promise<{ surveyObservations: ObservationRecord[]; supplementaryObservationData: ObservationSupplementaryData }>} + * @memberof ObservationService + */ + async getSurveyObservationsSupplementaryData(surveyId: number): Promise { + const observationCount = await this.observationRepository.getSurveyObservationCount(surveyId); + + return { observationCount }; + } + + /** + * Inserts a survey observation submission record into the database and returns the key + * + * @param {Express.Multer.File} file + * @param {number} projectId + * @param {number} surveyId + * @return {*} {Promise<{ key: string }>} + * @memberof ObservationService + */ + async insertSurveyObservationSubmission( + file: Express.Multer.File, + projectId: number, + surveyId: number + ): Promise<{ submission_id: number; key: string }> { + const submissionId = await this.observationRepository.getNextSubmissionId(); + + const key = generateS3FileKey({ + projectId, + surveyId, + submissionId, + fileName: file.originalname + }); + + const insertResult = await this.observationRepository.insertSurveyObservationSubmission( + submissionId, + key, + surveyId, + file.originalname + ); + + return { submission_id: insertResult.submission_id, key }; + } + + /** + * Retrieves the observation submission record by the given submission ID. + * + * @param {number} submissionId + * @return {*} {Promise} + * @memberof ObservationService + */ + async getObservationSubmissionById(submissionId: number): Promise { + return this.observationRepository.getObservationSubmissionById(submissionId); + } + + /** + * Retrieves all observation records for the given survey and sample site ids * * @param {number} surveyId + * @param {number} sampleSiteId + * @return {*} {Promise<{ observationCount: number }>} + * @memberof ObservationService + */ + async getObservationsCountBySampleSiteId( + surveyId: number, + sampleSiteId: number + ): Promise<{ observationCount: number }> { + return this.observationRepository.getObservationsCountBySampleSiteId(surveyId, sampleSiteId); + } + + /** + * Retrieves observation records count for the given survey and sample method ids + * + * @param {number} sampleMethodId + * @return {*} {Promise<{ observationCount: number }>} + * @memberof ObservationService + */ + async getObservationsCountBySampleMethodId(sampleMethodId: number): Promise<{ observationCount: number }> { + return this.observationRepository.getObservationsCountBySampleMethodId(sampleMethodId); + } + + /** + * Retrieves observation records count for the given survey and sample period ids + * + * @param {number} samplePeriodId + * @return {*} {Promise<{ observationCount: number }>} + * @memberof ObservationService + */ + async getObservationsCountBySamplePeriodId(samplePeriodId: number): Promise<{ observationCount: number }> { + return this.observationRepository.getObservationsCountBySamplePeriodId(samplePeriodId); + } + + /** + * Processes a observation upload submission. This method receives an ID belonging to an + * observation submission, gets the CSV file associated with the submission, and appends + * all of the records in the CSV file to the observations for the survey. If the CSV + * file fails validation, this method fails. + * + * @param {number} submissionId * @return {*} {Promise} * @memberof ObservationService */ - async getSurveyObservations(surveyId: number): Promise { - return this.observationRepository.getSurveyObservations(surveyId); + async processObservationCsvSubmission(submissionId: number): Promise { + defaultLog.debug({ label: 'processObservationCsvSubmission', submissionId }); + + // Step 1. Retrieve the observation submission record + const submission = await this.getObservationSubmissionById(submissionId); + const surveyId = submission.survey_id; + + // Step 2. Retrieve the S3 object containing the uploaded CSV file + const s3Object = await getFileFromS3(submission.key); + + // Step 3. Get the contents of the S3 object + const mediaFile = parseS3File(s3Object); + + // Step 4. Validate the CSV file + if (mediaFile.mimetype !== 'text/csv') { + throw new Error('Failed to process file for importing observations. Invalid CSV file.'); + } + + // Construct the XLSX workbook + const xlsxWorkBook = constructXLSXWorkbook(mediaFile); + + // Construct the worksheets + const xlsxWorksheets = constructWorksheets(xlsxWorkBook); + + if (validateCsvFile(xlsxWorksheets, observationCSVColumnValidator)) { + throw new Error('Failed to process file for importing observations. Invalid CSV file.'); + } + + // Get the worksheet row objects + const worksheetRowObjects = getWorksheetRowObjects(xlsxWorksheets['Sheet1']); + + // Step 5. Merge all the table rows into an array of ObservationInsert[] + const insertRows: InsertObservation[] = worksheetRowObjects.map((row) => ({ + survey_id: surveyId, + wldtaxonomic_units_id: row['SPECIES_TAXONOMIC_ID'], + survey_sample_site_id: null, + survey_sample_method_id: null, + survey_sample_period_id: null, + latitude: row['LATITUDE'], + longitude: row['LONGITUDE'], + count: row['COUNT'], + observation_time: row['TIME'], + observation_date: row['DATE'] + })); + + // Step 6. Insert new rows and return them + return this.observationRepository.insertUpdateSurveyObservations(surveyId, insertRows); + } + + /** + * Deletes all of the given survey observations by ID. + * + * @param {number[]} observationIds + * @return {*} {Promise} + * @memberof ObservationRepository + */ + async deleteObservationsByIds(observationIds: number[]): Promise { + return this.observationRepository.deleteObservationsByIds(observationIds); } } diff --git a/api/src/services/platform-service.test.ts b/api/src/services/platform-service.test.ts index 5eb2d28a09..0ea374321e 100644 --- a/api/src/services/platform-service.test.ts +++ b/api/src/services/platform-service.test.ts @@ -20,6 +20,7 @@ import { AttachmentService } from './attachment-service'; import { EmlPackage, EmlService } from './eml-service'; import { HistoryPublishService } from './history-publish-service'; import { KeycloakService } from './keycloak-service'; +import { ObservationService } from './observation-service'; import { IArtifact, IGetObservationSubmissionResponse, @@ -249,6 +250,137 @@ describe('PlatformService', () => { }); }); + describe('submitSurveyToBioHub', () => { + afterEach(() => { + sinon.restore(); + }); + + it('throws an error if BioHub intake is not enabled', async () => { + process.env.BACKBONE_INTAKE_ENABLED = 'false'; + + const mockDBConnection = getMockDBConnection(); + const platformService = new PlatformService(mockDBConnection); + + try { + await platformService.submitSurveyToBioHub(1, { additionalInformation: 'test' }); + expect.fail(); + } catch (error) { + expect((error as Error).message).to.equal('BioHub intake is not enabled'); + } + }); + + it('throws error when axios request fails', async () => { + process.env.BACKBONE_INTAKE_ENABLED = 'true'; + process.env.BACKBONE_API_HOST = 'http://backbone-host.dev/'; + + const mockDBConnection = getMockDBConnection(); + const platformService = new PlatformService(mockDBConnection); + + const getKeycloakServiceTokenStub = sinon + .stub(KeycloakService.prototype, 'getKeycloakServiceToken') + .resolves('token'); + + const generateSurveyDataPackageStub = sinon + .stub(PlatformService.prototype, 'generateSurveyDataPackage') + .resolves(({ id: '123-456-789' } as unknown) as any); + + sinon.stub(axios, 'post').resolves({}); + + try { + await platformService.submitSurveyToBioHub(1, { additionalInformation: 'test' }); + } catch (error) { + expect((error as Error).message).to.equal('Failed to submit survey ID to Biohub'); + expect(getKeycloakServiceTokenStub).to.have.been.calledOnce; + expect(generateSurveyDataPackageStub).to.have.been.calledOnceWith(1, 'test'); + } + }); + + it('should submit survey to BioHub successfully', async () => { + process.env.BACKBONE_INTAKE_ENABLED = 'true'; + process.env.BACKBONE_API_HOST = 'http://backbone-host.dev/'; + + const mockDBConnection = getMockDBConnection(); + const platformService = new PlatformService(mockDBConnection); + + const getKeycloakServiceTokenStub = sinon + .stub(KeycloakService.prototype, 'getKeycloakServiceToken') + .resolves('token'); + + const generateSurveyDataPackageStub = sinon + .stub(PlatformService.prototype, 'generateSurveyDataPackage') + .resolves(({ id: '123-456-789' } as unknown) as any); + + sinon.stub(axios, 'post').resolves({ data: { submission_id: 1 } }); + + const insertSurveyMetadataPublishRecordStub = sinon + .stub(HistoryPublishService.prototype, 'insertSurveyMetadataPublishRecord') + .resolves(); + + const response = await platformService.submitSurveyToBioHub(1, { additionalInformation: 'test' }); + + expect(getKeycloakServiceTokenStub).to.have.been.calledOnce; + expect(generateSurveyDataPackageStub).to.have.been.calledOnceWith(1, 'test'); + expect(insertSurveyMetadataPublishRecordStub).to.have.been.calledOnceWith({ survey_id: 1, queue_id: 1 }); + expect(response).to.eql({ submission_id: 1 }); + }); + }); + + describe('generateSurveyDataPackage', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should generate survey data package successfully', async () => { + const mockDBConnection = getMockDBConnection(); + const platformService = new PlatformService(mockDBConnection); + + const getSurveyDataStub = sinon.stub(SurveyService.prototype, 'getSurveyData').resolves({ uuid: '1' } as any); + + const getSurveyObservationsWithSupplementaryDataStub = sinon + .stub(ObservationService.prototype, 'getSurveyObservationsWithSupplementaryData') + .resolves({ surveyObservations: [{ survey_observation_id: 2 } as any], supplementaryData: [] } as any); + + const response = await platformService.generateSurveyDataPackage(1, 'test'); + + expect(getSurveyDataStub).to.have.been.calledOnceWith(1); + expect(getSurveyObservationsWithSupplementaryDataStub).to.have.been.calledOnceWith(1); + expect(response).to.eql({ + id: '1', + type: 'dataset', + properties: { + additional_information: 'test', + survey_id: undefined, + project_id: undefined, + name: undefined, + start_date: undefined, + end_date: undefined, + survey_types: undefined, + revision_count: undefined, + geometry: undefined + }, + features: [ + { + id: '2', + type: 'observation', + properties: { + survey_id: undefined, + taxonomy: undefined, + survey_sample_site_id: null, + survey_sample_method_id: null, + survey_sample_period_id: null, + latitude: undefined, + longitude: undefined, + count: undefined, + observation_time: undefined, + observation_date: undefined + }, + features: [] + } + ] + }); + }); + }); + describe('submitSurveyDataToBioHub', () => { afterEach(() => { sinon.restore(); diff --git a/api/src/services/platform-service.ts b/api/src/services/platform-service.ts index abb3f029db..94a6ad64f2 100644 --- a/api/src/services/platform-service.ts +++ b/api/src/services/platform-service.ts @@ -5,6 +5,7 @@ import { URL } from 'url'; import { v4 as uuidv4 } from 'uuid'; import { IDBConnection } from '../database/db'; import { ApiError, ApiErrorType, ApiGeneralError } from '../errors/api-error'; +import { PostSurveyToBiohubObject } from '../models/biohub-create'; import { IProjectAttachment, IProjectReportAttachment, @@ -20,6 +21,7 @@ import { DBService } from './db-service'; import { EmlPackage, EmlService } from './eml-service'; import { HistoryPublishService } from './history-publish-service'; import { KeycloakService } from './keycloak-service'; +import { ObservationService } from './observation-service'; import { SummaryService } from './summary-service'; import { SurveyService } from './survey-service'; @@ -112,7 +114,8 @@ const getBackboneIntakeEnabled = () => process.env.BACKBONE_INTAKE_ENABLED === ' const getBackboneApiHost = () => process.env.BACKBONE_API_HOST || ''; const getBackboneArtifactIntakePath = () => process.env.BACKBONE_ARTIFACT_INTAKE_PATH || '/api/artifact/intake'; const getBackboneArtifactDeletePath = () => process.env.BACKBONE_ARTIFACT_DELETE_PATH || '/api/artifact/delete'; -const getBackboneIntakePath = () => process.env.BACKBONE_INTAKE_PATH || '/api/dwc/submission/queue'; +const getBackboneDwcIntakePath = () => process.env.BACKBONE_INTAKE_PATH || '/api/dwc/submission/queue'; +const getBackboneSurveyIntakePath = () => process.env.BACKBONE_DATASET_INTAKE_PATH || '/api/dataset/intake'; export class PlatformService extends DBService { attachmentService: AttachmentService; @@ -257,6 +260,76 @@ export class PlatformService extends DBService { return { uuid: emlPackage.packageId }; } + /** + * Submit survey ID to BioHub. + * + * @param {string} surveyUUID + * @param {number} surveyId + * @param {{ additionalInformation: string }} data + * @return {*} {Promise<{ queue_id: number }>} + * @memberof PlatformService + */ + async submitSurveyToBioHub( + surveyId: number, + data: { additionalInformation: string } + ): Promise<{ submission_id: number }> { + defaultLog.debug({ label: 'submitSurveyToBioHub', message: 'params', surveyId }); + + if (!getBackboneIntakeEnabled()) { + throw new ApiGeneralError('BioHub intake is not enabled'); + } + + const keycloakService = new KeycloakService(); + + const token = await keycloakService.getKeycloakServiceToken(); + + const backboneSurveyIntakeUrl = new URL(getBackboneSurveyIntakePath(), getBackboneApiHost()).href; + + const surveyDataPackage = await this.generateSurveyDataPackage(surveyId, data.additionalInformation); + + const response = await axios.post<{ submission_id: number }>(backboneSurveyIntakeUrl, surveyDataPackage, { + headers: { + authorization: `Bearer ${token}` + } + }); + + if (!response.data) { + throw new ApiError(ApiErrorType.UNKNOWN, 'Failed to submit survey ID to Biohub'); + } + + // Insert publish history record + + // NOTE: this is a temporary solution to get the queue_id into the publish history table + // the queue_id is not returned from the survey intake endpoint, so we are using the submission_id + // as a temporary solution + await this.historyPublishService.insertSurveyMetadataPublishRecord({ + survey_id: surveyId, + queue_id: response.data.submission_id + }); + + return response.data; + } + + /** + * Generate survey data package to submit to BioHub. + * + * @param {number} surveyId + * @param {string} additionalInformation + * @return {*} {Promise} + * @memberof PlatformService + */ + async generateSurveyDataPackage(surveyId: number, additionalInformation: string): Promise { + const observationService = new ObservationService(this.connection); + const surveyService = new SurveyService(this.connection); + + const survey = await surveyService.getSurveyData(surveyId); + const { surveyObservations } = await observationService.getSurveyObservationsWithSupplementaryData(surveyId); + + const surveyDataPackage = new PostSurveyToBiohubObject(survey, surveyObservations, additionalInformation); + + return surveyDataPackage; + } + /** * Submit survey data to BioHub. * @@ -419,7 +492,7 @@ export class PlatformService extends DBService { formData.append('security_request[disa_required]', `${dwcaDataset.securityRequest.disa_required}`); } - const backboneIntakeUrl = new URL(getBackboneIntakePath(), getBackboneApiHost()).href; + const backboneIntakeUrl = new URL(getBackboneDwcIntakePath(), getBackboneApiHost()).href; const { data } = await axios.post<{ queue_id: number }>(backboneIntakeUrl, formData.getBuffer(), { headers: { diff --git a/api/src/services/project-service.test.ts b/api/src/services/project-service.test.ts index 2b06ebc4ca..ddcc6ce9fb 100644 --- a/api/src/services/project-service.test.ts +++ b/api/src/services/project-service.test.ts @@ -2,7 +2,7 @@ import chai, { expect } from 'chai'; import { describe } from 'mocha'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; -import { GetIUCNClassificationData, GetLocationData, GetObjectivesData, ProjectData } from '../models/project-view'; +import { GetIUCNClassificationData, GetObjectivesData, ProjectData } from '../models/project-view'; import { ProjectRepository } from '../repositories/project-repository'; import { getMockDBConnection } from '../__mocks__/db'; import { HistoryPublishService } from './history-publish-service'; @@ -129,22 +129,6 @@ describe('getObjectivesData', () => { }); }); -describe('getLocationData', () => { - it('returns the first row on success', async () => { - const dbConnection = getMockDBConnection(); - const service = new ProjectService(dbConnection); - - const data = new GetLocationData({ id: 1 }); - - const repoStub = sinon.stub(ProjectRepository.prototype, 'getLocationData').resolves(data); - - const response = await service.getLocationData(1); - - expect(repoStub).to.be.calledOnce; - expect(response).to.eql(data); - }); -}); - describe('getIUCNClassificationData', () => { it('returns the first row on success', async () => { const dbConnection = getMockDBConnection(); diff --git a/api/src/services/project-service.ts b/api/src/services/project-service.ts index 8998189c5b..f1896388cf 100644 --- a/api/src/services/project-service.ts +++ b/api/src/services/project-service.ts @@ -4,11 +4,10 @@ import { COMPLETION_STATUS } from '../constants/status'; import { IDBConnection } from '../database/db'; import { HTTP400 } from '../errors/http-error'; import { IPostIUCN, PostProjectObject } from '../models/project-create'; -import { IPutIUCN, PutIUCNData, PutLocationData, PutObjectivesData, PutProjectData } from '../models/project-update'; +import { IPutIUCN, PutIUCNData, PutObjectivesData, PutProjectData } from '../models/project-update'; import { GetAttachmentsData, GetIUCNClassificationData, - GetLocationData, GetObjectivesData, GetReportAttachmentsData, IGetProject, @@ -72,11 +71,10 @@ export class ProjectService extends DBService { } async getProjectById(projectId: number): Promise { - const [projectData, objectiveData, projectParticipantsData, locationData, iucnData] = await Promise.all([ + const [projectData, objectiveData, projectParticipantsData, iucnData] = await Promise.all([ this.getProjectData(projectId), this.getObjectivesData(projectId), this.getProjectParticipantsData(projectId), - this.getLocationData(projectId), this.getIUCNClassificationData(projectId) ]); @@ -84,7 +82,6 @@ export class ProjectService extends DBService { project: projectData, objectives: objectiveData, participants: projectParticipantsData, - location: locationData, iucn: iucnData }; } @@ -106,20 +103,11 @@ export class ProjectService extends DBService { const results: Partial = { project: undefined, objectives: undefined, - location: undefined, iucn: undefined }; const promises: Promise[] = []; - if (entities.includes(GET_ENTITIES.location)) { - promises.push( - this.getLocationData(projectId).then((value) => { - results.location = value; - }) - ); - } - if (entities.includes(GET_ENTITIES.iucn)) { promises.push( this.getIUCNClassificationData(projectId).then((value) => { @@ -169,10 +157,6 @@ export class ProjectService extends DBService { return this.projectParticipationService.getProjectParticipants(projectId); } - async getLocationData(projectId: number): Promise { - return this.projectRepository.getLocationData(projectId); - } - async getIUCNClassificationData(projectId: number): Promise { return this.projectRepository.getIUCNClassificationData(projectId); } @@ -225,9 +209,6 @@ export class ProjectService extends DBService { ) ); - // Handle project regions - promises.push(this.insertRegion(projectId, postProjectData.location.geometry)); - // Handle project programs promises.push(this.insertPrograms(projectId, postProjectData.project.project_programs)); @@ -338,7 +319,7 @@ export class ProjectService extends DBService { async updateProject(projectId: number, entities: IUpdateProject): Promise { const promises: Promise[] = []; - if (entities?.project || entities?.location || entities?.objectives) { + if (entities?.project || entities?.objectives) { promises.push(this.updateProjectData(projectId, entities)); } @@ -372,24 +353,16 @@ export class ProjectService extends DBService { async updateProjectData(projectId: number, entities: IUpdateProject): Promise { const putProjectData = (entities?.project && new PutProjectData(entities.project)) || null; - const putLocationData = (entities?.location && new PutLocationData(entities.location)) || null; const putObjectivesData = (entities?.objectives && new PutObjectivesData(entities.objectives)) || null; // Update project table - const revision_count = - putProjectData?.revision_count ?? putLocationData?.revision_count ?? putObjectivesData?.revision_count ?? null; + const revision_count = putProjectData?.revision_count ?? putObjectivesData?.revision_count ?? null; if (!revision_count && revision_count !== 0) { throw new HTTP400('Failed to parse request body'); } - await this.projectRepository.updateProjectData( - projectId, - putProjectData, - putLocationData, - putObjectivesData, - revision_count - ); + await this.projectRepository.updateProjectData(projectId, putProjectData, putObjectivesData, revision_count); } async deleteProject(projectId: number): Promise { diff --git a/api/src/services/sample-location-service.test.ts b/api/src/services/sample-location-service.test.ts index 92fd9eb8c0..4328b8aac0 100644 --- a/api/src/services/sample-location-service.test.ts +++ b/api/src/services/sample-location-service.test.ts @@ -21,24 +21,26 @@ describe('SampleLocationService', () => { const mockData: PostSampleLocations = { survey_sample_site_id: null, survey_id: 1, - name: `Sample Site 1`, - description: ``, survey_sample_sites: [ { - type: 'Feature', - geometry: { - type: 'Polygon', - coordinates: [ - [ - [-121.904297, 50.930738], - [-121.904297, 51.971346], - [-120.19043, 51.971346], - [-120.19043, 50.930738], - [-121.904297, 50.930738] + name: `Sample Site 1`, + description: ``, + feature: { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-121.904297, 50.930738], + [-121.904297, 51.971346], + [-120.19043, 51.971346], + [-120.19043, 50.930738], + [-121.904297, 50.930738] + ] ] - ] - }, - properties: {} + }, + properties: {} + } } ], methods: [ @@ -50,7 +52,9 @@ describe('SampleLocationService', () => { { survey_sample_method_id: 1, start_date: '2023-01-01', - end_date: '2023-01-03' + end_date: '2023-01-03', + start_time: '12:00:00', + end_time: '13:00:00' } ] } @@ -116,6 +120,14 @@ describe('SampleLocationService', () => { const mockDBConnection = getMockDBConnection(); const service = new SampleLocationService(mockDBConnection); + const getSampleMethodsForSurveySampleSiteIdStub = sinon + .stub(SampleMethodService.prototype, 'getSampleMethodsForSurveySampleSiteId') + .resolves([{ survey_sample_method_id: 1 } as any]); + + const deleteSampleMethodRecordStub = sinon + .stub(SampleMethodService.prototype, 'deleteSampleMethodRecord') + .resolves(); + sinon.stub(SampleLocationRepository.prototype, 'deleteSampleLocationRecord').resolves({ survey_sample_site_id: 1, survey_id: 1, @@ -134,6 +146,8 @@ describe('SampleLocationService', () => { const { survey_sample_site_id } = await service.deleteSampleLocationRecord(1); expect(survey_sample_site_id).to.be.eq(1); + expect(getSampleMethodsForSurveySampleSiteIdStub).to.be.calledOnceWith(1); + expect(deleteSampleMethodRecordStub).to.be.calledOnceWith(1); }); }); diff --git a/api/src/services/sample-location-service.ts b/api/src/services/sample-location-service.ts index e254f708b2..533a64eb52 100644 --- a/api/src/services/sample-location-service.ts +++ b/api/src/services/sample-location-service.ts @@ -9,12 +9,16 @@ import { InsertSampleMethodRecord } from '../repositories/sample-method-reposito import { DBService } from './db-service'; import { SampleMethodService } from './sample-method-service'; +interface SampleSite { + name: string; + description: string; + feature: Feature; +} + export interface PostSampleLocations { survey_sample_site_id: number | null; survey_id: number; - name: string; - description: string; - survey_sample_sites: Feature[]; + survey_sample_sites: SampleSite[]; methods: InsertSampleMethodRecord[]; } @@ -52,26 +56,40 @@ export class SampleLocationService extends DBService { * @memberof SampleLocationService */ async deleteSampleLocationRecord(surveySampleSiteId: number): Promise { + const sampleMethodService = new SampleMethodService(this.connection); + + // Delete all methods associated with the sample location + const existingSampleMethods = await sampleMethodService.getSampleMethodsForSurveySampleSiteId(surveySampleSiteId); + for (const item of existingSampleMethods) { + await sampleMethodService.deleteSampleMethodRecord(item.survey_sample_method_id); + } + return this.sampleLocationRepository.deleteSampleLocationRecord(surveySampleSiteId); } /** * Inserts survey Sample Locations. * + * It is a business requirement to use strings from the properties field of provided geometry + * to determine the name and description of sampling locations when possible. + * + * If there is no string contained in the fields 'name', 'label' to be used in our db, + * the system will auto-generate a name of 'Sampling Site #x', where x is taken from the greatest value + * integer id + 1 in the db. + * * @param {PostSampleLocations} sampleLocations * @return {*} {Promise} * @memberof SampleLocationService */ async insertSampleLocations(sampleLocations: PostSampleLocations): Promise { const methodService = new SampleMethodService(this.connection); - // Create a sample location for each feature found - const promises = sampleLocations.survey_sample_sites.map((item, index) => { + const promises = sampleLocations.survey_sample_sites.map((item) => { const sampleLocation = { survey_id: sampleLocations.survey_id, - name: `Sample Site ${index + 1}`, // Business requirement to default the names to Sample Site # on creation - description: sampleLocations.description, - geojson: item + name: item.name, + description: item.description, + geojson: item.feature }; return this.sampleLocationRepository.insertSampleLocation(sampleLocation); diff --git a/api/src/services/sample-method-service.test.ts b/api/src/services/sample-method-service.test.ts index 8095ec3d89..22f2c7558a 100644 --- a/api/src/services/sample-method-service.test.ts +++ b/api/src/services/sample-method-service.test.ts @@ -9,6 +9,7 @@ import { } from '../repositories/sample-method-repository'; import { SamplePeriodRecord } from '../repositories/sample-period-repository'; import { getMockDBConnection } from '../__mocks__/db'; +import { ObservationService } from './observation-service'; import { SampleMethodService } from './sample-method-service'; import { SamplePeriodService } from './sample-period-service'; @@ -130,6 +131,8 @@ describe('SampleMethodService', () => { survey_sample_period_id: 2, start_date: '2023-10-04', end_date: '2023-11-05', + start_time: '12:00:00', + end_time: '13:00:00', create_date: '2023-01-02', create_user: 1, update_date: null, @@ -145,8 +148,20 @@ describe('SampleMethodService', () => { method_lookup_id: 3, description: 'description', periods: [ - { end_date: '2023-01-02', start_date: '2023-10-02', survey_sample_method_id: 1 }, - { end_date: '2023-10-03', start_date: '2023-11-05', survey_sample_method_id: 1 } + { + end_date: '2023-01-02', + start_date: '2023-10-02', + start_time: '12:00:00', + end_time: '13:00:00', + survey_sample_method_id: 1 + }, + { + end_date: '2023-10-03', + start_date: '2023-11-05', + start_time: '12:00:00', + end_time: '13:00:00', + survey_sample_method_id: 1 + } ] }; const sampleMethodService = new SampleMethodService(mockDBConnection); @@ -156,12 +171,16 @@ describe('SampleMethodService', () => { expect(insertSamplePeriodStub).to.be.calledWith({ survey_sample_method_id: mockSampleMethodRecord.survey_sample_method_id, start_date: sampleMethod.periods[0].start_date, - end_date: sampleMethod.periods[0].end_date + end_date: sampleMethod.periods[0].end_date, + start_time: sampleMethod.periods[0].start_time, + end_time: sampleMethod.periods[0].end_time }); expect(insertSamplePeriodStub).to.be.calledWith({ survey_sample_method_id: mockSampleMethodRecord.survey_sample_method_id, start_date: sampleMethod.periods[1].start_date, - end_date: sampleMethod.periods[1].end_date + end_date: sampleMethod.periods[1].end_date, + start_time: sampleMethod.periods[1].start_time, + end_time: sampleMethod.periods[1].end_time }); expect(response).to.eql(mockSampleMethodRecord); }); @@ -201,8 +220,21 @@ describe('SampleMethodService', () => { method_lookup_id: 3, description: 'description', periods: [ - { end_date: '2023-01-02', start_date: '2023-10-02', survey_sample_method_id: 1, survey_sample_period_id: 4 }, - { end_date: '2023-10-03', start_date: '2023-11-05', survey_sample_method_id: 1 } as SamplePeriodRecord + { + end_date: '2023-01-02', + start_date: '2023-10-02', + start_time: '12:00:00', + end_time: '13:00:00', + survey_sample_method_id: 1, + survey_sample_period_id: 4 + }, + { + end_date: '2023-10-03', + start_date: '2023-11-05', + start_time: '12:00:00', + end_time: '13:00:00', + survey_sample_method_id: 1 + } as SamplePeriodRecord ] }; const sampleMethodService = new SampleMethodService(mockDBConnection); @@ -244,6 +276,10 @@ describe('SampleMethodService', () => { .stub(SampleMethodService.prototype, 'deleteSampleMethodRecord') .resolves(); + const getObservationsCountBySampleMethodIdStub = sinon + .stub(ObservationService.prototype, 'getObservationsCountBySampleMethodId') + .resolves({ observationCount: 0 }); + const surveySampleSiteId = 1; const sampleMethodService = new SampleMethodService(mockDBConnection); @@ -255,6 +291,7 @@ describe('SampleMethodService', () => { expect(getSampleMethodsForSurveySampleSiteIdStub).to.be.calledOnceWith(surveySampleSiteId); expect(deleteSampleMethodRecordStub).to.be.calledOnceWith(mockSampleMethodRecord.survey_sample_method_id); + expect(getObservationsCountBySampleMethodIdStub).to.be.calledOnceWith(survey_sample_method_id); }); }); }); diff --git a/api/src/services/sample-method-service.ts b/api/src/services/sample-method-service.ts index e09a3ac3ed..c0ffa21cd7 100644 --- a/api/src/services/sample-method-service.ts +++ b/api/src/services/sample-method-service.ts @@ -1,4 +1,5 @@ import { IDBConnection } from '../database/db'; +import { HTTP400 } from '../errors/http-error'; import { InsertSampleMethodRecord, SampleMethodRecord, @@ -6,6 +7,7 @@ import { UpdateSampleMethodRecord } from '../repositories/sample-method-repository'; import { DBService } from './db-service'; +import { ObservationService } from './observation-service'; import { SamplePeriodService } from './sample-period-service'; /** @@ -44,9 +46,10 @@ export class SampleMethodService extends DBService { async deleteSampleMethodRecord(surveySampleMethodId: number): Promise { const samplePeriodService = new SamplePeriodService(this.connection); - // Delete all associated sample periods + // Collect list of periods to delete const existingSamplePeriods = await samplePeriodService.getSamplePeriodsForSurveyMethodId(surveySampleMethodId); const periodsToDelete = existingSamplePeriods.map((item) => item.survey_sample_period_id); + // Delete all associated sample periods await samplePeriodService.deleteSamplePeriodRecords(periodsToDelete); return this.sampleMethodRepository.deleteSampleMethodRecord(surveySampleMethodId); @@ -69,7 +72,9 @@ export class SampleMethodService extends DBService { const samplePeriod = { survey_sample_method_id: record.survey_sample_method_id, start_date: item.start_date, - end_date: item.end_date + end_date: item.end_date, + start_time: item.start_time, + end_time: item.end_time }; return samplePeriodService.insertSamplePeriod(samplePeriod); }); @@ -98,13 +103,23 @@ export class SampleMethodService extends DBService { ); }); + const observationService = new ObservationService(this.connection); + // Delete any methods not found in the passed in array if (existingMethodsToDelete.length > 0) { const promises: Promise[] = []; - existingMethodsToDelete.forEach((method: any) => { + // Check if any observations are associated with the methods to be deleted + for (const method of existingMethodsToDelete) { + if ( + (await observationService.getObservationsCountBySampleMethodId(method.survey_sample_method_id)) + .observationCount > 0 + ) { + throw new HTTP400('Cannot delete a sample method that is associated with an observation'); + } + promises.push(this.deleteSampleMethodRecord(method.survey_sample_method_id)); - }); + } await Promise.all(promises); } @@ -133,7 +148,9 @@ export class SampleMethodService extends DBService { const samplePeriod = { survey_sample_method_id: sampleMethod.survey_sample_method_id, start_date: item.start_date, - end_date: item.end_date + end_date: item.end_date, + start_time: item.start_time, + end_time: item.end_time }; await samplePeriodService.insertSamplePeriod(samplePeriod); } diff --git a/api/src/services/sample-period-service.test.ts b/api/src/services/sample-period-service.test.ts index e2e668f9c2..42671d0657 100644 --- a/api/src/services/sample-period-service.test.ts +++ b/api/src/services/sample-period-service.test.ts @@ -8,6 +8,7 @@ import { UpdateSamplePeriodRecord } from '../repositories/sample-period-repository'; import { getMockDBConnection } from '../__mocks__/db'; +import { ObservationService } from './observation-service'; import { SamplePeriodService } from './sample-period-service'; chai.use(sinonChai); @@ -35,6 +36,8 @@ describe('SamplePeriodService', () => { survey_sample_method_id: 2, start_date: '2023-10-02', end_date: '2023-01-02', + start_time: '12:00:00', + end_time: '13:00:00', create_date: '2023-05-06', create_user: 1, update_date: null, @@ -68,6 +71,8 @@ describe('SamplePeriodService', () => { survey_sample_method_id: 2, start_date: '2023-10-02', end_date: '2023-01-02', + start_time: '12:00:00', + end_time: '13:00:00', create_date: '2023-05-06', create_user: 1, update_date: null, @@ -100,6 +105,8 @@ describe('SamplePeriodService', () => { survey_sample_method_id: 2, start_date: '2023-10-02', end_date: '2023-01-02', + start_time: '12:00:00', + end_time: '13:00:00', create_date: '2023-05-06', create_user: 1, update_date: null, @@ -113,7 +120,9 @@ describe('SamplePeriodService', () => { const samplePeriod: InsertSamplePeriodRecord = { survey_sample_method_id: 1, start_date: '2023-10-02', - end_date: '2023-01-02' + end_date: '2023-01-02', + start_time: '12:00:00', + end_time: '13:00:00' }; const samplePeriodService = new SamplePeriodService(mockDBConnection); const response = await samplePeriodService.insertSamplePeriod(samplePeriod); @@ -136,6 +145,8 @@ describe('SamplePeriodService', () => { survey_sample_method_id: 2, start_date: '2023-10-02', end_date: '2023-01-02', + start_time: '12:00:00', + end_time: '13:00:00', create_date: '2023-05-06', create_user: 1, update_date: null, @@ -150,7 +161,9 @@ describe('SamplePeriodService', () => { survey_sample_method_id: 1, survey_sample_period_id: 2, start_date: '2023-10-02', - end_date: '2023-01-02' + end_date: '2023-01-02', + start_time: '12:00:00', + end_time: '13:00:00' }; const samplePeriodService = new SamplePeriodService(mockDBConnection); const response = await samplePeriodService.updateSamplePeriod(samplePeriod); @@ -170,6 +183,8 @@ describe('SamplePeriodService', () => { survey_sample_method_id: 2, start_date: '2023-10-02', end_date: '2023-01-02', + start_time: '12:00:00', + end_time: '13:00:00', create_date: '2023-05-06', create_user: 1, update_date: null, @@ -185,6 +200,10 @@ describe('SamplePeriodService', () => { .stub(SamplePeriodService.prototype, 'deleteSamplePeriodRecords') .resolves(); + const getObservationsCountBySamplePeriodIdStub = sinon + .stub(ObservationService.prototype, 'getObservationsCountBySamplePeriodId') + .resolves({ observationCount: 0 }); + const surveySampleMethodId = 1; const samplePeriodService = new SamplePeriodService(mockDBConnection); const response = await samplePeriodService.deleteSamplePeriodsNotInArray(surveySampleMethodId, [ @@ -194,6 +213,9 @@ describe('SamplePeriodService', () => { expect(getSamplePeriodsForSurveyMethodIdStub).to.be.calledOnceWith(surveySampleMethodId); expect(deleteSamplePeriodRecordsStub).to.be.calledOnceWith([mockSamplePeriodRecords[0].survey_sample_period_id]); expect(response).to.eql(undefined); + expect(getObservationsCountBySamplePeriodIdStub).to.be.calledOnceWith( + mockSamplePeriodRecords[0].survey_sample_period_id + ); }); }); }); diff --git a/api/src/services/sample-period-service.ts b/api/src/services/sample-period-service.ts index 11e9715b29..6135dcf08e 100644 --- a/api/src/services/sample-period-service.ts +++ b/api/src/services/sample-period-service.ts @@ -1,4 +1,5 @@ import { IDBConnection } from '../database/db'; +import { HTTP400 } from '../errors/http-error'; import { InsertSamplePeriodRecord, SamplePeriodRecord, @@ -6,6 +7,7 @@ import { UpdateSamplePeriodRecord } from '../repositories/sample-period-repository'; import { DBService } from './db-service'; +import { ObservationService } from './observation-service'; /** * Sample Period Repository @@ -97,9 +99,24 @@ export class SamplePeriodService extends DBService { ); }); + const observationService = new ObservationService(this.connection); + // Delete any Periods not found in the passed in array if (existingPeriodToDelete.length > 0) { - const idsToDelete = existingPeriodToDelete.map((item) => item.survey_sample_period_id); + const idsToDelete = []; + + // Check if any observations are associated with the periods to delete + for (const period of existingPeriodToDelete) { + if ( + (await observationService.getObservationsCountBySamplePeriodId(period.survey_sample_period_id)) + .observationCount > 0 + ) { + throw new HTTP400('Cannot delete a sample period that is associated with an observation'); + } + + idsToDelete.push(period.survey_sample_period_id); + } + await this.deleteSamplePeriodRecords(idsToDelete); } } diff --git a/api/src/services/survey-location-service.test.ts b/api/src/services/survey-location-service.test.ts new file mode 100644 index 0000000000..b6d1cbf5cd --- /dev/null +++ b/api/src/services/survey-location-service.test.ts @@ -0,0 +1,94 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { PostSurveyLocationData } from '../models/survey-update'; +import { SurveyLocationRecord, SurveyLocationRepository } from '../repositories/survey-location-repository'; +import { getMockDBConnection } from '../__mocks__/db'; +import { SurveyLocationService } from './survey-location-service'; + +chai.use(sinonChai); + +describe('SurveyLocationService', () => { + afterEach(() => { + sinon.restore(); + }); + + const dataObj = { + survey_location_id: 1, + name: 'Updated Test Location', + description: 'Updated Test Description', + geojson: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [1, 1] + }, + properties: {} + } + ], + revision_count: 2 + }; + + const data = new PostSurveyLocationData(dataObj); + + describe('insertSurveyLocation', () => { + it('inserts survey location and returns void', async () => { + const dbConnection = getMockDBConnection(); + const service = new SurveyLocationService(dbConnection); + + const repoStub = sinon.stub(SurveyLocationRepository.prototype, 'insertSurveyLocation').resolves(); + + const response = await service.insertSurveyLocation(1, data); + + expect(repoStub).to.be.calledOnce; + expect(response).to.be.undefined; + }); + }); + + describe('updateSurveyLocation', () => { + it('updates survey location and returns void', async () => { + const dbConnection = getMockDBConnection(); + const service = new SurveyLocationService(dbConnection); + + const repoStub = sinon.stub(SurveyLocationRepository.prototype, 'updateSurveyLocation').resolves(); + + const response = await service.updateSurveyLocation(data); + + expect(repoStub).to.be.calledOnce; + expect(response).to.be.undefined; + }); + }); + + describe('getSurveyLocationsData', () => { + it('returns list of survey locations', async () => { + const dbConnection = getMockDBConnection(); + const service = new SurveyLocationService(dbConnection); + + const repoStub = sinon + .stub(SurveyLocationRepository.prototype, 'getSurveyLocationsData') + .resolves([{} as SurveyLocationRecord]); + + const response = await service.getSurveyLocationsData(1); + + expect(repoStub).to.be.calledOnce; + expect(response).to.eql([{}]); + }); + }); + + describe('deleteSurveyLocation', () => { + it('deletes survey location and returns record', async () => { + const dbConnection = getMockDBConnection(); + const service = new SurveyLocationService(dbConnection); + + const repoStub = sinon + .stub(SurveyLocationRepository.prototype, 'deleteSurveyLocation') + .resolves({} as SurveyLocationRecord); + + const response = await service.deleteSurveyLocation(1); + + expect(repoStub).to.be.calledOnce; + expect(response).to.eql({}); + }); + }); +}); diff --git a/api/src/services/survey-location-service.ts b/api/src/services/survey-location-service.ts index 296794a042..5de35c1a19 100644 --- a/api/src/services/survey-location-service.ts +++ b/api/src/services/survey-location-service.ts @@ -1,6 +1,5 @@ import { IDBConnection } from '../database/db'; -import { PostLocationData } from '../models/survey-create'; -import { PutSurveyLocationData } from '../models/survey-update'; +import { PostSurveyLocationData } from '../models/survey-update'; import { SurveyLocationRecord, SurveyLocationRepository } from '../repositories/survey-location-repository'; import { DBService } from './db-service'; @@ -30,22 +29,22 @@ export class SurveyLocationService extends DBService { * Insert a new survey location record. * * @param {number} surveyId - * @param {PostLocationData} data + * @param {PostSurveyLocationData} data * @return {*} {Promise} * @memberof SurveyLocationService */ - async insertSurveyLocation(surveyId: number, data: PostLocationData): Promise { + async insertSurveyLocation(surveyId: number, data: PostSurveyLocationData): Promise { return this.surveyLocationRepository.insertSurveyLocation(surveyId, data); } /** * Update an existing survey location record. * - * @param {PutSurveyLocationData} data + * @param {PostSurveyLocationData} data * @return {*} {Promise} * @memberof SurveyLocationService */ - async updateSurveyLocation(data: PutSurveyLocationData): Promise { + async updateSurveyLocation(data: PostSurveyLocationData): Promise { return this.surveyLocationRepository.updateSurveyLocation(data); } /** @@ -58,4 +57,15 @@ export class SurveyLocationService extends DBService { async getSurveyLocationsData(surveyId: number): Promise { return this.surveyLocationRepository.getSurveyLocationsData(surveyId); } + + /** + * Deletes a survey location for a given survey location id + * + * @param surveyLocationId + * @returns {*} Promise + * @memberof SurveyLocationService + */ + async deleteSurveyLocation(surveyLocationId: number): Promise { + return this.surveyLocationRepository.deleteSurveyLocation(surveyLocationId); + } } diff --git a/api/src/services/survey-service.test.ts b/api/src/services/survey-service.test.ts index c076835879..2641df1767 100644 --- a/api/src/services/survey-service.test.ts +++ b/api/src/services/survey-service.test.ts @@ -7,7 +7,7 @@ import { MESSAGE_CLASS_NAME, SUBMISSION_MESSAGE_TYPE, SUBMISSION_STATUS_TYPE } f import { ApiExecuteSQLError, ApiGeneralError } from '../errors/api-error'; import { GetReportAttachmentsData } from '../models/project-view'; import { PostProprietorData, PostSurveyObject } from '../models/survey-create'; -import { PutSurveyObject, PutSurveyPermitData } from '../models/survey-update'; +import { PostSurveyLocationData, PutSurveyObject, PutSurveyPermitData } from '../models/survey-update'; import { GetAncillarySpeciesData, GetAttachmentsData, @@ -35,6 +35,7 @@ import { PermitService } from './permit-service'; import { PlatformService } from './platform-service'; import { SiteSelectionStrategyService } from './site-selection-strategy-service'; import { SurveyBlockService } from './survey-block-service'; +import { SurveyLocationService } from './survey-location-service'; import { SurveyParticipationService } from './survey-participation-service'; import { SurveyService } from './survey-service'; import { TaxonomyService } from './taxonomy-service'; @@ -149,6 +150,9 @@ describe('SurveyService', () => { const updateSurveyStratumsStub = sinon .stub(SiteSelectionStrategyService.prototype, 'updateSurveyStratums') .resolves(); + const insertUpdateDeleteSurveyLocationStub = sinon + .stub(SurveyService.prototype, 'insertUpdateDeleteSurveyLocation') + .resolves(); const surveyService = new SurveyService(dbConnectionObj); @@ -166,6 +170,7 @@ describe('SurveyService', () => { expect(insertRegionStub).not.to.have.been.called; expect(upsertSurveyParticipantDataStub).not.to.have.been.called; expect(updateSurveyStratumsStub).not.to.have.been.called; + expect(insertUpdateDeleteSurveyLocationStub).not.to.have.been.called; }); it('updates everything when all data provided', async () => { @@ -176,6 +181,9 @@ describe('SurveyService', () => { const updateSurveyVantageCodesDataStub = sinon .stub(SurveyService.prototype, 'updateSurveyVantageCodesData') .resolves(); + const updateSurveyIntendedOutcomesStub = sinon + .stub(SurveyService.prototype, 'updateSurveyIntendedOutcomes') + .resolves(); const updateSurveySpeciesDataStub = sinon.stub(SurveyService.prototype, 'updateSurveySpeciesData').resolves(); const updateSurveyPermitDataStub = sinon.stub(SurveyService.prototype, 'updateSurveyPermitData').resolves(); const upsertSurveyFundingSourceDataStub = sinon @@ -194,6 +202,9 @@ describe('SurveyService', () => { const replaceSiteStrategiesStub = sinon .stub(SiteSelectionStrategyService.prototype, 'replaceSurveySiteSelectionStrategies') .resolves(); + const insertUpdateDeleteSurveyLocationStub = sinon + .stub(SurveyService.prototype, 'insertUpdateDeleteSurveyLocation') + .resolves(); const surveyService = new SurveyService(dbConnectionObj); @@ -205,7 +216,7 @@ describe('SurveyService', () => { funding_sources: [{}], proprietor: {}, purpose_and_methodology: {}, - locations: [], + locations: [{}], participants: [{}], site_selection: { stratums: [], strategies: [] }, blocks: [{}] @@ -224,6 +235,8 @@ describe('SurveyService', () => { expect(upsertBlocks).to.have.been.calledOnce; expect(replaceSurveyStratumsStub).to.have.been.calledOnce; expect(replaceSiteStrategiesStub).to.have.been.calledOnce; + expect(insertUpdateDeleteSurveyLocationStub).to.have.been.calledOnce; + expect(updateSurveyIntendedOutcomesStub).to.have.been.calledOnce; }); }); @@ -1347,6 +1360,119 @@ describe('SurveyService', () => { expect(response).to.eql(PublishStatus.UNSUBMITTED); }); }); + + describe('insertUpdateDeleteSurveyLocation', () => { + afterEach(() => { + sinon.restore(); + }); + + it('passes correct data to insert, update, and delete methods', async () => { + const dbConnection = getMockDBConnection(); + const service = new SurveyService(dbConnection); + const existingLocations = [ + { survey_location_id: 30, name: 'Location 1' }, + { survey_location_id: 31, name: 'Location 2' } + ] as SurveyLocationRecord[]; + + const getSurveyLocationsDataStub = sinon.stub(service, 'getSurveyLocationsData').resolves(existingLocations); + + const inputData = [ + { survey_location_id: 30, name: 'Updated Location 1' }, + { name: 'New Location' } + ] as PostSurveyLocationData[]; + + const insertSurveyLocationsStub = sinon.stub(service, 'insertSurveyLocations').resolves(); + const updateSurveyLocationStub = sinon.stub(service, 'updateSurveyLocation').resolves(); + const deleteSurveyLocationStub = sinon.stub(service, 'deleteSurveyLocation').resolves(existingLocations[1]); + + await service.insertUpdateDeleteSurveyLocation(20, inputData); + + expect(getSurveyLocationsDataStub).to.be.calledOnceWith(20); + + expect(insertSurveyLocationsStub).to.be.calledOnceWith(20, { name: 'New Location' }); + + expect(updateSurveyLocationStub).to.be.calledOnceWith({ + survey_location_id: 30, + name: 'Updated Location 1' + }); + + expect(deleteSurveyLocationStub).to.be.calledOnceWith(31); + }); + }); + + describe('deleteSurveyLocation', () => { + afterEach(() => { + sinon.restore(); + }); + + it('calls the deleteSurveyLocation method of SurveyLocationService with correct arguments', async () => { + const dbConnection = getMockDBConnection(); + const service = new SurveyService(dbConnection); + const surveyLocationServiceStub = sinon + .stub(SurveyLocationService.prototype, 'deleteSurveyLocation') + .resolves({ survey_location_id: 30, name: 'Location 1' } as SurveyLocationRecord); + + const response = await service.deleteSurveyLocation(30); + + expect(surveyLocationServiceStub).to.be.calledOnceWith(30); + expect(response).to.eql({ survey_location_id: 30, name: 'Location 1' }); + }); + }); + + describe('updateSurveyLocation', () => { + afterEach(() => { + sinon.restore(); + }); + + it('calls the updateSurveyLocation method of SurveyLocationService with correct arguments', async () => { + const dbConnection = getMockDBConnection(); + const service = new SurveyService(dbConnection); + const surveyLocationServiceStub = sinon.stub(SurveyLocationService.prototype, 'updateSurveyLocation').resolves(); + + const input = { survey_location_id: 30, name: 'Updated Location 1' } as PostSurveyLocationData; + + await service.updateSurveyLocation(input); + + expect(surveyLocationServiceStub).to.be.calledOnceWith(input); + }); + }); + + describe('insertSurveyIntendedOutcomes', () => { + afterEach(() => { + sinon.restore(); + }); + + it('calls the updateSurveyLocation method of SurveyLocationService with correct arguments', async () => { + const dbConnection = getMockDBConnection(); + const service = new SurveyService(dbConnection); + const insertionStub = sinon.stub(SurveyRepository.prototype, 'insertManySurveyIntendedOutcomes').resolves(); + + await service.insertSurveyIntendedOutcomes([1, 2], 1); + + expect(insertionStub).to.be.calledOnceWith(1, [1, 2]); + }); + }); + + describe('updateSurveyIntendedOutcomes', () => { + afterEach(() => { + sinon.restore(); + }); + + it('calls the updateSurveyLocation method of SurveyLocationService with correct arguments', async () => { + const dbConnection = getMockDBConnection(); + const service = new SurveyService(dbConnection); + const insertionStub = sinon.stub(SurveyRepository.prototype, 'insertManySurveyIntendedOutcomes').resolves(); + const deleteStub = sinon.stub(SurveyRepository.prototype, 'deleteManySurveyIntendedOutcomes').resolves(); + sinon + .stub(SurveyRepository.prototype, 'getSurveyPurposeAndMethodology') + .resolves(new GetSurveyPurposeAndMethodologyData({ intended_outcome_ids: [1, 3] })); + const putObj = new PutSurveyObject({ purpose_and_methodology: { intended_outcome_ids: [1, 2] } }); + await service.updateSurveyIntendedOutcomes(1, putObj); + + expect(insertionStub).to.be.calledOnceWith(1, [2]); + expect(deleteStub).to.be.calledOnceWith(1, [3]); + }); + }); }); /* diff --git a/api/src/services/survey-service.ts b/api/src/services/survey-service.ts index b7b7c3d5f3..4614da28a7 100644 --- a/api/src/services/survey-service.ts +++ b/api/src/services/survey-service.ts @@ -1,8 +1,8 @@ import { Feature } from 'geojson'; import { MESSAGE_CLASS_NAME, SUBMISSION_MESSAGE_TYPE, SUBMISSION_STATUS_TYPE } from '../constants/status'; import { IDBConnection } from '../database/db'; -import { PostLocationData, PostProprietorData, PostSurveyObject } from '../models/survey-create'; -import { PutPartnershipsData, PutSurveyLocationData, PutSurveyObject } from '../models/survey-update'; +import { PostProprietorData, PostSurveyObject } from '../models/survey-create'; +import { PostSurveyLocationData, PutPartnershipsData, PutSurveyObject } from '../models/survey-update'; import { GetAncillarySpeciesData, GetAttachmentsData, @@ -27,6 +27,7 @@ import { IObservationSubmissionUpdateDetails, IOccurrenceSubmissionMessagesResponse, ISurveyProprietorModel, + SurveyBasicFields, SurveyRepository } from '../repositories/survey-repository'; import { getLogger } from '../utils/logger'; @@ -338,6 +339,38 @@ export class SurveyService extends DBService { return this.getSurveysByIds(surveyIds.map((survey) => survey.id)); } + /** + * Fetches a subset of survey fields for all surveys under a project. + * + * @param {number} projectId + * @return {*} {Promise} + * @memberof SurveyService + */ + async getSurveysBasicFieldsByProjectId(projectId: number): Promise { + const surveys = await this.surveyRepository.getSurveysBasicFieldsByProjectId(projectId); + + // Build an array of all unique focal species ids from all surveys + const uniqueFocalSpeciesIds = Array.from( + new Set(surveys.reduce((ids: number[], survey) => ids.concat(survey.focal_species), [])) + ); + + // Fetch focal species data for all species ids + const taxonomyService = new TaxonomyService(); + const focalSpecies = await taxonomyService.getSpeciesFromIds(uniqueFocalSpeciesIds); + + // Decorate the surveys response with their matching focal species labels + const decoratedSurveys: SurveyBasicFields[] = []; + for (const survey of surveys) { + const matchingFocalSpeciesNames = focalSpecies + .filter((item) => survey.focal_species.includes(Number(item.id))) + .map((item) => item.label); + + decoratedSurveys.push({ ...survey, focal_species_names: matchingFocalSpeciesNames }); + } + + return decoratedSurveys; + } + /** * Creates a survey and uploads the affected metadata to BioHub * @@ -380,6 +413,11 @@ export class SurveyService extends DBService { // Handle survey types promises.push(this.insertSurveyTypes(postSurveyData.survey_details.survey_types, surveyId)); + //Handle multiple intended outcomes + promises.push( + this.insertSurveyIntendedOutcomes(postSurveyData.purpose_and_methodology.intended_outcome_ids, surveyId) + ); + // Handle focal species associated to this survey promises.push( Promise.all( @@ -494,7 +532,7 @@ export class SurveyService extends DBService { * @return {*} {Promise} * @memberof SurveyService */ - async insertSurveyLocations(surveyId: number, data: PostLocationData): Promise { + async insertSurveyLocations(surveyId: number, data: PostSurveyLocationData): Promise { const service = new SurveyLocationService(this.connection); return service.insertSurveyLocation(surveyId, data); } @@ -619,6 +657,16 @@ export class SurveyService extends DBService { return this.surveyRepository.insertSurveyProprietor(survey_proprietor, surveyId); } + /** + * Inserts multiple rows for intended outcomes of a survey. + * + * @param {number[]} intended_outcomes + * @param {number} surveyId + */ + async insertSurveyIntendedOutcomes(intended_outcomes: number[], surveyId: number): Promise { + return this.surveyRepository.insertManySurveyIntendedOutcomes(surveyId, intended_outcomes); + } + /** * Insert or update association of permit to a given survey * @@ -693,6 +741,7 @@ export class SurveyService extends DBService { if (putSurveyData?.purpose_and_methodology) { promises.push(this.updateSurveyVantageCodesData(surveyId, putSurveyData)); + promises.push(this.updateSurveyIntendedOutcomes(surveyId, putSurveyData)); } if (putSurveyData?.partnerships) { @@ -714,8 +763,8 @@ export class SurveyService extends DBService { promises.push(this.updateSurveyProprietorData(surveyId, putSurveyData)); } - if (putSurveyData?.locations) { - promises.push(Promise.all(putSurveyData.locations.map((item) => this.updateSurveyLocation(item)))); + if (putSurveyData?.locations.length) { + promises.push(this.insertUpdateDeleteSurveyLocation(surveyId, putSurveyData.locations)); } if (putSurveyData?.participants.length) { @@ -750,7 +799,51 @@ export class SurveyService extends DBService { await Promise.all(promises); } - async updateSurveyLocation(data: PutSurveyLocationData): Promise { + /** + * Handles the create, update and deletion of survey locations based on the given data. + * + * @param {number} surveyId + * @param {PostSurveyLocationData} data + * @returns {*} {Promise} + */ + async insertUpdateDeleteSurveyLocation(surveyId: number, data: PostSurveyLocationData[]): Promise { + const existingLocations = await this.getSurveyLocationsData(surveyId); + // compare existing locations with passed in locations + // any locations not found in both arrays will be deleted + const deletes = existingLocations.filter( + (existing) => !data.find((incoming) => incoming?.survey_location_id === existing.survey_location_id) + ); + const deletePromises = deletes.map((item) => this.deleteSurveyLocation(item.survey_location_id)); + + const inserts = data.filter((item) => !item.survey_location_id); + const insertPromises = inserts.map((item) => this.insertSurveyLocations(surveyId, item)); + + const updates = data.filter((item) => item.survey_location_id); + const updatePromises = updates.map((item) => this.updateSurveyLocation(item)); + + return Promise.all([insertPromises, updatePromises, deletePromises]); + } + + /** + * Deletes a survey location for the given id. Returns the deleted record + * + * @param {number} surveyLocationId Id of the record to delete + * @returns {*} {Promise} The deleted record + * @memberof SurveyService + */ + async deleteSurveyLocation(surveyLocationId: number): Promise { + const surveyLocationService = new SurveyLocationService(this.connection); + return surveyLocationService.deleteSurveyLocation(surveyLocationId); + } + + /** + * Updates Survey Locations based on the data provided + * + * @param {PostSurveyLocationData} data + * @returns {*} {Promise} + * @memberof SurveyService + */ + async updateSurveyLocation(data: PostSurveyLocationData): Promise { const surveyLocationService = new SurveyLocationService(this.connection); return surveyLocationService.updateSurveyLocation(data); } @@ -877,6 +970,33 @@ export class SurveyService extends DBService { await Promise.all(promises); } + /** + * Updates the list of intended outcomes associated with this survey. + * + * @param {number} surveyId + * @param {PurSurveyObject} surveyData + */ + async updateSurveyIntendedOutcomes(surveyId: number, surveyData: PutSurveyObject) { + const purposeMethodInfo = await this.getSurveyPurposeAndMethodology(surveyId); + const { intended_outcome_ids: currentOutcomeIds } = surveyData.purpose_and_methodology; + const existingOutcomeIds = purposeMethodInfo.intended_outcome_ids; + const rowsToInsert = currentOutcomeIds.reduce((acc: number[], curr: number) => { + if (!existingOutcomeIds.find((existingId) => existingId === curr)) { + return [...acc, curr]; + } + return acc; + }, []); + const rowsToDelete = existingOutcomeIds.reduce((acc: number[], curr: number) => { + if (!currentOutcomeIds.find((existingId) => existingId === curr)) { + return [...acc, curr]; + } + return acc; + }, []); + + await this.surveyRepository.insertManySurveyIntendedOutcomes(surveyId, rowsToInsert); + await this.surveyRepository.deleteManySurveyIntendedOutcomes(surveyId, rowsToDelete); + } + /** * Compares incoming survey permit data against the existing survey permits, if any, and determines which need to be * deleted, added, or updated. diff --git a/api/src/services/telemetry-service.ts b/api/src/services/telemetry-service.ts new file mode 100644 index 0000000000..b3be19ac55 --- /dev/null +++ b/api/src/services/telemetry-service.ts @@ -0,0 +1,84 @@ +import { IDBConnection } from '../database/db'; +import { TelemetryRepository, TelemetrySubmissionRecord } from '../repositories/telemetry-repository'; +import { generateS3FileKey, getFileFromS3 } from '../utils/file-utils'; +import { parseS3File } from '../utils/media/media-utils'; +import { constructWorksheets, constructXLSXWorkbook, validateCsvFile } from '../utils/xlsx-utils/worksheet-utils'; +import { DBService } from './db-service'; + +const telemetryCSVColumnValidator = { + columnNames: ['DEVICE_ID', 'DATE', 'TIME', 'LATITUDE', 'LONGITUDE'], + columnTypes: ['number', 'date', 'string', 'number', 'number'] +}; + +export class TelemetryService extends DBService { + repository: TelemetryRepository; + constructor(connection: IDBConnection) { + super(connection); + this.repository = new TelemetryRepository(connection); + } + + /** + * + * Inserts a survey telemetry submission record into the database and returns the key + * + * @param {Express.Multer.File} file + * @param {number} projectId + * @param {number} surveyId + * @return {*} {Promise<{ key: string }>} + * @memberof ObservationService + */ + async insertSurveyTelemetrySubmission( + file: Express.Multer.File, + projectId: number, + surveyId: number + ): Promise<{ submission_id: number; key: string }> { + const submissionId = await this.repository.getNextSubmissionId(); + const key = generateS3FileKey({ projectId, surveyId, submissionId, fileName: file.originalname }); + const result = await this.repository.insertSurveyTelemetrySubmission( + submissionId, + key, + surveyId, + file.originalname + ); + return { submission_id: result.survey_telemetry_submission_id, key }; + } + + async processTelemetryCsvSubmission(submissionId: number): Promise { + // step 1 get submission record + const submission = await this.getTelemetrySubmissionById(submissionId); + + // step 2 get s3 record for given key + const s3Object = await getFileFromS3(submission.key); + + // step 3 parse the file + const mediaFile = parseS3File(s3Object); + + // step 4 validate csv + if (mediaFile.mimetype !== 'text/csv') { + throw new Error( + `Failed to process file for importing telemetry. Incorrect file type. Expected CSV received ${mediaFile.mimetype}` + ); + } + + // step 5 construct workbook/ setup + const xlsxWorkBook = constructXLSXWorkbook(mediaFile); + const xlsxWorksheets = constructWorksheets(xlsxWorkBook); + + // step 6 validate columns + if (validateCsvFile(xlsxWorksheets, telemetryCSVColumnValidator)) { + throw new Error('Failed to process file for importing telemetry. Invalid CSV file.'); + } + + // step 7 fetch survey deployments + // const bctwService = new BctwService() + + // step 8 create dictionary of deployments (alias-device_id) + // step 9 map data from csv/ dictionary into telemetry records + // step 10 send to telemetry service api + return []; + } + + async getTelemetrySubmissionById(submissionId: number): Promise { + return this.repository.getTelemetrySubmissionById(submissionId); + } +} diff --git a/api/src/services/user-service.ts b/api/src/services/user-service.ts index 9a6460a144..01b6268b43 100644 --- a/api/src/services/user-service.ts +++ b/api/src/services/user-service.ts @@ -1,3 +1,4 @@ +import { SYSTEM_ROLE } from '../constants/roles'; import { IDBConnection } from '../database/db'; import { ApiExecuteSQLError } from '../errors/api-error'; import { SystemUser, UserRepository, UserSearchCriteria } from '../repositories/user-repository'; @@ -15,6 +16,19 @@ export class UserService extends DBService { this.userRepository = new UserRepository(connection); } + /** + * Checks if the given system user is an admin (has an admin level system role). + * + * @param {SystemUser} systemUser + * @return {*} {boolean} `true` if the user is an admin, `false` otherwise. + */ + static isAdmin(systemUser: SystemUser): boolean { + return ( + systemUser.role_names.includes(SYSTEM_ROLE.SYSTEM_ADMIN) || + systemUser.role_names.includes(SYSTEM_ROLE.DATA_ADMINISTRATOR) + ); + } + /** * Fetch a single system user by their system user ID. * diff --git a/api/src/services/validation-service.ts b/api/src/services/validation-service.ts index d4032e48a1..6d82356579 100644 --- a/api/src/services/validation-service.ts +++ b/api/src/services/validation-service.ts @@ -332,15 +332,9 @@ export class ValidationService extends DBService { const surveyData = await this.surveyService.getSurveyById(surveyId); - const surveyFieldMethodId = surveyData.purpose_and_methodology.field_method_id; const surveySpecies = surveyData.species.focal_species; - return this.validationRepository.getTemplateMethodologySpeciesRecord( - templateName, - templateVersion, - surveyFieldMethodId, - surveySpecies - ); + return this.validationRepository.getTemplateMethodologySpeciesRecord(templateName, templateVersion, surveySpecies); } async getValidationSchema(file: XLSXCSV, surveyId: number): Promise { diff --git a/api/src/utils/keycloak-utils.test.ts b/api/src/utils/keycloak-utils.test.ts index 4f561642dc..333533ac45 100644 --- a/api/src/utils/keycloak-utils.test.ts +++ b/api/src/utils/keycloak-utils.test.ts @@ -13,8 +13,7 @@ import { isBceidBasicUserInformation, isBceidBusinessUserInformation, isDatabaseUserInformation, - isIdirUserInformation, - ServiceClientUserInformation + isIdirUserInformation } from './keycloak-utils'; describe('keycloakUtils', () => { @@ -264,91 +263,6 @@ describe('keycloakUtils', () => { }); }); - describe('getKeycloakUserInformationFromKeycloakToken', () => { - it('returns valid idir token information', () => { - const keycloakUserInformation: IdirUserInformation = { - idir_user_guid: '123456789', - identity_provider: 'idir', - idir_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@idir', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = getUserIdentitySource(keycloakUserInformation); - - expect(response).not.to.be.null; - }); - - it('returns valid bceid basic token information', () => { - const keycloakUserInformation: BceidBasicUserInformation = { - bceid_user_guid: '123456789', - identity_provider: 'bceidbasic', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbasic', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = getUserIdentitySource(keycloakUserInformation); - - expect(response).not.to.be.null; - }); - - it('returns valid bceid business token information', () => { - const keycloakUserInformation: BceidBusinessUserInformation = { - bceid_business_guid: '1122334455', - bceid_business_name: 'Business Name', - bceid_user_guid: '123456789', - identity_provider: 'bceidbusiness', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbusiness', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = getUserIdentitySource(keycloakUserInformation); - - expect(response).not.to.be.null; - }); - - it('returns valid database token information', () => { - const keycloakUserInformation: DatabaseUserInformation = { - database_user_guid: '123456789', - identity_provider: 'database', - username: 'biohub_dapi_v1' - }; - - const response = getUserIdentitySource(keycloakUserInformation); - - expect(response).not.to.be.null; - }); - - it('returns valid system token information', () => { - const keycloakUserInformation: ServiceClientUserInformation = { - database_user_guid: '123456789', - identity_provider: 'database', - username: 'biohub_dapi_v1' - }; - - const response = getUserIdentitySource(keycloakUserInformation); - - expect(response).not.to.be.null; - }); - }); - describe('isIdirUserInformation', () => { it('returns true when idir token information provided', () => { const keycloakUserInformation: IdirUserInformation = { diff --git a/api/src/utils/keycloak-utils.ts b/api/src/utils/keycloak-utils.ts index 193f7a6b04..fbb21e1146 100644 --- a/api/src/utils/keycloak-utils.ts +++ b/api/src/utils/keycloak-utils.ts @@ -1,93 +1,84 @@ -import { z } from 'zod'; import { SOURCE_SYSTEM, SYSTEM_IDENTITY_SOURCE } from '../constants/database'; /** * User information from a verified IDIR Keycloak token. */ -export const IdirUserInformation = z.object({ - idir_user_guid: z.string().toLowerCase(), - identity_provider: z.literal(SYSTEM_IDENTITY_SOURCE.IDIR.toLowerCase()), - idir_username: z.string().toLowerCase(), - email_verified: z.boolean(), - name: z.string(), - preferred_username: z.string(), - display_name: z.string(), - given_name: z.string(), - family_name: z.string(), - email: z.string() -}); -export type IdirUserInformation = z.infer; +export type IdirUserInformation = { + idir_user_guid: string; + identity_provider: 'idir'; + idir_username: string; + email_verified: boolean; + name: string; + preferred_username: string; + display_name: string; + given_name: string; + family_name: string; + email: string; +}; /** * User information from a verified BCeID Basic Keycloak token. */ -export const BceidBasicUserInformation = z.object({ - bceid_user_guid: z.string().toLowerCase(), - identity_provider: z.literal(SYSTEM_IDENTITY_SOURCE.BCEID_BASIC.toLowerCase()), - bceid_username: z.string().toLowerCase(), - email_verified: z.boolean(), - name: z.string(), - preferred_username: z.string(), - display_name: z.string(), - given_name: z.string(), - family_name: z.string(), - email: z.string() -}); -export type BceidBasicUserInformation = z.infer; +export type BceidBasicUserInformation = { + bceid_user_guid: string; + identity_provider: 'bceidbasic'; + bceid_username: string; + email_verified: boolean; + name: string; + preferred_username: string; + display_name: string; + given_name: string; + family_name: string; + email: string; +}; /** * User information from a verified BCeID Keycloak token. */ -export const BceidBusinessUserInformation = z.object({ - bceid_business_guid: z.string().toLowerCase(), - bceid_business_name: z.string(), - bceid_user_guid: z.string().toLowerCase(), - identity_provider: z.literal(SYSTEM_IDENTITY_SOURCE.BCEID_BUSINESS.toLowerCase()), - bceid_username: z.string().toLowerCase(), - email_verified: z.boolean(), - name: z.string(), - preferred_username: z.string(), - display_name: z.string(), - given_name: z.string(), - family_name: z.string(), - email: z.string() -}); -export type BceidBusinessUserInformation = z.infer; +export type BceidBusinessUserInformation = { + bceid_business_guid: string; + bceid_business_name: string; + bceid_user_guid: string; + identity_provider: 'bceidbusiness'; + bceid_username: string; + email_verified: boolean; + name: string; + preferred_username: string; + display_name: string; + given_name: string; + family_name: string; + email: string; +}; /** * User information for a keycloak service client userF. */ -export const ServiceClientUserInformation = z.object({ - database_user_guid: z.string(), - identity_provider: z.literal(SYSTEM_IDENTITY_SOURCE.SYSTEM.toLowerCase()), - username: z.string() -}); +export type ServiceClientUserInformation = { + database_user_guid: string; + identity_provider: 'system'; + username: string; + clientId?: string; + azp?: string; +}; /** * User information for an internal database user. */ -export const DatabaseUserInformation = z.object({ - database_user_guid: z.string(), - identity_provider: z.literal(SYSTEM_IDENTITY_SOURCE.DATABASE.toLowerCase()), - username: z.string() -}); - -export type DatabaseUserInformation = z.infer; - -export type ServiceClientUserInformation = z.infer; +export type DatabaseUserInformation = { + database_user_guid: string; + identity_provider: 'database'; + username: string; +}; /** * User information from either an IDIR or BCeID Basic or BCeID Business Keycloak token. */ -export const KeycloakUserInformation = z.discriminatedUnion('identity_provider', [ - IdirUserInformation, - BceidBasicUserInformation, - BceidBusinessUserInformation, - ServiceClientUserInformation, - DatabaseUserInformation -]); - -export type KeycloakUserInformation = z.infer; +export type KeycloakUserInformation = + | IdirUserInformation + | BceidBasicUserInformation + | BceidBusinessUserInformation + | ServiceClientUserInformation + | DatabaseUserInformation; /** * Returns the user information guid. @@ -173,24 +164,6 @@ export const getUserIdentifier = (keycloakUserInformation: KeycloakUserInformati return keycloakUserInformation.username; }; -/** - * Get a `KeycloakUserInformation` object from a Keycloak Bearer Token (IDIR or BCeID Basic or BCeID Business token). - * - * @param {Record} keycloakToken - * @return {*} {(KeycloakUserInformation | null)} - */ -export const getKeycloakUserInformationFromKeycloakToken = ( - keycloakToken: Record -): KeycloakUserInformation | null => { - const result = KeycloakUserInformation.safeParse(keycloakToken); - - if (!result.success) { - return null; - } - - return result.data; -}; - export const isIdirUserInformation = ( keycloakUserInformation: KeycloakUserInformation ): keycloakUserInformation is IdirUserInformation => { @@ -215,6 +188,12 @@ export const isDatabaseUserInformation = ( return keycloakUserInformation.identity_provider === SYSTEM_IDENTITY_SOURCE.DATABASE.toLowerCase(); }; +export const isServiceClientUserInformation = ( + keycloakUserInformation: KeycloakUserInformation +): keycloakUserInformation is ServiceClientUserInformation => { + return keycloakUserInformation.identity_provider === SYSTEM_IDENTITY_SOURCE.SYSTEM.toLowerCase(); +}; + /** * Parses out the `clientId` and `azp` fields from the token and maps them to a known `SOURCE_SYSTEM`, or null if no * match is found. diff --git a/api/src/utils/xlsx-utils/cell-utils.ts b/api/src/utils/xlsx-utils/cell-utils.ts new file mode 100644 index 0000000000..b225b80590 --- /dev/null +++ b/api/src/utils/xlsx-utils/cell-utils.ts @@ -0,0 +1,115 @@ +import dayjs from 'dayjs'; +import { CellObject } from 'xlsx'; +import { safeTrim } from '../string-utils'; + +/** + * Trims whitespace from the value of a string type cell. + * Trims whitespace from the formatted text value of a cell, if present. + * + * @export + * @param {CellObject} cell + * @return {*} + */ +export function trimCellWhitespace(cell: CellObject) { + if (isStringCell(cell)) { + // check and clean raw strings + cell.v = safeTrim(cell.v); + } + + if (cell.w) { + // check and clean formatted strings + cell.w = safeTrim(cell.w); + } + + return cell; +} + +/** + * Attempts to update the cells value with a formatted date or time value if the cell is a date type cell that has a + * date or time format. + * + * @see https://docs.sheetjs.com/docs/csf/cell for details on cell fields + * @export + * @param {CellObject} cell + * @return {*} + */ +export function replaceCellDates(cell: CellObject) { + if (!isDateCell(cell)) { + return cell; + } + + const cellDate = dayjs(cell.v as any); + + if (!cellDate.isValid()) { + return cell; + } + + if (isDateFormatCell(cell)) { + const DateFormat = 'YYYY-MM-DD'; + cell.v = cellDate.format(DateFormat); + return cell; + } + + if (isTimeFormatCell(cell)) { + const TimeFormat = 'HH:mm'; + cell.v = cellDate.format(TimeFormat); + return cell; + } + + return cell; +} + +/** + * Checks if the cell has type string. + * + * @export + * @param {CellObject} cell + * @return {*} {boolean} `true` if the cell has type string, `false` otherwise. + */ +export function isStringCell(cell: CellObject): boolean { + return cell.t === 's'; +} + +/** + * Checks if the cell has type date. + * + * @export + * @param {CellObject} cell + * @return {*} {boolean} `true` if the cell has type date, `false` otherwise. + */ +export function isDateCell(cell: CellObject): boolean { + return cell.t === 'd'; +} + +/** + * Checks if the cell has a format, and if the format is likely a date format. + * + * @export + * @param {CellObject} cell + * @return {*} {boolean} `true` if the cell has a date format, `false` otherwise. + */ +export function isDateFormatCell(cell: CellObject): boolean { + if (!cell.z) { + return false; + } + + // format contains `d` and/or `y` which are values only used in date formats + return String(cell.z).includes('d') || String(cell.z).includes('y'); +} + +/** + * Checks if the cell has a format, and if the format is likely a time format. + * + * @export + * @param {CellObject} cell + * @return {*} {boolean} `true` if the cell has a time format, `false` otherwise. + */ +export function isTimeFormatCell(cell: CellObject): boolean { + if (!cell.z) { + // Not a date cell and/or has no date format + return false; + } + + // format contains `h` and/or `ss` which are values only used in time formats, or date formats that include time + return String(cell.z).includes('h') || String(cell.z).includes('ss'); +} diff --git a/api/src/utils/xlsx-utils/worksheet-utils.ts b/api/src/utils/xlsx-utils/worksheet-utils.ts new file mode 100644 index 0000000000..96c6d2b41e --- /dev/null +++ b/api/src/utils/xlsx-utils/worksheet-utils.ts @@ -0,0 +1,296 @@ +import moment from 'moment'; +import xlsx, { CellObject } from 'xlsx'; +import { MediaFile } from '../media/media-file'; +import { safeToLowerCase, safeTrim } from '../string-utils'; +import { replaceCellDates, trimCellWhitespace } from './cell-utils'; + +/** + * Returns true if the given cell is a date type cell. + * + * @export + * @param {MediaFile} file + * @param {xlsx.ParsingOptions} [options] + * @return {*} {xlsx.WorkBook} + */ +export function constructXLSXWorkbook(file: MediaFile, options?: xlsx.ParsingOptions): xlsx.WorkBook { + return xlsx.read(file.buffer, { cellDates: true, cellNF: true, cellHTML: false, ...options }); +} + +/** + * Constructs a CSVWorksheets from the given workbook + * + * @export + * @param {xlsx.WorkBook} workbook + * @return {*} {CSVWorksheets} + */ +export function constructWorksheets(workbook: xlsx.WorkBook): xlsx.WorkSheet { + const worksheets: xlsx.WorkSheet = {}; + + Object.entries(workbook.Sheets).forEach(([key, value]) => { + worksheets[key] = value; + }); + + return worksheets; +} + +/** + * Get the headers for the given worksheet. + * + * @export + * @param {xlsx.WorkSheet} worksheet + * @return {*} {string[]} + */ +export function getWorksheetHeaders(worksheet: xlsx.WorkSheet): string[] { + const originalRange = getWorksheetRange(worksheet); + + if (!originalRange) { + return []; + } + const customRange: xlsx.Range = { ...originalRange, e: { ...originalRange.e, r: 0 } }; + + const aoaHeaders: any[][] = xlsx.utils.sheet_to_json(worksheet, { + header: 1, + blankrows: false, + range: customRange + }); + + let headers = []; + + if (aoaHeaders.length > 0) { + // Parse the headers array from the array of arrays produced by calling `xlsx.utils.sheet_to_json` + headers = aoaHeaders[0].map(safeTrim); + } + + return headers; +} + +/** + * Get the headers for the given worksheet, with all values converted to lowercase. + * + * @export + * @param {xlsx.WorkSheet} worksheet + * @return {*} {string[]} + */ +export function getHeadersLowerCase(worksheet: xlsx.WorkSheet): string[] { + return getWorksheetHeaders(worksheet).map(safeToLowerCase); +} + +/** + * Get the index of the given header name. + * + * @export + * @param {xlsx.WorkSheet} worksheet + * @param {string} headerName + * @return {*} {number} + */ +export function getHeaderIndex(worksheet: xlsx.WorkSheet, headerName: string): number { + return getWorksheetHeaders(worksheet).indexOf(headerName); +} + +/** + * Return an array of row value arrays. + * + * @export + * @param {xlsx.WorkSheet} worksheet + * @return {*} {string[][]} + */ +export function getWorksheetRows(worksheet: xlsx.WorkSheet): string[][] { + const originalRange = getWorksheetRange(worksheet); + + if (!originalRange) { + return []; + } + + const rowsToReturn: string[][] = []; + + for (let i = 1; i <= originalRange.e.r; i++) { + const row = new Array(getWorksheetHeaders(worksheet).length); + let rowHasValues = false; + + for (let j = 0; j <= originalRange.e.c; j++) { + const cellAddress = { c: j, r: i }; + const cellRef = xlsx.utils.encode_cell(cellAddress); + const cell = worksheet[cellRef]; + + if (!cell) { + continue; + } + + row[j] = trimCellWhitespace(replaceCellDates(cell)).v; + + rowHasValues = true; + } + + if (row.length && rowHasValues) { + rowsToReturn.push(row); + } + } + + return rowsToReturn; +} + +/** + * Return an array of row value arrays. + * + * @export + * @param {xlsx.WorkSheet} worksheet + * @return {*} {Record[]} + */ +export function getWorksheetRowObjects(worksheet: xlsx.WorkSheet): Record[] { + const ref = worksheet['!ref']; + + if (!ref) { + return []; + } + + const rowObjectsArray: Record[] = []; + const rows = getWorksheetRows(worksheet); + const headers = getWorksheetHeaders(worksheet); + + rows.forEach((row: string[]) => { + const rowObject = {}; + + headers.forEach((header: string, index: number) => { + rowObject[header] = row[index]; + }); + + rowObjectsArray.push(rowObject); + }); + + return rowObjectsArray; +} + +/** + * Return boolean indicating whether the worksheet has the expected headers. + * + * @export + * @param {xlsx.WorkSheet} worksheet + * @param {string[]} expectedHeaders + * @return {*} {boolean} + */ +export function validateWorksheetHeaders(worksheet: xlsx.WorkSheet, expectedHeaders: string[]): boolean { + const worksheetHeaders = getWorksheetHeaders(worksheet); + + if (worksheetHeaders.length !== expectedHeaders.length) { + return false; + } + + return expectedHeaders.every((header) => worksheetHeaders.includes(header)); +} + +/** + * Return boolean indicating whether the worksheet has correct column types. + * + * @export + * @param {xlsx.WorkSheet} worksheet + * @param {string[]} rowValueTypes + * @return {*} {boolean} + */ +export function validateWorksheetColumnTypes(worksheet: xlsx.WorkSheet, rowValueTypes: string[]): boolean { + const worksheetRows = getWorksheetRows(worksheet); + + return worksheetRows.every((row) => { + if (row.length !== rowValueTypes.length) { + return false; + } + + return Object.values(row).every((value, index) => { + const type = typeof value; + + if (rowValueTypes[index] === 'date') { + return moment(value).isValid(); + } + + return rowValueTypes[index] === type; + }); + }); +} + +/** + * Get a worksheet by name. + * + * @export + * @param {xlsx.WorkBook} workbook + * @param {string} sheetName + * @return {*} {xlsx.WorkSheet} + */ +export function getWorksheetByName(workbook: xlsx.WorkBook, sheetName: string): xlsx.WorkSheet { + return workbook.Sheets[sheetName]; +} + +/** + * Get a worksheets decoded range object, or return undefined if the worksheet is missing range information. + * + * @export + * @param {xlsx.WorkSheet} worksheet + * @return {*} {(xlsx.Range | undefined)} + */ +export function getWorksheetRange(worksheet: xlsx.WorkSheet): xlsx.Range | undefined { + const ref = worksheet['!ref']; + + if (!ref) { + return undefined; + } + + 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 + */ +export function prepareWorksheetCells(worksheet: xlsx.WorkSheet) { + const range = getWorksheetRange(worksheet); + + if (!range) { + return undefined; + } + + for (let r = range.s.r; r < range.e.r; r++) { + for (let c = range.s.c; c < range.e.c; c++) { + const coord = xlsx.utils.encode_cell({ r, c }); + let cell: CellObject = worksheet[coord]; + + if (!cell?.v) { + // Cell is null or has no raw value + continue; + } + + cell = replaceCellDates(cell); + + cell = trimCellWhitespace(cell); + } + } +} + +export interface IXLSXCSVValidator { + columnNames: string[]; + columnTypes: string[]; +} +/** + * Validates the given CSV file against the given column validator + * + * @param {MediaFile} file + * @return {*} {boolean} + * @memberof ObservationService + */ +export function validateCsvFile( + xlsxWorksheets: xlsx.WorkSheet, + columnValidator: IXLSXCSVValidator, + sheet = 'Sheet1' +): boolean { + // Validate the worksheet headers + if (!validateWorksheetHeaders(xlsxWorksheets[sheet], columnValidator.columnNames)) { + return false; + } + + // Validate the worksheet column types + if (!validateWorksheetColumnTypes(xlsxWorksheets[sheet], columnValidator.columnTypes)) { + return false; + } + + return true; +} diff --git a/api/src/zod-schema/geoJsonZodSchema.test.ts b/api/src/zod-schema/geoJsonZodSchema.test.ts index 02f5595225..a01f2e61ac 100644 --- a/api/src/zod-schema/geoJsonZodSchema.test.ts +++ b/api/src/zod-schema/geoJsonZodSchema.test.ts @@ -10,8 +10,7 @@ import { GeoJSONMultiPointZodSchema, GeoJSONMultiPolygonZodSchema, GeoJSONPointZodSchema, - GeoJSONPolygonZodSchema, - GeoJSONZodSchema + GeoJSONPolygonZodSchema } from './geoJsonZodSchema'; describe('geoJsonZodSchema', () => { @@ -377,159 +376,4 @@ describe('geoJsonZodSchema', () => { expect(() => GeoJSONFeatureCollectionZodSchema.parse(geoJson)).to.throw(ZodError); }); }); - - describe('GeoJSONZodSchema', () => { - it('validates and returns correct geo json (FeatureCollection)', () => { - const geoJson = { - type: 'FeatureCollection', - features: [ - { - type: 'Feature', - properties: { prop0: 'value0' }, - geometry: { - coordinates: [-122.91259963675338, 49.37596881840895], - type: 'Point' - } - }, - { - type: 'Feature', - properties: { prop0: 'value0' }, - geometry: { - coordinates: [ - [-122.33078290142579, 49.52462898822321], - [-122.82749139343922, 49.63533663294646] - ], - type: 'LineString' - } - }, - { - type: 'Feature', - properties: { prop0: 'value0' }, - geometry: { - coordinates: [ - [ - [-123.01165460668969, 49.64896702554316], - [-123.01165460668969, 49.27062822525298], - [-122.26659186866941, 49.27062822525298], - [-122.26659186866941, 49.64896702554316], - [-123.01165460668969, 49.64896702554316] - ] - ], - type: 'Polygon' - } - } - ] - }; - - expect(() => GeoJSONZodSchema.parse(geoJson)).not.to.throw(); - }); - - it('validates and returns correct geo json (GeometryCollection)', () => { - const geoJson = { - type: 'GeometryCollection', - geometries: [ - { - coordinates: [-120.89455551309156, 50.55558184778528], - type: 'Point' - }, - { - coordinates: [ - [-122.7523690343273, 50.14949756562072], - [-121.50892156862892, 50.1424249906992] - ], - type: 'LineString' - }, - { - coordinates: [ - [ - [-124.11504035918227, 51.08084984848492], - [-124.11504035918227, 48.947203083932465], - [-118.88808742345168, 48.947203083932465], - [-118.88808742345168, 51.08084984848492], - [-124.11504035918227, 51.08084984848492] - ] - ], - type: 'Polygon' - }, - { - coordinates: [ - [-120.89455551309156, 50.55558184778528], - [-120.89455551309156, 50.55558184778528] - ], - type: 'MultiPoint' - }, - { - coordinates: [ - [ - [-122.7523690343273, 50.14949756562072], - [-121.50892156862892, 50.1424249906992] - ], - [ - [-122.7523690343273, 50.14949756562072], - [-121.50892156862892, 50.1424249906992] - ] - ], - type: 'MultiLineString' - }, - { - coordinates: [ - [ - [ - [-124.11504035918227, 51.08084984848492], - [-124.11504035918227, 48.947203083932465], - [-118.88808742345168, 48.947203083932465], - [-118.88808742345168, 51.08084984848492], - [-124.11504035918227, 51.08084984848492] - ] - ], - [ - [ - [-124.11504035918227, 51.08084984848492], - [-124.11504035918227, 48.947203083932465], - [-118.88808742345168, 48.947203083932465], - [-118.88808742345168, 51.08084984848492], - [-124.11504035918227, 51.08084984848492] - ] - ] - ], - type: 'MultiPolygon' - } - ] - }; - - expect(() => GeoJSONZodSchema.parse(geoJson)).not.to.throw(); - }); - - it('validates and returns correct geo json (Polygon)', () => { - const geoJson = { - coordinates: [ - [ - [-124.11504035918227, 51.08084984848492], - [-124.11504035918227, 48.947203083932465], - [-118.88808742345168, 48.947203083932465], - [-118.88808742345168, 51.08084984848492], - [-124.11504035918227, 51.08084984848492] - ] - ], - type: 'Polygon' - }; - - expect(() => GeoJSONZodSchema.parse(geoJson)).not.to.throw(); - }); - - it('throws an exception on incorrect geo json', () => { - const geoJson = { - type: 'Polygon', - geometry: { - coordinates: [-120.89455551309156, 50.55558184778528], - type: 'Point' - }, - properties: { - prop0: 'value0' - } - }; - - expect(() => GeoJSONZodSchema.parse(geoJson)).to.throw(ZodError); - }); - }); }); diff --git a/api/src/zod-schema/geoJsonZodSchema.ts b/api/src/zod-schema/geoJsonZodSchema.ts index 7493a2d462..44408d817b 100644 --- a/api/src/zod-schema/geoJsonZodSchema.ts +++ b/api/src/zod-schema/geoJsonZodSchema.ts @@ -1,10 +1,5 @@ import { z } from 'zod'; -/** - * GeoJSON Zod Schemas derived from {@link https://geojson.org/schema/GeoJSON.json} and using - * {@link https://www.npmjs.com/package/json-schema-to-zod} to generate the corresponding zod schema, below. - */ - export const GeoJSONPointZodSchema = z.object({ type: z.enum(['Point']), coordinates: z.array(z.number()).min(2), @@ -43,193 +38,20 @@ export const GeoJSONMultiPolygonZodSchema = z.object({ export const GeoJSONGeometryCollectionZodSchema = z.object({ type: z.enum(['GeometryCollection']), - geometries: z.array( - z.any().superRefine((x, ctx) => { - const schemas = [ - GeoJSONPointZodSchema, - GeoJSONLineStringZodSchema, - GeoJSONPolygonZodSchema, - GeoJSONMultiPointZodSchema, - GeoJSONMultiLineStringZodSchema, - GeoJSONMultiPolygonZodSchema - ]; - const errors = schemas.reduce( - (errors: z.ZodError[], schema) => - ((result) => ('error' in result ? [...errors, result.error] : errors))(schema.safeParse(x)), - [] - ); - if (schemas.length - errors.length !== 1) { - ctx.addIssue({ - path: ctx.path, - code: 'invalid_union', - unionErrors: errors, - message: 'Invalid input: Should pass single schema' - }); - } - }) - ), + geometries: z.array(z.object({})), bbox: z.array(z.number()).min(4).optional() }); export const GeoJSONFeatureZodSchema = z.object({ type: z.enum(['Feature']), - id: z - .any() - .superRefine((x, ctx) => { - const schemas = [z.number(), z.string()]; - const errors = schemas.reduce( - (errors: z.ZodError[], schema) => - ((result) => ('error' in result ? [...errors, result.error] : errors))(schema.safeParse(x)), - [] - ); - if (schemas.length - errors.length !== 1) { - ctx.addIssue({ - path: ctx.path, - code: 'invalid_union', - unionErrors: errors, - message: 'Invalid input: Should pass single schema' - }); - } - }) - .optional(), - properties: z.any().superRefine((x, ctx) => { - const schemas = [z.record(z.any()), z.null()]; - const errors = schemas.reduce( - (errors: z.ZodError[], schema) => - ((result) => ('error' in result ? [...errors, result.error] : errors))(schema.safeParse(x)), - [] - ); - if (schemas.length - errors.length !== 1) { - ctx.addIssue({ - path: ctx.path, - code: 'invalid_union', - unionErrors: errors, - message: 'Invalid input: Should pass single schema' - }); - } - }), - geometry: z.any().superRefine((x, ctx) => { - const schemas = [ - GeoJSONPointZodSchema, - GeoJSONLineStringZodSchema, - GeoJSONPolygonZodSchema, - GeoJSONMultiPointZodSchema, - GeoJSONMultiLineStringZodSchema, - GeoJSONMultiPolygonZodSchema, - GeoJSONGeometryCollectionZodSchema, - z.null() - ]; - const errors = schemas.reduce( - (errors: z.ZodError[], schema) => - ((result) => ('error' in result ? [...errors, result.error] : errors))(schema.safeParse(x)), - [] - ); - if (schemas.length - errors.length !== 1) { - ctx.addIssue({ - path: ctx.path, - code: 'invalid_union', - unionErrors: errors, - message: 'Invalid input: Should pass single schema' - }); - } - }), + id: z.union([z.number(), z.string()]).optional(), + properties: z.object({}), + geometry: z.object({}), bbox: z.array(z.number()).min(4).optional() }); export const GeoJSONFeatureCollectionZodSchema = z.object({ type: z.enum(['FeatureCollection']), - features: z.array( - z.object({ - type: z.enum(['Feature']), - id: z - .any() - .superRefine((x, ctx) => { - const schemas = [z.number(), z.string()]; - const errors = schemas.reduce( - (errors: z.ZodError[], schema) => - ((result) => ('error' in result ? [...errors, result.error] : errors))(schema.safeParse(x)), - [] - ); - if (schemas.length - errors.length !== 1) { - ctx.addIssue({ - path: ctx.path, - code: 'invalid_union', - unionErrors: errors, - message: 'Invalid input: Should pass single schema' - }); - } - }) - .optional(), - properties: z.any().superRefine((x, ctx) => { - const schemas = [z.record(z.any()), z.null()]; - const errors = schemas.reduce( - (errors: z.ZodError[], schema) => - ((result) => ('error' in result ? [...errors, result.error] : errors))(schema.safeParse(x)), - [] - ); - if (schemas.length - errors.length !== 1) { - ctx.addIssue({ - path: ctx.path, - code: 'invalid_union', - unionErrors: errors, - message: 'Invalid input: Should pass single schema' - }); - } - }), - geometry: z.any().superRefine((x, ctx) => { - const schemas = [ - GeoJSONPointZodSchema, - GeoJSONLineStringZodSchema, - GeoJSONPolygonZodSchema, - GeoJSONMultiPointZodSchema, - GeoJSONMultiLineStringZodSchema, - GeoJSONMultiPolygonZodSchema, - GeoJSONGeometryCollectionZodSchema, - z.null() - ]; - const errors = schemas.reduce( - (errors: z.ZodError[], schema) => - ((result) => ('error' in result ? [...errors, result.error] : errors))(schema.safeParse(x)), - [] - ); - if (schemas.length - errors.length !== 1) { - ctx.addIssue({ - path: ctx.path, - code: 'invalid_union', - unionErrors: errors, - message: 'Invalid input: Should pass single schema' - }); - } - }), - bbox: z.array(z.number()).min(4).optional() - }) - ), + features: z.array(z.object({})), bbox: z.array(z.number()).min(4).optional() }); - -export const GeoJSONZodSchema = z.any().superRefine((x, ctx) => { - const schemas = [ - GeoJSONPointZodSchema, - GeoJSONLineStringZodSchema, - GeoJSONPolygonZodSchema, - GeoJSONMultiPointZodSchema, - GeoJSONMultiLineStringZodSchema, - GeoJSONMultiPolygonZodSchema, - GeoJSONGeometryCollectionZodSchema, - GeoJSONFeatureCollectionZodSchema, - GeoJSONFeatureZodSchema - ]; - const errors = schemas.reduce( - (errors: z.ZodError[], schema) => - ((result) => ('error' in result ? [...errors, result.error] : errors))(schema.safeParse(x)), - [] - ); - if (schemas.length - errors.length !== 1) { - ctx.addIssue({ - path: ctx.path, - code: 'invalid_union', - unionErrors: errors, - message: 'Invalid input: Should pass single schema' - }); - } -}); diff --git a/api/src/zod-schema/json.ts b/api/src/zod-schema/json.ts index 4b101baa54..01f8613f8c 100644 --- a/api/src/zod-schema/json.ts +++ b/api/src/zod-schema/json.ts @@ -1,9 +1,15 @@ import * as z from 'zod'; // Defines a Zod Schema for a valid JSON value +// Not safe of massive JSON object. Causes a Heap out of memory error const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); type Literal = z.infer; type Json = Literal | { [key: string]: Json } | Json[]; export const jsonSchema: z.ZodType = z.lazy(() => z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]) ); + +// Defines a Zod Schema for a shallow JSON value +export const shallowJsonSchema: z.ZodType = z.lazy(() => + z.union([literalSchema, z.array(z.any()), z.record(z.string()), z.record(z.any())]) +); diff --git a/api/tsconfig.json b/api/tsconfig.json index 1ae8680ddd..605eec1ca6 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -20,7 +20,8 @@ "resolveJsonModule": true, "isolatedModules": true, "noFallthroughCasesInSwitch": true, - "strict": true + "strict": true, + "typeRoots": ["node_modules/@types", "src/types"] }, "include": ["src"], "ts-node": { diff --git a/app/.gitignore b/app/.gitignore index 8dbc004cb7..dc13798b7c 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -27,3 +27,5 @@ test-report.xml npm-debug.log* yarn-debug.log* yarn-error.log* + +ts-traces diff --git a/app/.pipeline/config.js b/app/.pipeline/config.js index 1adbd81f94..e027cd4f90 100644 --- a/app/.pipeline/config.js +++ b/app/.pipeline/config.js @@ -60,12 +60,11 @@ const phases = { instance: `${name}-build-${changeId}`, version: `${version}-${changeId}`, tag: tag, - env: 'build', branch: branch, cpuRequest: '50m', - cpuLimit: '1000m', + cpuLimit: '2000m', memoryRequest: '100Mi', - memoryLimit: '5Gi' + memoryLimit: '6Gi' }, dev: { namespace: 'af2668-dev', @@ -82,14 +81,15 @@ const phases = { siteminderLogoutURL: config.siteminderLogoutURL.dev, maxUploadNumFiles, maxUploadFileSize, - env: 'dev', + nodeEnv: 'development', sso: config.sso.dev, cpuRequest: '50m', cpuLimit: '200m', memoryRequest: '100Mi', memoryLimit: '333Mi', replicas: (isStaticDeployment && '1') || '1', - replicasMax: (isStaticDeployment && '2') || '1' + replicasMax: (isStaticDeployment && '2') || '1', + biohubFeatureFlag: 'true' }, test: { namespace: 'af2668-test', @@ -105,14 +105,15 @@ const phases = { siteminderLogoutURL: config.siteminderLogoutURL.test, maxUploadNumFiles, maxUploadFileSize, - env: 'test', + nodeEnv: 'production', sso: config.sso.test, cpuRequest: '50m', cpuLimit: '500m', memoryRequest: '100Mi', memoryLimit: '500Mi', replicas: '2', - replicasMax: '3' + replicasMax: '3', + biohubFeatureFlag: 'false' }, prod: { namespace: 'af2668-prod', @@ -128,14 +129,15 @@ const phases = { siteminderLogoutURL: config.siteminderLogoutURL.prod, maxUploadNumFiles, maxUploadFileSize, - env: 'prod', + nodeEnv: 'production', sso: config.sso.prod, cpuRequest: '50m', cpuLimit: '500m', memoryRequest: '100Mi', memoryLimit: '500Mi', replicas: '2', - replicasMax: '3' + replicasMax: '3', + biohubFeatureFlag: 'false' } }; diff --git a/app/.pipeline/lib/app.build.js b/app/.pipeline/lib/app.build.js index 1e44121d3c..9f3aab0634 100644 --- a/app/.pipeline/lib/app.build.js +++ b/app/.pipeline/lib/app.build.js @@ -11,7 +11,7 @@ const path = require('path'); const appBuild = (settings) => { const phases = settings.phases; const options = settings.options; - const phase = 'build'; + const phase = settings.phase; const oc = new OpenShiftClientX(Object.assign({ namespace: phases[phase].namespace }, options)); diff --git a/app/.pipeline/lib/app.deploy.js b/app/.pipeline/lib/app.deploy.js index 2bc4df9f75..d3dc07a425 100644 --- a/app/.pipeline/lib/app.deploy.js +++ b/app/.pipeline/lib/app.deploy.js @@ -6,7 +6,7 @@ const path = require('path'); const appDeploy = async (settings) => { const phases = settings.phases; const options = settings.options; - const phase = options.env; + const phase = settings.options.env; const oc = new OpenShiftClientX(Object.assign({ namespace: phases[phase].namespace }, options)); @@ -26,19 +26,24 @@ const appDeploy = async (settings) => { CHANGE_ID: phases.build.changeId || changeId, REACT_APP_API_HOST: phases[phase].apiHost, REACT_APP_SITEMINDER_LOGOUT_URL: phases[phase].siteminderLogoutURL, + // File Upload Settings REACT_APP_MAX_UPLOAD_NUM_FILES: phases[phase].maxUploadNumFiles, REACT_APP_MAX_UPLOAD_FILE_SIZE: phases[phase].maxUploadFileSize, - NODE_ENV: phases[phase].env, - REACT_APP_NODE_ENV: phases[phase].env, + // Node + NODE_ENV: phases[phase].nodeEnv, + REACT_APP_NODE_ENV: phases[phase].nodeEnv, + // Keycloak REACT_APP_KEYCLOAK_HOST: phases[phase].sso.host, REACT_APP_KEYCLOAK_REALM: phases[phase].sso.realm, REACT_APP_KEYCLOAK_CLIENT_ID: phases[phase].sso.clientId, + // Openshift Resources CPU_REQUEST: phases[phase].cpuRequest, CPU_LIMIT: phases[phase].cpuLimit, MEMORY_REQUEST: phases[phase].memoryRequest, MEMORY_LIMIT: phases[phase].memoryLimit, REPLICAS: phases[phase].replicas, - REPLICAS_MAX: phases[phase].replicasMax + REPLICAS_MAX: phases[phase].replicasMax, + REACT_APP_BIOHUB_FEATURE_FLAG: phases[phase].biohubFeatureFlag } }) ); diff --git a/app/.pipeline/lib/clean.js b/app/.pipeline/lib/clean.js index 5f5423386d..9cd225d191 100644 --- a/app/.pipeline/lib/clean.js +++ b/app/.pipeline/lib/clean.js @@ -10,7 +10,7 @@ const { OpenShiftClientX } = require('pipeline-cli'); const clean = (settings) => { const phases = settings.phases; const options = settings.options; - const target_phase = options.env; + const target_phase = options.phase; const oc = new OpenShiftClientX(Object.assign({ namespace: phases.build.namespace }, options)); diff --git a/app/.pipeline/scripts/app.deploy.js b/app/.pipeline/scripts/app.deploy.js index ed189434a0..128a729257 100644 --- a/app/.pipeline/scripts/app.deploy.js +++ b/app/.pipeline/scripts/app.deploy.js @@ -4,7 +4,7 @@ const process = require('process'); const { appDeploy } = require('../lib/app.deploy.js'); const config = require('../config.js'); -const settings = { ...config, phase: config.options.env }; +const settings = { ...config, phase: config.options.phase }; process.on('unhandledRejection', (reason, promise) => { console.log('app deploy - unhandled rejection:', promise, 'reason:', reason); diff --git a/app/.pipeline/scripts/clean.js b/app/.pipeline/scripts/clean.js index 62c6a35451..5e7fae7b4f 100644 --- a/app/.pipeline/scripts/clean.js +++ b/app/.pipeline/scripts/clean.js @@ -3,7 +3,7 @@ const { clean } = require('../lib/clean.js'); const config = require('../config.js'); -const settings = { ...config, phase: config.options.env }; +const settings = { ...config, phase: config.options.phase }; // Cleans all build and deployment artifacts (pods, etc) clean(settings); diff --git a/app/.pipeline/templates/app.dc.yaml b/app/.pipeline/templates/app.dc.yaml index a28f22267f..dc5b3a1823 100644 --- a/app/.pipeline/templates/app.dc.yaml +++ b/app/.pipeline/templates/app.dc.yaml @@ -14,6 +14,9 @@ parameters: - name: HOST - name: CHANGE_ID value: '0' + - name: REACT_APP_BIOHUB_FEATURE_FLAG + description: Flag to indicate if the application has biohub specific features enabled + value: 'false' - name: REACT_APP_API_HOST description: API host for application backend value: '' @@ -28,10 +31,10 @@ parameters: value: '52428800' - name: NODE_ENV description: NODE_ENV specification variable - value: 'dev' + value: 'development' - name: REACT_APP_NODE_ENV description: NODE_ENV specification variable for React app - value: 'dev' + value: 'development' - name: OBJECT_STORE_SECRETS description: Secrets used to read and write to the S3 storage value: 'biohubbc-object-store' @@ -112,7 +115,8 @@ objects: role: app spec: containers: - - env: + - name: app + env: - name: CHANGE_VERSION value: ${CHANGE_ID} - name: REACT_APP_API_HOST @@ -135,6 +139,8 @@ objects: name: ${OBJECT_STORE_SECRETS} - name: NODE_ENV value: ${NODE_ENV} + - name: REACT_APP_BIOHUB_FEATURE_FLAG + value: ${REACT_APP_BIOHUB_FEATURE_FLAG} - name: REACT_APP_NODE_ENV value: ${REACT_APP_NODE_ENV} - name: VERSION @@ -147,7 +153,6 @@ objects: value: ${REACT_APP_KEYCLOAK_CLIENT_ID} image: ' ' imagePullPolicy: Always - name: app ports: - containerPort: ${{APP_PORT_DEFAULT}} protocol: TCP diff --git a/app/config-overrides.js b/app/config-overrides.js index 66a98bb79a..c991e0ddc6 100644 --- a/app/config-overrides.js +++ b/app/config-overrides.js @@ -1,5 +1,5 @@ /* - Using react-app-rewired, this file allows you to modify the default webpack configuration that react-scripts produces + Using react-app-rewired, this file allows you to modify the default webpack configuration that react-scripts produces internally, which are normally not exposed for modification. https://www.npmjs.com/package/react-app-rewired diff --git a/app/package-lock.json b/app/package-lock.json index 320cefe5b6..a97ea9dfe0 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -2875,28 +2875,10 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==" }, - "@react-keycloak/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@react-keycloak/core/-/core-3.2.0.tgz", - "integrity": "sha512-1yzU7gQzs+6E1v6hGqxy0Q+kpMHg9sEcke2yxZR29WoU8KNE8E50xS6UbI8N7rWsgyYw8r9W1cUPCOF48MYjzw==", - "requires": { - "react-fast-compare": "^3.2.0" - } - }, - "@react-keycloak/web": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@react-keycloak/web/-/web-3.4.0.tgz", - "integrity": "sha512-yKKSCyqBtn7dt+VckYOW1IM5NW999pPkxDZOXqJ6dfXPXstYhOQCkTZqh8l7UL14PkpsoaHDh7hSJH8whah01g==", - "requires": { - "@babel/runtime": "^7.9.0", - "@react-keycloak/core": "^3.2.0", - "hoist-non-react-statics": "^3.3.2" - } - }, "@react-leaflet/core": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-1.0.2.tgz", - "integrity": "sha512-QbleYZTMcgujAEyWGki8Lx6cXQqWkNtQlqf5c7NImlIp8bKW66bFpez/6EVatW7+p9WKBOEOVci/9W7WW70EZg==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==" }, "@rollup/plugin-babel": { "version": "5.3.1", @@ -3452,6 +3434,38 @@ "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", "dev": true }, + "@turf/along": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/along/-/along-6.5.0.tgz", + "integrity": "sha512-LLyWQ0AARqJCmMcIEAXF4GEu8usmd4Kbz3qk1Oy5HoRNpZX47+i5exQtmIWKdqJ1MMhW26fCTXgpsEs5zgJ5gw==", + "requires": { + "@turf/bearing": "^6.5.0", + "@turf/destination": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + } + }, + "@turf/angle": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/angle/-/angle-6.5.0.tgz", + "integrity": "sha512-4pXMbWhFofJJAOvTMCns6N4C8CMd5Ih4O2jSAG9b3dDHakj3O4yN1+Zbm+NUei+eVEZ9gFeVp9svE3aMDenIkw==", + "requires": { + "@turf/bearing": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/rhumb-bearing": "^6.5.0" + } + }, + "@turf/area": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/area/-/area-6.5.0.tgz", + "integrity": "sha512-xCZdiuojokLbQ+29qR6qoMD89hv+JAgWjLrwSEWL+3JV8IXKeNFl6XkEJz9HGkVpnXvQKJoRz4/liT+8ZZ5Jyg==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, "@turf/bbox": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.3.0.tgz", @@ -3461,29 +3475,1436 @@ "@turf/meta": "^6.3.0" } }, - "@turf/boolean-equal": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@turf/boolean-equal/-/boolean-equal-6.3.0.tgz", - "integrity": "sha512-eXr3oSHTvJYGyu/v57uNg0tnDHFnu+triwAaXtBh7lozt4d2riU8Ow71B+tjT9mBe/JRFfXIDsBWjbyB37y/6w==", + "@turf/bbox-clip": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox-clip/-/bbox-clip-6.5.0.tgz", + "integrity": "sha512-F6PaIRF8WMp8EmgU/Ke5B1Y6/pia14UAYB5TiBC668w5rVVjy5L8rTm/m2lEkkDMHlzoP9vNY4pxpNthE7rLcQ==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + } + }, + "@turf/bbox-polygon": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox-polygon/-/bbox-polygon-6.5.0.tgz", + "integrity": "sha512-+/r0NyL1lOG3zKZmmf6L8ommU07HliP4dgYToMoTxqzsWzyLjaj/OzgQ8rBmv703WJX+aS6yCmLuIhYqyufyuw==", + "requires": { + "@turf/helpers": "^6.5.0" + } + }, + "@turf/bearing": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bearing/-/bearing-6.5.0.tgz", + "integrity": "sha512-dxINYhIEMzgDOztyMZc20I7ssYVNEpSv04VbMo5YPQsqa80KO3TFvbuCahMsCAW5z8Tncc8dwBlEFrmRjJG33A==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + } + }, + "@turf/bezier-spline": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bezier-spline/-/bezier-spline-6.5.0.tgz", + "integrity": "sha512-vokPaurTd4PF96rRgGVm6zYYC5r1u98ZsG+wZEv9y3kJTuJRX/O3xIY2QnTGTdbVmAJN1ouOsD0RoZYaVoXORQ==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + } + }, + "@turf/boolean-clockwise": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-6.5.0.tgz", + "integrity": "sha512-45+C7LC5RMbRWrxh3Z0Eihsc8db1VGBO5d9BLTOAwU4jR6SgsunTfRWR16X7JUwIDYlCVEmnjcXJNi/kIU3VIw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + } + }, + "@turf/boolean-contains": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-contains/-/boolean-contains-6.5.0.tgz", + "integrity": "sha512-4m8cJpbw+YQcKVGi8y0cHhBUnYT+QRfx6wzM4GI1IdtYH3p4oh/DOBJKrepQyiDzFDaNIjxuWXBh0ai1zVwOQQ==", + "requires": { + "@turf/bbox": "^6.5.0", + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/boolean-point-on-line": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + }, + "dependencies": { + "@turf/bbox": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", + "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + } + } + }, + "@turf/boolean-crosses": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-crosses/-/boolean-crosses-6.5.0.tgz", + "integrity": "sha512-gvshbTPhAHporTlQwBJqyfW+2yV8q/mOTxG6PzRVl6ARsqNoqYQWkd4MLug7OmAqVyBzLK3201uAeBjxbGw0Ng==", + "requires": { + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/line-intersect": "^6.5.0", + "@turf/polygon-to-line": "^6.5.0" + } + }, + "@turf/boolean-disjoint": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-disjoint/-/boolean-disjoint-6.5.0.tgz", + "integrity": "sha512-rZ2ozlrRLIAGo2bjQ/ZUu4oZ/+ZjGvLkN5CKXSKBcu6xFO6k2bgqeM8a1836tAW+Pqp/ZFsTA5fZHsJZvP2D5g==", + "requires": { + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/line-intersect": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/polygon-to-line": "^6.5.0" + } + }, + "@turf/boolean-equal": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-equal/-/boolean-equal-6.3.0.tgz", + "integrity": "sha512-eXr3oSHTvJYGyu/v57uNg0tnDHFnu+triwAaXtBh7lozt4d2riU8Ow71B+tjT9mBe/JRFfXIDsBWjbyB37y/6w==", + "requires": { + "@turf/clean-coords": "^6.3.0", + "@turf/helpers": "^6.3.0", + "@turf/invariant": "^6.3.0", + "geojson-equality": "0.1.6" + } + }, + "@turf/boolean-intersects": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-intersects/-/boolean-intersects-6.5.0.tgz", + "integrity": "sha512-nIxkizjRdjKCYFQMnml6cjPsDOBCThrt+nkqtSEcxkKMhAQj5OO7o2CecioNTaX8EayqwMGVKcsz27oP4mKPTw==", + "requires": { + "@turf/boolean-disjoint": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/boolean-overlap": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-overlap/-/boolean-overlap-6.5.0.tgz", + "integrity": "sha512-8btMIdnbXVWUa1M7D4shyaSGxLRw6NjMcqKBcsTXcZdnaixl22k7ar7BvIzkaRYN3SFECk9VGXfLncNS3ckQUw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/line-intersect": "^6.5.0", + "@turf/line-overlap": "^6.5.0", + "@turf/meta": "^6.5.0", + "geojson-equality": "0.1.6" + } + }, + "@turf/boolean-parallel": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-parallel/-/boolean-parallel-6.5.0.tgz", + "integrity": "sha512-aSHJsr1nq9e5TthZGZ9CZYeXklJyRgR5kCLm5X4urz7+MotMOp/LsGOsvKvK9NeUl9+8OUmfMn8EFTT8LkcvIQ==", + "requires": { + "@turf/clean-coords": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/line-segment": "^6.5.0", + "@turf/rhumb-bearing": "^6.5.0" + } + }, + "@turf/boolean-point-in-polygon": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-6.5.0.tgz", + "integrity": "sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + } + }, + "@turf/boolean-point-on-line": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-on-line/-/boolean-point-on-line-6.5.0.tgz", + "integrity": "sha512-A1BbuQ0LceLHvq7F/P7w3QvfpmZqbmViIUPHdNLvZimFNLo4e6IQunmzbe+8aSStH9QRZm3VOflyvNeXvvpZEQ==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + } + }, + "@turf/boolean-within": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-within/-/boolean-within-6.5.0.tgz", + "integrity": "sha512-YQB3oU18Inx35C/LU930D36RAVe7LDXk1kWsQ8mLmuqYn9YdPsDQTMTkLJMhoQ8EbN7QTdy333xRQ4MYgToteQ==", + "requires": { + "@turf/bbox": "^6.5.0", + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/boolean-point-on-line": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + }, + "dependencies": { + "@turf/bbox": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", + "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + } + } + }, + "@turf/buffer": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/buffer/-/buffer-6.5.0.tgz", + "integrity": "sha512-qeX4N6+PPWbKqp1AVkBVWFerGjMYMUyencwfnkCesoznU6qvfugFHNAngNqIBVnJjZ5n8IFyOf+akcxnrt9sNg==", + "requires": { + "@turf/bbox": "^6.5.0", + "@turf/center": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/projection": "^6.5.0", + "d3-geo": "1.7.1", + "turf-jsts": "*" + }, + "dependencies": { + "@turf/bbox": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", + "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + } + } + }, + "@turf/center": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/center/-/center-6.5.0.tgz", + "integrity": "sha512-T8KtMTfSATWcAX088rEDKjyvQCBkUsLnK/Txb6/8WUXIeOZyHu42G7MkdkHRoHtwieLdduDdmPLFyTdG5/e7ZQ==", + "requires": { + "@turf/bbox": "^6.5.0", + "@turf/helpers": "^6.5.0" + }, + "dependencies": { + "@turf/bbox": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", + "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + } + } + }, + "@turf/center-mean": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/center-mean/-/center-mean-6.5.0.tgz", + "integrity": "sha512-AAX6f4bVn12pTVrMUiB9KrnV94BgeBKpyg3YpfnEbBpkN/znfVhL8dG8IxMAxAoSZ61Zt9WLY34HfENveuOZ7Q==", + "requires": { + "@turf/bbox": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + }, + "dependencies": { + "@turf/bbox": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", + "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + } + } + }, + "@turf/center-median": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/center-median/-/center-median-6.5.0.tgz", + "integrity": "sha512-dT8Ndu5CiZkPrj15PBvslpuf01ky41DEYEPxS01LOxp5HOUHXp1oJxsPxvc+i/wK4BwccPNzU1vzJ0S4emd1KQ==", + "requires": { + "@turf/center-mean": "^6.5.0", + "@turf/centroid": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + }, + "dependencies": { + "@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==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + } + } + }, + "@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==", + "requires": { + "@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" + }, + "dependencies": { + "@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==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + } + } + }, + "@turf/centroid": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-6.4.0.tgz", + "integrity": "sha512-p78MVeJ3InVZzkBP4rpoWTUspsRqsW6a/fGuigfjizHz+YqTRXyG7HDkqoR8IwLwpQC83Nlw5kyacgMlgEbN+Q==", + "requires": { + "@turf/helpers": "^6.4.0", + "@turf/meta": "^6.4.0" + } + }, + "@turf/circle": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/circle/-/circle-6.5.0.tgz", + "integrity": "sha512-oU1+Kq9DgRnoSbWFHKnnUdTmtcRUMmHoV9DjTXu9vOLNV5OWtAAh1VZ+mzsioGGzoDNT/V5igbFOkMfBQc0B6A==", + "requires": { + "@turf/destination": "^6.5.0", + "@turf/helpers": "^6.5.0" + } + }, + "@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==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + } + }, + "@turf/clone": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/clone/-/clone-6.5.0.tgz", + "integrity": "sha512-mzVtTFj/QycXOn6ig+annKrM6ZlimreKYz6f/GSERytOpgzodbQyOgkfwru100O1KQhhjSudKK4DsQ0oyi9cTw==", + "requires": { + "@turf/helpers": "^6.5.0" + } + }, + "@turf/clusters": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/clusters/-/clusters-6.5.0.tgz", + "integrity": "sha512-Y6gfnTJzQ1hdLfCsyd5zApNbfLIxYEpmDibHUqR5z03Lpe02pa78JtgrgUNt1seeO/aJ4TG1NLN8V5gOrHk04g==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/clusters-dbscan": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/clusters-dbscan/-/clusters-dbscan-6.5.0.tgz", + "integrity": "sha512-SxZEE4kADU9DqLRiT53QZBBhu8EP9skviSyl+FGj08Y01xfICM/RR9ACUdM0aEQimhpu+ZpRVcUK+2jtiCGrYQ==", + "requires": { + "@turf/clone": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0", + "density-clustering": "1.3.0" + } + }, + "@turf/clusters-kmeans": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/clusters-kmeans/-/clusters-kmeans-6.5.0.tgz", + "integrity": "sha512-DwacD5+YO8kwDPKaXwT9DV46tMBVNsbi1IzdajZu1JDSWoN7yc7N9Qt88oi+p30583O0UPVkAK+A10WAQv4mUw==", + "requires": { + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "skmeans": "0.9.7" + } + }, + "@turf/collect": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/collect/-/collect-6.5.0.tgz", + "integrity": "sha512-4dN/T6LNnRg099m97BJeOcTA5fSI8cu87Ydgfibewd2KQwBexO69AnjEFqfPX3Wj+Zvisj1uAVIZbPmSSrZkjg==", + "requires": { + "@turf/bbox": "^6.5.0", + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/helpers": "^6.5.0", + "rbush": "2.x" + }, + "dependencies": { + "@turf/bbox": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", + "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "quickselect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-1.1.1.tgz", + "integrity": "sha512-qN0Gqdw4c4KGPsBOQafj6yj/PA6c/L63f6CaZ/DCF/xF4Esu3jVmKLUDYxghFx8Kb/O7y9tI7x2RjTSXwdK1iQ==" + }, + "rbush": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-2.0.2.tgz", + "integrity": "sha512-XBOuALcTm+O/H8G90b6pzu6nX6v2zCKiFG4BJho8a+bY6AER6t8uQUZdi5bomQc0AprCWhEGa7ncAbbRap0bRA==", + "requires": { + "quickselect": "^1.0.1" + } + } + } + }, + "@turf/combine": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/combine/-/combine-6.5.0.tgz", + "integrity": "sha512-Q8EIC4OtAcHiJB3C4R+FpB4LANiT90t17uOd851qkM2/o6m39bfN5Mv0PWqMZIHWrrosZqRqoY9dJnzz/rJxYQ==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/concave": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/concave/-/concave-6.5.0.tgz", + "integrity": "sha512-I/sUmUC8TC5h/E2vPwxVht+nRt+TnXIPRoztDFvS8/Y0+cBDple9inLSo9nnPXMXidrBlGXZ9vQx/BjZUJgsRQ==", + "requires": { + "@turf/clone": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/tin": "^6.5.0", + "topojson-client": "3.x", + "topojson-server": "3.x" + } + }, + "@turf/convex": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/convex/-/convex-6.5.0.tgz", + "integrity": "sha512-x7ZwC5z7PJB0SBwNh7JCeCNx7Iu+QSrH7fYgK0RhhNop13TqUlvHMirMLRgf2db1DqUetrAO2qHJeIuasquUWg==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0", + "concaveman": "*" + } + }, + "@turf/destination": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/destination/-/destination-6.5.0.tgz", + "integrity": "sha512-4cnWQlNC8d1tItOz9B4pmJdWpXqS0vEvv65bI/Pj/genJnsL7evI0/Xw42RvEGROS481MPiU80xzvwxEvhQiMQ==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + } + }, + "@turf/difference": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/difference/-/difference-6.5.0.tgz", + "integrity": "sha512-l8iR5uJqvI+5Fs6leNbhPY5t/a3vipUF/3AeVLpwPQcgmedNXyheYuy07PcMGH5Jdpi5gItOiTqwiU/bUH4b3A==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "polygon-clipping": "^0.15.3" + } + }, + "@turf/dissolve": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/dissolve/-/dissolve-6.5.0.tgz", + "integrity": "sha512-WBVbpm9zLTp0Bl9CE35NomTaOL1c4TQCtEoO43YaAhNEWJOOIhZMFJyr8mbvYruKl817KinT3x7aYjjCMjTAsQ==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "polygon-clipping": "^0.15.3" + } + }, + "@turf/distance": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-6.5.0.tgz", + "integrity": "sha512-xzykSLfoURec5qvQJcfifw/1mJa+5UwByZZ5TZ8iaqjGYN0vomhV9aiSLeYdUGtYRESZ+DYC/OzY+4RclZYgMg==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + } + }, + "@turf/distance-weight": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/distance-weight/-/distance-weight-6.5.0.tgz", + "integrity": "sha512-a8qBKkgVNvPKBfZfEJZnC3DV7dfIsC3UIdpRci/iap/wZLH41EmS90nM+BokAJflUHYy8PqE44wySGWHN1FXrQ==", + "requires": { + "@turf/centroid": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0" + }, + "dependencies": { + "@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==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + } + } + }, + "@turf/ellipse": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/ellipse/-/ellipse-6.5.0.tgz", + "integrity": "sha512-kuXtwFviw/JqnyJXF1mrR/cb496zDTSbGKtSiolWMNImYzGGkbsAsFTjwJYgD7+4FixHjp0uQPzo70KDf3AIBw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/rhumb-destination": "^6.5.0", + "@turf/transform-rotate": "^6.5.0" + } + }, + "@turf/envelope": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/envelope/-/envelope-6.5.0.tgz", + "integrity": "sha512-9Z+FnBWvOGOU4X+fMZxYFs1HjFlkKqsddLuMknRaqcJd6t+NIv5DWvPtDL8ATD2GEExYDiFLwMdckfr1yqJgHA==", + "requires": { + "@turf/bbox": "^6.5.0", + "@turf/bbox-polygon": "^6.5.0", + "@turf/helpers": "^6.5.0" + }, + "dependencies": { + "@turf/bbox": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", + "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + } + } + }, + "@turf/explode": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/explode/-/explode-6.5.0.tgz", + "integrity": "sha512-6cSvMrnHm2qAsace6pw9cDmK2buAlw8+tjeJVXMfMyY+w7ZUi1rprWMsY92J7s2Dar63Bv09n56/1V7+tcj52Q==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/flatten": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/flatten/-/flatten-6.5.0.tgz", + "integrity": "sha512-IBZVwoNLVNT6U/bcUUllubgElzpMsNoCw8tLqBw6dfYg9ObGmpEjf9BIYLr7a2Yn5ZR4l7YIj2T7kD5uJjZADQ==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/flip": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/flip/-/flip-6.5.0.tgz", + "integrity": "sha512-oyikJFNjt2LmIXQqgOGLvt70RgE2lyzPMloYWM7OR5oIFGRiBvqVD2hA6MNw6JewIm30fWZ8DQJw1NHXJTJPbg==", + "requires": { + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/great-circle": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/great-circle/-/great-circle-6.5.0.tgz", + "integrity": "sha512-7ovyi3HaKOXdFyN7yy1yOMa8IyOvV46RC1QOQTT+RYUN8ke10eyqExwBpL9RFUPvlpoTzoYbM/+lWPogQlFncg==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + } + }, + "@turf/helpers": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.5.0.tgz", + "integrity": "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==" + }, + "@turf/hex-grid": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/hex-grid/-/hex-grid-6.5.0.tgz", + "integrity": "sha512-Ln3tc2tgZT8etDOldgc6e741Smg1CsMKAz1/Mlel+MEL5Ynv2mhx3m0q4J9IB1F3a4MNjDeVvm8drAaf9SF33g==", + "requires": { + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/intersect": "^6.5.0", + "@turf/invariant": "^6.5.0" + } + }, + "@turf/interpolate": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/interpolate/-/interpolate-6.5.0.tgz", + "integrity": "sha512-LSH5fMeiGyuDZ4WrDJNgh81d2DnNDUVJtuFryJFup8PV8jbs46lQGfI3r1DJ2p1IlEJIz3pmAZYeTfMMoeeohw==", + "requires": { + "@turf/bbox": "^6.5.0", + "@turf/centroid": "^6.5.0", + "@turf/clone": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/hex-grid": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/point-grid": "^6.5.0", + "@turf/square-grid": "^6.5.0", + "@turf/triangle-grid": "^6.5.0" + }, + "dependencies": { + "@turf/bbox": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", + "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@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==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + } + } + }, + "@turf/intersect": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/intersect/-/intersect-6.5.0.tgz", + "integrity": "sha512-2legGJeKrfFkzntcd4GouPugoqPUjexPZnOvfez+3SfIMrHvulw8qV8u7pfVyn2Yqs53yoVCEjS5sEpvQ5YRQg==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "polygon-clipping": "^0.15.3" + } + }, + "@turf/invariant": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-6.5.0.tgz", + "integrity": "sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg==", + "requires": { + "@turf/helpers": "^6.5.0" + } + }, + "@turf/isobands": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/isobands/-/isobands-6.5.0.tgz", + "integrity": "sha512-4h6sjBPhRwMVuFaVBv70YB7eGz+iw0bhPRnp+8JBdX1UPJSXhoi/ZF2rACemRUr0HkdVB/a1r9gC32vn5IAEkw==", + "requires": { + "@turf/area": "^6.5.0", + "@turf/bbox": "^6.5.0", + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/explode": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "object-assign": "*" + }, + "dependencies": { + "@turf/bbox": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", + "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + } + } + }, + "@turf/isolines": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/isolines/-/isolines-6.5.0.tgz", + "integrity": "sha512-6ElhiLCopxWlv4tPoxiCzASWt/jMRvmp6mRYrpzOm3EUl75OhHKa/Pu6Y9nWtCMmVC/RcWtiiweUocbPLZLm0A==", + "requires": { + "@turf/bbox": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "object-assign": "*" + }, + "dependencies": { + "@turf/bbox": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", + "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + } + } + }, + "@turf/kinks": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/kinks/-/kinks-6.5.0.tgz", + "integrity": "sha512-ViCngdPt1eEL7hYUHR2eHR662GvCgTc35ZJFaNR6kRtr6D8plLaDju0FILeFFWSc+o8e3fwxZEJKmFj9IzPiIQ==", + "requires": { + "@turf/helpers": "^6.5.0" + } + }, + "@turf/length": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/length/-/length-6.5.0.tgz", + "integrity": "sha512-5pL5/pnw52fck3oRsHDcSGrj9HibvtlrZ0QNy2OcW8qBFDNgZ4jtl6U7eATVoyWPKBHszW3dWETW+iLV7UARig==", + "requires": { + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/line-arc": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-arc/-/line-arc-6.5.0.tgz", + "integrity": "sha512-I6c+V6mIyEwbtg9P9zSFF89T7QPe1DPTG3MJJ6Cm1MrAY0MdejwQKOpsvNl8LDU2ekHOlz2kHpPVR7VJsoMllA==", + "requires": { + "@turf/circle": "^6.5.0", + "@turf/destination": "^6.5.0", + "@turf/helpers": "^6.5.0" + } + }, + "@turf/line-chunk": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-chunk/-/line-chunk-6.5.0.tgz", + "integrity": "sha512-i1FGE6YJaaYa+IJesTfyRRQZP31QouS+wh/pa6O3CC0q4T7LtHigyBSYjrbjSLfn2EVPYGlPCMFEqNWCOkC6zg==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/length": "^6.5.0", + "@turf/line-slice-along": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/line-intersect": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-intersect/-/line-intersect-6.5.0.tgz", + "integrity": "sha512-CS6R1tZvVQD390G9Ea4pmpM6mJGPWoL82jD46y0q1KSor9s6HupMIo1kY4Ny+AEYQl9jd21V3Scz20eldpbTVA==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/line-segment": "^6.5.0", + "@turf/meta": "^6.5.0", + "geojson-rbush": "3.x" + } + }, + "@turf/line-offset": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-offset/-/line-offset-6.5.0.tgz", + "integrity": "sha512-CEXZbKgyz8r72qRvPchK0dxqsq8IQBdH275FE6o4MrBkzMcoZsfSjghtXzKaz9vvro+HfIXal0sTk2mqV1lQTw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/line-overlap": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-overlap/-/line-overlap-6.5.0.tgz", + "integrity": "sha512-xHOaWLd0hkaC/1OLcStCpfq55lPHpPNadZySDXYiYjEz5HXr1oKmtMYpn0wGizsLwrOixRdEp+j7bL8dPt4ojQ==", + "requires": { + "@turf/boolean-point-on-line": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/line-segment": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/nearest-point-on-line": "^6.5.0", + "deep-equal": "1.x", + "geojson-rbush": "3.x" + } + }, + "@turf/line-segment": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-segment/-/line-segment-6.5.0.tgz", + "integrity": "sha512-jI625Ho4jSuJESNq66Mmi290ZJ5pPZiQZruPVpmHkUw257Pew0alMmb6YrqYNnLUuiVVONxAAKXUVeeUGtycfw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/line-slice": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-slice/-/line-slice-6.5.0.tgz", + "integrity": "sha512-vDqJxve9tBHhOaVVFXqVjF5qDzGtKWviyjbyi2QnSnxyFAmLlLnBfMX8TLQCAf2GxHibB95RO5FBE6I2KVPRuw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/nearest-point-on-line": "^6.5.0" + } + }, + "@turf/line-slice-along": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-slice-along/-/line-slice-along-6.5.0.tgz", + "integrity": "sha512-KHJRU6KpHrAj+BTgTNqby6VCTnDzG6a1sJx/I3hNvqMBLvWVA2IrkR9L9DtsQsVY63IBwVdQDqiwCuZLDQh4Ng==", + "requires": { + "@turf/bearing": "^6.5.0", + "@turf/destination": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0" + } + }, + "@turf/line-split": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-split/-/line-split-6.5.0.tgz", + "integrity": "sha512-/rwUMVr9OI2ccJjw7/6eTN53URtGThNSD5I0GgxyFXMtxWiloRJ9MTff8jBbtPWrRka/Sh2GkwucVRAEakx9Sw==", + "requires": { + "@turf/bbox": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/line-intersect": "^6.5.0", + "@turf/line-segment": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/nearest-point-on-line": "^6.5.0", + "@turf/square": "^6.5.0", + "@turf/truncate": "^6.5.0", + "geojson-rbush": "3.x" + }, + "dependencies": { + "@turf/bbox": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", + "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + } + } + }, + "@turf/line-to-polygon": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-to-polygon/-/line-to-polygon-6.5.0.tgz", + "integrity": "sha512-qYBuRCJJL8Gx27OwCD1TMijM/9XjRgXH/m/TyuND4OXedBpIWlK5VbTIO2gJ8OCfznBBddpjiObLBrkuxTpN4Q==", + "requires": { + "@turf/bbox": "^6.5.0", + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + }, + "dependencies": { + "@turf/bbox": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", + "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + } + } + }, + "@turf/mask": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/mask/-/mask-6.5.0.tgz", + "integrity": "sha512-RQha4aU8LpBrmrkH8CPaaoAfk0Egj5OuXtv6HuCQnHeGNOQt3TQVibTA3Sh4iduq4EPxnZfDjgsOeKtrCA19lg==", + "requires": { + "@turf/helpers": "^6.5.0", + "polygon-clipping": "^0.15.3" + } + }, + "@turf/meta": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-6.5.0.tgz", + "integrity": "sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA==", + "requires": { + "@turf/helpers": "^6.5.0" + } + }, + "@turf/midpoint": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/midpoint/-/midpoint-6.5.0.tgz", + "integrity": "sha512-MyTzV44IwmVI6ec9fB2OgZ53JGNlgOpaYl9ArKoF49rXpL84F9rNATndbe0+MQIhdkw8IlzA6xVP4lZzfMNVCw==", + "requires": { + "@turf/bearing": "^6.5.0", + "@turf/destination": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0" + } + }, + "@turf/moran-index": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/moran-index/-/moran-index-6.5.0.tgz", + "integrity": "sha512-ItsnhrU2XYtTtTudrM8so4afBCYWNaB0Mfy28NZwLjB5jWuAsvyV+YW+J88+neK/ougKMTawkmjQqodNJaBeLQ==", + "requires": { + "@turf/distance-weight": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/nearest-point": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/nearest-point/-/nearest-point-6.5.0.tgz", + "integrity": "sha512-fguV09QxilZv/p94s8SMsXILIAMiaXI5PATq9d7YWijLxWUj6Q/r43kxyoi78Zmwwh1Zfqz9w+bCYUAxZ5+euA==", + "requires": { + "@turf/clone": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/nearest-point-on-line": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/nearest-point-on-line/-/nearest-point-on-line-6.5.0.tgz", + "integrity": "sha512-WthrvddddvmymnC+Vf7BrkHGbDOUu6Z3/6bFYUGv1kxw8tiZ6n83/VG6kHz4poHOfS0RaNflzXSkmCi64fLBlg==", + "requires": { + "@turf/bearing": "^6.5.0", + "@turf/destination": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/line-intersect": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/nearest-point-to-line": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/nearest-point-to-line/-/nearest-point-to-line-6.5.0.tgz", + "integrity": "sha512-PXV7cN0BVzUZdjj6oeb/ESnzXSfWmEMrsfZSDRgqyZ9ytdiIj/eRsnOXLR13LkTdXVOJYDBuf7xt1mLhM4p6+Q==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/point-to-line-distance": "^6.5.0", + "object-assign": "*" + } + }, + "@turf/planepoint": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/planepoint/-/planepoint-6.5.0.tgz", + "integrity": "sha512-R3AahA6DUvtFbka1kcJHqZ7DMHmPXDEQpbU5WaglNn7NaCQg9HB0XM0ZfqWcd5u92YXV+Gg8QhC8x5XojfcM4Q==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + } + }, + "@turf/point-grid": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/point-grid/-/point-grid-6.5.0.tgz", + "integrity": "sha512-Iq38lFokNNtQJnOj/RBKmyt6dlof0yhaHEDELaWHuECm1lIZLY3ZbVMwbs+nXkwTAHjKfS/OtMheUBkw+ee49w==", + "requires": { + "@turf/boolean-within": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + } + }, + "@turf/point-on-feature": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/point-on-feature/-/point-on-feature-6.5.0.tgz", + "integrity": "sha512-bDpuIlvugJhfcF/0awAQ+QI6Om1Y1FFYE8Y/YdxGRongivix850dTeXCo0mDylFdWFPGDo7Mmh9Vo4VxNwW/TA==", + "requires": { + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/center": "^6.5.0", + "@turf/explode": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/nearest-point": "^6.5.0" + } + }, + "@turf/point-to-line-distance": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/point-to-line-distance/-/point-to-line-distance-6.5.0.tgz", + "integrity": "sha512-opHVQ4vjUhNBly1bob6RWy+F+hsZDH9SA0UW36pIRzfpu27qipU18xup0XXEePfY6+wvhF6yL/WgCO2IbrLqEA==", + "requires": { + "@turf/bearing": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/projection": "^6.5.0", + "@turf/rhumb-bearing": "^6.5.0", + "@turf/rhumb-distance": "^6.5.0" + } + }, + "@turf/points-within-polygon": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/points-within-polygon/-/points-within-polygon-6.5.0.tgz", + "integrity": "sha512-YyuheKqjliDsBDt3Ho73QVZk1VXX1+zIA2gwWvuz8bR1HXOkcuwk/1J76HuFMOQI3WK78wyAi+xbkx268PkQzQ==", + "requires": { + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/polygon-smooth": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/polygon-smooth/-/polygon-smooth-6.5.0.tgz", + "integrity": "sha512-LO/X/5hfh/Rk4EfkDBpLlVwt3i6IXdtQccDT9rMjXEP32tRgy0VMFmdkNaXoGlSSKf/1mGqLl4y4wHd86DqKbg==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/polygon-tangents": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/polygon-tangents/-/polygon-tangents-6.5.0.tgz", + "integrity": "sha512-sB4/IUqJMYRQH9jVBwqS/XDitkEfbyqRy+EH/cMRJURTg78eHunvJ708x5r6umXsbiUyQU4eqgPzEylWEQiunw==", + "requires": { + "@turf/bbox": "^6.5.0", + "@turf/boolean-within": "^6.5.0", + "@turf/explode": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/nearest-point": "^6.5.0" + }, + "dependencies": { + "@turf/bbox": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", + "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + } + } + }, + "@turf/polygon-to-line": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/polygon-to-line/-/polygon-to-line-6.5.0.tgz", + "integrity": "sha512-5p4n/ij97EIttAq+ewSnKt0ruvuM+LIDzuczSzuHTpq4oS7Oq8yqg5TQ4nzMVuK41r/tALCk7nAoBuw3Su4Gcw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + } + }, + "@turf/polygonize": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/polygonize/-/polygonize-6.5.0.tgz", + "integrity": "sha512-a/3GzHRaCyzg7tVYHo43QUChCspa99oK4yPqooVIwTC61npFzdrmnywMv0S+WZjHZwK37BrFJGFrZGf6ocmY5w==", + "requires": { + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/envelope": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/projection": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/projection/-/projection-6.5.0.tgz", + "integrity": "sha512-/Pgh9mDvQWWu8HRxqpM+tKz8OzgauV+DiOcr3FCjD6ubDnrrmMJlsf6fFJmggw93mtVPrZRL6yyi9aYCQBOIvg==", + "requires": { + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/random": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/random/-/random-6.5.0.tgz", + "integrity": "sha512-8Q25gQ/XbA7HJAe+eXp4UhcXM9aOOJFaxZ02+XSNwMvY8gtWSCBLVqRcW4OhqilgZ8PeuQDWgBxeo+BIqqFWFQ==", + "requires": { + "@turf/helpers": "^6.5.0" + } + }, + "@turf/rectangle-grid": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/rectangle-grid/-/rectangle-grid-6.5.0.tgz", + "integrity": "sha512-yQZ/1vbW68O2KsSB3OZYK+72aWz/Adnf7m2CMKcC+aq6TwjxZjAvlbCOsNUnMAuldRUVN1ph6RXMG4e9KEvKvg==", + "requires": { + "@turf/boolean-intersects": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0" + } + }, + "@turf/rewind": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/rewind/-/rewind-6.5.0.tgz", + "integrity": "sha512-IoUAMcHWotBWYwSYuYypw/LlqZmO+wcBpn8ysrBNbazkFNkLf3btSDZMkKJO/bvOzl55imr/Xj4fi3DdsLsbzQ==", + "requires": { + "@turf/boolean-clockwise": "^6.5.0", + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/rhumb-bearing": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/rhumb-bearing/-/rhumb-bearing-6.5.0.tgz", + "integrity": "sha512-jMyqiMRK4hzREjQmnLXmkJ+VTNTx1ii8vuqRwJPcTlKbNWfjDz/5JqJlb5NaFDcdMpftWovkW5GevfnuzHnOYA==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + } + }, + "@turf/rhumb-destination": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/rhumb-destination/-/rhumb-destination-6.5.0.tgz", + "integrity": "sha512-RHNP1Oy+7xTTdRrTt375jOZeHceFbjwohPHlr9Hf68VdHHPMAWgAKqiX2YgSWDcvECVmiGaBKWus1Df+N7eE4Q==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + } + }, + "@turf/rhumb-distance": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/rhumb-distance/-/rhumb-distance-6.5.0.tgz", + "integrity": "sha512-oKp8KFE8E4huC2Z1a1KNcFwjVOqa99isxNOwfo4g3SUABQ6NezjKDDrnvC4yI5YZ3/huDjULLBvhed45xdCrzg==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + } + }, + "@turf/sample": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/sample/-/sample-6.5.0.tgz", + "integrity": "sha512-kSdCwY7el15xQjnXYW520heKUrHwRvnzx8ka4eYxX9NFeOxaFITLW2G7UtXb6LJK8mmPXI8Aexv23F2ERqzGFg==", + "requires": { + "@turf/helpers": "^6.5.0" + } + }, + "@turf/sector": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/sector/-/sector-6.5.0.tgz", + "integrity": "sha512-cYUOkgCTWqa23SOJBqxoFAc/yGCUsPRdn/ovbRTn1zNTm/Spmk6hVB84LCKOgHqvSF25i0d2kWqpZDzLDdAPbw==", + "requires": { + "@turf/circle": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/line-arc": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/shortest-path": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/shortest-path/-/shortest-path-6.5.0.tgz", + "integrity": "sha512-4de5+G7+P4hgSoPwn+SO9QSi9HY5NEV/xRJ+cmoFVRwv2CDsuOPDheHKeuIAhKyeKDvPvPt04XYWbac4insJMg==", + "requires": { + "@turf/bbox": "^6.5.0", + "@turf/bbox-polygon": "^6.5.0", + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/clean-coords": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/transform-scale": "^6.5.0" + }, + "dependencies": { + "@turf/bbox": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", + "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + } + } + }, + "@turf/simplify": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/simplify/-/simplify-6.5.0.tgz", + "integrity": "sha512-USas3QqffPHUY184dwQdP8qsvcVH/PWBYdXY5am7YTBACaQOMAlf6AKJs9FT8jiO6fQpxfgxuEtwmox+pBtlOg==", + "requires": { + "@turf/clean-coords": "^6.5.0", + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/square": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/square/-/square-6.5.0.tgz", + "integrity": "sha512-BM2UyWDmiuHCadVhHXKIx5CQQbNCpOxB6S/aCNOCLbhCeypKX5Q0Aosc5YcmCJgkwO5BERCC6Ee7NMbNB2vHmQ==", + "requires": { + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0" + } + }, + "@turf/square-grid": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/square-grid/-/square-grid-6.5.0.tgz", + "integrity": "sha512-mlR0ayUdA+L4c9h7p4k3pX6gPWHNGuZkt2c5II1TJRmhLkW2557d6b/Vjfd1z9OVaajb1HinIs1FMSAPXuuUrA==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/rectangle-grid": "^6.5.0" + } + }, + "@turf/standard-deviational-ellipse": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/standard-deviational-ellipse/-/standard-deviational-ellipse-6.5.0.tgz", + "integrity": "sha512-02CAlz8POvGPFK2BKK8uHGUk/LXb0MK459JVjKxLC2yJYieOBTqEbjP0qaWhiBhGzIxSMaqe8WxZ0KvqdnstHA==", + "requires": { + "@turf/center-mean": "^6.5.0", + "@turf/ellipse": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/points-within-polygon": "^6.5.0" + } + }, + "@turf/tag": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/tag/-/tag-6.5.0.tgz", + "integrity": "sha512-XwlBvrOV38CQsrNfrxvBaAPBQgXMljeU0DV8ExOyGM7/hvuGHJw3y8kKnQ4lmEQcmcrycjDQhP7JqoRv8vFssg==", + "requires": { + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@turf/tesselate": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/tesselate/-/tesselate-6.5.0.tgz", + "integrity": "sha512-M1HXuyZFCfEIIKkglh/r5L9H3c5QTEsnMBoZOFQiRnGPGmJWcaBissGb7mTFX2+DKE7FNWXh4TDnZlaLABB0dQ==", + "requires": { + "@turf/helpers": "^6.5.0", + "earcut": "^2.0.0" + } + }, + "@turf/tin": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/tin/-/tin-6.5.0.tgz", + "integrity": "sha512-YLYikRzKisfwj7+F+Tmyy/LE3d2H7D4kajajIfc9mlik2+esG7IolsX/+oUz1biguDYsG0DUA8kVYXDkobukfg==", + "requires": { + "@turf/helpers": "^6.5.0" + } + }, + "@turf/transform-rotate": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/transform-rotate/-/transform-rotate-6.5.0.tgz", + "integrity": "sha512-A2Ip1v4246ZmpssxpcL0hhiVBEf4L8lGnSPWTgSv5bWBEoya2fa/0SnFX9xJgP40rMP+ZzRaCN37vLHbv1Guag==", + "requires": { + "@turf/centroid": "^6.5.0", + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/rhumb-bearing": "^6.5.0", + "@turf/rhumb-destination": "^6.5.0", + "@turf/rhumb-distance": "^6.5.0" + }, + "dependencies": { + "@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==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + } + } + }, + "@turf/transform-scale": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/transform-scale/-/transform-scale-6.5.0.tgz", + "integrity": "sha512-VsATGXC9rYM8qTjbQJ/P7BswKWXHdnSJ35JlV4OsZyHBMxJQHftvmZJsFbOqVtQnIQIzf2OAly6rfzVV9QLr7g==", + "requires": { + "@turf/bbox": "^6.5.0", + "@turf/center": "^6.5.0", + "@turf/centroid": "^6.5.0", + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/rhumb-bearing": "^6.5.0", + "@turf/rhumb-destination": "^6.5.0", + "@turf/rhumb-distance": "^6.5.0" + }, + "dependencies": { + "@turf/bbox": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", + "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@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==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + } + } + }, + "@turf/transform-translate": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/transform-translate/-/transform-translate-6.5.0.tgz", + "integrity": "sha512-NABLw5VdtJt/9vSstChp93pc6oel4qXEos56RBMsPlYB8hzNTEKYtC146XJvyF4twJeeYS8RVe1u7KhoFwEM5w==", + "requires": { + "@turf/clone": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/rhumb-destination": "^6.5.0" + } + }, + "@turf/triangle-grid": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/triangle-grid/-/triangle-grid-6.5.0.tgz", + "integrity": "sha512-2jToUSAS1R1htq4TyLQYPTIsoy6wg3e3BQXjm2rANzw4wPQCXGOxrur1Fy9RtzwqwljlC7DF4tg0OnWr8RjmfA==", + "requires": { + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/intersect": "^6.5.0" + } + }, + "@turf/truncate": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/truncate/-/truncate-6.5.0.tgz", + "integrity": "sha512-pFxg71pLk+eJj134Z9yUoRhIi8vqnnKvCYwdT4x/DQl/19RVdq1tV3yqOT3gcTQNfniteylL5qV1uTBDV5sgrg==", "requires": { - "@turf/clean-coords": "^6.3.0", - "@turf/helpers": "^6.3.0", - "@turf/invariant": "^6.3.0", - "geojson-equality": "0.1.6" + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" } }, - "@turf/center-of-mass": { + "@turf/turf": { "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==", - "requires": { + "resolved": "https://registry.npmjs.org/@turf/turf/-/turf-6.5.0.tgz", + "integrity": "sha512-ipMCPnhu59bh92MNt8+pr1VZQhHVuTMHklciQURo54heoxRzt1neNYZOBR6jdL+hNsbDGAECMuIpAutX+a3Y+w==", + "requires": { + "@turf/along": "^6.5.0", + "@turf/angle": "^6.5.0", + "@turf/area": "^6.5.0", + "@turf/bbox": "^6.5.0", + "@turf/bbox-clip": "^6.5.0", + "@turf/bbox-polygon": "^6.5.0", + "@turf/bearing": "^6.5.0", + "@turf/bezier-spline": "^6.5.0", + "@turf/boolean-clockwise": "^6.5.0", + "@turf/boolean-contains": "^6.5.0", + "@turf/boolean-crosses": "^6.5.0", + "@turf/boolean-disjoint": "^6.5.0", + "@turf/boolean-equal": "^6.5.0", + "@turf/boolean-intersects": "^6.5.0", + "@turf/boolean-overlap": "^6.5.0", + "@turf/boolean-parallel": "^6.5.0", + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/boolean-point-on-line": "^6.5.0", + "@turf/boolean-within": "^6.5.0", + "@turf/buffer": "^6.5.0", + "@turf/center": "^6.5.0", + "@turf/center-mean": "^6.5.0", + "@turf/center-median": "^6.5.0", + "@turf/center-of-mass": "^6.5.0", "@turf/centroid": "^6.5.0", + "@turf/circle": "^6.5.0", + "@turf/clean-coords": "^6.5.0", + "@turf/clone": "^6.5.0", + "@turf/clusters": "^6.5.0", + "@turf/clusters-dbscan": "^6.5.0", + "@turf/clusters-kmeans": "^6.5.0", + "@turf/collect": "^6.5.0", + "@turf/combine": "^6.5.0", + "@turf/concave": "^6.5.0", "@turf/convex": "^6.5.0", + "@turf/destination": "^6.5.0", + "@turf/difference": "^6.5.0", + "@turf/dissolve": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/distance-weight": "^6.5.0", + "@turf/ellipse": "^6.5.0", + "@turf/envelope": "^6.5.0", + "@turf/explode": "^6.5.0", + "@turf/flatten": "^6.5.0", + "@turf/flip": "^6.5.0", + "@turf/great-circle": "^6.5.0", "@turf/helpers": "^6.5.0", + "@turf/hex-grid": "^6.5.0", + "@turf/interpolate": "^6.5.0", + "@turf/intersect": "^6.5.0", "@turf/invariant": "^6.5.0", - "@turf/meta": "^6.5.0" + "@turf/isobands": "^6.5.0", + "@turf/isolines": "^6.5.0", + "@turf/kinks": "^6.5.0", + "@turf/length": "^6.5.0", + "@turf/line-arc": "^6.5.0", + "@turf/line-chunk": "^6.5.0", + "@turf/line-intersect": "^6.5.0", + "@turf/line-offset": "^6.5.0", + "@turf/line-overlap": "^6.5.0", + "@turf/line-segment": "^6.5.0", + "@turf/line-slice": "^6.5.0", + "@turf/line-slice-along": "^6.5.0", + "@turf/line-split": "^6.5.0", + "@turf/line-to-polygon": "^6.5.0", + "@turf/mask": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/midpoint": "^6.5.0", + "@turf/moran-index": "^6.5.0", + "@turf/nearest-point": "^6.5.0", + "@turf/nearest-point-on-line": "^6.5.0", + "@turf/nearest-point-to-line": "^6.5.0", + "@turf/planepoint": "^6.5.0", + "@turf/point-grid": "^6.5.0", + "@turf/point-on-feature": "^6.5.0", + "@turf/point-to-line-distance": "^6.5.0", + "@turf/points-within-polygon": "^6.5.0", + "@turf/polygon-smooth": "^6.5.0", + "@turf/polygon-tangents": "^6.5.0", + "@turf/polygon-to-line": "^6.5.0", + "@turf/polygonize": "^6.5.0", + "@turf/projection": "^6.5.0", + "@turf/random": "^6.5.0", + "@turf/rewind": "^6.5.0", + "@turf/rhumb-bearing": "^6.5.0", + "@turf/rhumb-destination": "^6.5.0", + "@turf/rhumb-distance": "^6.5.0", + "@turf/sample": "^6.5.0", + "@turf/sector": "^6.5.0", + "@turf/shortest-path": "^6.5.0", + "@turf/simplify": "^6.5.0", + "@turf/square": "^6.5.0", + "@turf/square-grid": "^6.5.0", + "@turf/standard-deviational-ellipse": "^6.5.0", + "@turf/tag": "^6.5.0", + "@turf/tesselate": "^6.5.0", + "@turf/tin": "^6.5.0", + "@turf/transform-rotate": "^6.5.0", + "@turf/transform-scale": "^6.5.0", + "@turf/transform-translate": "^6.5.0", + "@turf/triangle-grid": "^6.5.0", + "@turf/truncate": "^6.5.0", + "@turf/union": "^6.5.0", + "@turf/unkink-polygon": "^6.5.0", + "@turf/voronoi": "^6.5.0" }, "dependencies": { + "@turf/bbox": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", + "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, + "@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==", + "requires": { + "@turf/clean-coords": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "geojson-equality": "0.1.6" + } + }, "@turf/centroid": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-6.5.0.tgz", @@ -3495,53 +4916,51 @@ } } }, - "@turf/centroid": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-6.4.0.tgz", - "integrity": "sha512-p78MVeJ3InVZzkBP4rpoWTUspsRqsW6a/fGuigfjizHz+YqTRXyG7HDkqoR8IwLwpQC83Nlw5kyacgMlgEbN+Q==", - "requires": { - "@turf/helpers": "^6.4.0", - "@turf/meta": "^6.4.0" - } - }, - "@turf/clean-coords": { + "@turf/union": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/clean-coords/-/clean-coords-6.5.0.tgz", - "integrity": "sha512-EMX7gyZz0WTH/ET7xV8MyrExywfm9qUi0/MY89yNffzGIEHuFfqwhcCqZ8O00rZIPZHUTxpmsxQSTfzJJA1CPw==", + "resolved": "https://registry.npmjs.org/@turf/union/-/union-6.5.0.tgz", + "integrity": "sha512-igYWCwP/f0RFHIlC2c0SKDuM/ObBaqSljI3IdV/x71805QbIvY/BYGcJdyNcgEA6cylIGl/0VSlIbpJHZ9ldhw==", "requires": { "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" + "@turf/invariant": "^6.5.0", + "polygon-clipping": "^0.15.3" } }, - "@turf/convex": { + "@turf/unkink-polygon": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/convex/-/convex-6.5.0.tgz", - "integrity": "sha512-x7ZwC5z7PJB0SBwNh7JCeCNx7Iu+QSrH7fYgK0RhhNop13TqUlvHMirMLRgf2db1DqUetrAO2qHJeIuasquUWg==", + "resolved": "https://registry.npmjs.org/@turf/unkink-polygon/-/unkink-polygon-6.5.0.tgz", + "integrity": "sha512-8QswkzC0UqKmN1DT6HpA9upfa1HdAA5n6bbuzHy8NJOX8oVizVAqfEPY0wqqTgboDjmBR4yyImsdPGUl3gZ8JQ==", "requires": { + "@turf/area": "^6.5.0", + "@turf/boolean-point-in-polygon": "^6.5.0", "@turf/helpers": "^6.5.0", "@turf/meta": "^6.5.0", - "concaveman": "*" - } - }, - "@turf/helpers": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.5.0.tgz", - "integrity": "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==" - }, - "@turf/invariant": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-6.5.0.tgz", - "integrity": "sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg==", - "requires": { - "@turf/helpers": "^6.5.0" + "rbush": "^2.0.1" + }, + "dependencies": { + "quickselect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-1.1.1.tgz", + "integrity": "sha512-qN0Gqdw4c4KGPsBOQafj6yj/PA6c/L63f6CaZ/DCF/xF4Esu3jVmKLUDYxghFx8Kb/O7y9tI7x2RjTSXwdK1iQ==" + }, + "rbush": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-2.0.2.tgz", + "integrity": "sha512-XBOuALcTm+O/H8G90b6pzu6nX6v2zCKiFG4BJho8a+bY6AER6t8uQUZdi5bomQc0AprCWhEGa7ncAbbRap0bRA==", + "requires": { + "quickselect": "^1.0.1" + } + } } }, - "@turf/meta": { + "@turf/voronoi": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-6.5.0.tgz", - "integrity": "sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA==", + "resolved": "https://registry.npmjs.org/@turf/voronoi/-/voronoi-6.5.0.tgz", + "integrity": "sha512-C/xUsywYX+7h1UyNqnydHXiun4UPjK88VDghtoRypR9cLlb7qozkiLRphQxxsCM0KxyxpVPHBVQXdAL3+Yurow==", "requires": { - "@turf/helpers": "^6.5.0" + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "d3-voronoi": "1.1.2" } }, "@types/aria-query": { @@ -3773,27 +5192,27 @@ "dev": true }, "@types/leaflet": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.3.tgz", - "integrity": "sha512-Caa1lYOgKVqDkDZVWkto2Z5JtVo09spEaUt2S69LiugbBpoqQu92HYFMGUbYezZbnBkyOxMNPXHSgRrRY5UyIA==", + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.7.tgz", + "integrity": "sha512-FOfKB1ALYUDnXkH7LfTFreWiZr9R7GErqGP+8lYQGWr2GFq5+jy3Ih0M7e9j41cvRN65kLALJ4dc43yZwyl/6g==", "dev": true, "requires": { "@types/geojson": "*" } }, "@types/leaflet-draw": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/leaflet-draw/-/leaflet-draw-1.0.7.tgz", - "integrity": "sha512-Tje5jjUC9aPmy9NSYx8HbPIVpX2VT3JyBk6wZ46PqneJzgev+UyBuK72Emvu8xaSmAEBkhlsImR7SACsdItXSw==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/leaflet-draw/-/leaflet-draw-1.0.9.tgz", + "integrity": "sha512-AlwY0sKabrHTZQqBWKqQD7SdTOBim1WgBzPKKb1SBOa2IwxOGuRCecX33Z6IhAjyZ/XCZlu/7rvMAQXxDsj7jQ==", "dev": true, "requires": { "@types/leaflet": "*" } }, "@types/leaflet-fullscreen": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/leaflet-fullscreen/-/leaflet-fullscreen-1.0.6.tgz", - "integrity": "sha512-Kd0T+YDJgtiY02iwjbt2zntGdzGZ+/4MspAJchu5WXz1uoE4EE1K4zmVAOjKFTX/fwzA6OTZBmefVyE3H+HSYg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/leaflet-fullscreen/-/leaflet-fullscreen-1.0.8.tgz", + "integrity": "sha512-vkwb0eNa3Ql/jqSmC5gUgNcUFhFcD8Uw2kVM8+Q1AhaGA0VWurrTYX6AQWeooSVvx0eVVcWz/sompX4dGIYN1w==", "dev": true, "requires": { "@types/leaflet": "*" @@ -3901,16 +5320,6 @@ "@types/react": "*" } }, - "@types/react-leaflet": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/@types/react-leaflet/-/react-leaflet-2.8.3.tgz", - "integrity": "sha512-MeBQnVQe6ikw8dkuZE4F96PvMdQeilZG6/ekk5XxhkSzU3lofedULn3UR/6G0uIHjbRazi4DA8LnLACX0bPhBg==", - "dev": true, - "requires": { - "@types/leaflet": "*", - "@types/react": "*" - } - }, "@types/react-router": { "version": "5.1.20", "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", @@ -4555,7 +5964,7 @@ "amdefine": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", - "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=" + "integrity": "sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==" }, "ansi-escapes": { "version": "4.3.2", @@ -4648,7 +6057,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", - "dev": true, "requires": { "call-bind": "^1.0.2", "is-array-buffer": "^3.0.1" @@ -4657,12 +6065,12 @@ "array-find-index": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", - "integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=" + "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==" }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, "array-includes": { "version": "3.1.6", @@ -4737,7 +6145,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.1.tgz", "integrity": "sha512-09x0ZWFEjj4WD8PDbykUwo3t9arLn8NIzmmYEJFpYekOAQjpkGSyrQhNoRTcwwcFRu+ycWF78QZ63oWTqSjBcw==", - "dev": true, "requires": { "array-buffer-byte-length": "^1.0.0", "call-bind": "^1.0.2", @@ -4777,7 +6184,7 @@ "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==" }, "ast-types-flow": { "version": "0.0.7", @@ -4794,12 +6201,12 @@ "async-foreach": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/async-foreach/-/async-foreach-0.1.3.tgz", - "integrity": "sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI=" + "integrity": "sha512-VUeSMD8nEGBWaZK4lizI1sf3yEC7pnAQ/mrI7pC2fBz2s/tq5jWWEngTwaf0Gruu/OoXRGLGg1XFqpYBiGTYJA==" }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "at-least-node": { "version": "1.0.0", @@ -4829,13 +6236,12 @@ "available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", - "dev": true + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==" }, "aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==" }, "aws4": { "version": "1.12.0", @@ -5128,7 +6534,8 @@ "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true }, "batch": { "version": "0.6.1", @@ -5139,7 +6546,7 @@ "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", "requires": { "tweetnacl": "^0.14.3" } @@ -5171,7 +6578,7 @@ "block-stream": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", - "integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=", + "integrity": "sha512-OorbnJVPII4DuUKbjARAe8u8EfqOmkEEaSFIyoQ7OjTHn6kafxWl0wLgoZ2rXaYd7MyLcDaU4TmhfxtwgcccMQ==", "requires": { "inherits": "~2.0.0" } @@ -5338,7 +6745,7 @@ "camelcase": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", - "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=" + "integrity": "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==" }, "camelcase-css": { "version": "2.0.1", @@ -5349,7 +6756,7 @@ "camelcase-keys": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", - "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=", + "integrity": "sha512-bA/Z/DERHKqoEOrp+qeGKw1QlvEQkGZSc0XaY6VnTxZr+Kv1G5zFwttpjv8qxZ/sBPT4nthwZaAcsAZTJlSKXQ==", "requires": { "camelcase": "^2.0.0", "map-obj": "^1.0.0" @@ -5381,7 +6788,7 @@ "caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" }, "chalk": { "version": "2.4.2", @@ -5521,7 +6928,7 @@ "code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", - "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==" }, "collect-v8-coverage": { "version": "1.0.2", @@ -5540,7 +6947,7 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "colord": { "version": "2.9.3", @@ -5642,7 +7049,7 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "concaveman": { "version": "1.2.1", @@ -5670,7 +7077,7 @@ "console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" }, "content-disposition": { "version": "0.5.4", @@ -5698,7 +7105,7 @@ "cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, "core-js": { "version": "3.31.1", @@ -5763,6 +7170,11 @@ } } }, + "crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "crypto-random-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", @@ -6115,11 +7527,29 @@ "currently-unhandled": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", - "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=", + "integrity": "sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==", "requires": { "array-find-index": "^1.0.1" } }, + "d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==" + }, + "d3-geo": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.7.1.tgz", + "integrity": "sha512-O4AempWAr+P5qbk2bC2FuN/sDW4z+dN2wDf9QV3bxQt4M5HfOEeXLgJ/UKQW0+o1Dj8BE+L5kiDbdWUMjsmQpw==", + "requires": { + "d3-array": "1" + } + }, + "d3-voronoi": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.2.tgz", + "integrity": "sha512-RhGS1u2vavcO7ay7ZNAPo4xeDh/VYeGof3x5ZLJBQgYhLegxr3s5IykvWmJ94FTU6mcbtp4sloqZ54mP6R4Utw==" + }, "damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -6129,7 +7559,7 @@ "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", "requires": { "assert-plus": "^1.0.0" } @@ -6156,7 +7586,7 @@ "decamelize": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==" }, "decimal.js": { "version": "10.4.3", @@ -6221,17 +7651,22 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" }, "delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + }, + "density-clustering": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/density-clustering/-/density-clustering-1.3.0.tgz", + "integrity": "sha512-icpmBubVTwLnsaor9qH/4tG5+7+f61VcqMN3V3pm9sxxSCt2Jcs0zWOgwZW9ARJYaKD3FumIgHiMOcIMRRAzFQ==" }, "depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==" }, "dequal": { "version": "2.0.3", @@ -6242,7 +7677,7 @@ "destroy": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", - "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + "integrity": "sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg==" }, "detect-newline": { "version": "3.1.0", @@ -6454,10 +7889,15 @@ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", "dev": true }, + "earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==" + }, "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", "requires": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" @@ -6466,7 +7906,7 @@ "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "ejs": { "version": "3.1.9", @@ -6502,7 +7942,7 @@ "encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" }, "enhanced-resolve": { "version": "5.15.0", @@ -6541,7 +7981,6 @@ "version": "1.22.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.1.tgz", "integrity": "sha512-ioRRcXMO6OFyRpyzV3kE1IIBd4WG5/kltnzdxSCqoP8CMGs/Li+M1uF5o7lOkZVFjDs+NLesthnF66Pg/0q0Lw==", - "dev": true, "requires": { "array-buffer-byte-length": "^1.0.0", "arraybuffer.prototype.slice": "^1.0.1", @@ -6625,7 +8064,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz", "integrity": "sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==", - "dev": true, "requires": { "get-intrinsic": "^1.1.3", "has": "^1.0.3", @@ -6645,7 +8083,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, "requires": { "is-callable": "^1.1.4", "is-date-object": "^1.0.1", @@ -6660,12 +8097,12 @@ "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" }, "escodegen": { "version": "2.1.0", @@ -7351,7 +8788,7 @@ "etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" }, "eventemitter3": { "version": "4.0.7", @@ -7484,7 +8921,7 @@ "extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==" }, "fast-deep-equal": { "version": "3.1.3", @@ -7667,7 +9104,7 @@ "find-up": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", - "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", + "integrity": "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA==", "requires": { "path-exists": "^2.0.0", "pinkie-promise": "^2.0.0" @@ -7709,7 +9146,6 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "dev": true, "requires": { "is-callable": "^1.1.3" } @@ -7717,7 +9153,7 @@ "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==" }, "fork-ts-checker-webpack-plugin": { "version": "6.5.3", @@ -7908,7 +9344,7 @@ "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" }, "fs-constants": { "version": "1.0.0", @@ -7936,7 +9372,7 @@ "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "fsevents": { "version": "2.3.2", @@ -7965,7 +9401,6 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", - "dev": true, "requires": { "call-bind": "^1.0.2", "define-properties": "^1.1.3", @@ -7981,7 +9416,7 @@ "gauge": { "version": "2.7.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", - "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", + "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==", "requires": { "aproba": "^1.0.3", "console-control-strings": "^1.0.0", @@ -8009,11 +9444,30 @@ "geojson-equality": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/geojson-equality/-/geojson-equality-0.1.6.tgz", - "integrity": "sha1-oXE3TvBD5dR5eZWEC65GSOB1LXI=", + "integrity": "sha512-TqG8YbqizP3EfwP5Uw4aLu6pKkg6JQK9uq/XZ1lXQntvTHD1BBKJWhNpJ2M0ax6TuWMP3oyx6Oq7FCIfznrgpQ==", "requires": { "deep-equal": "^1.0.0" } }, + "geojson-rbush": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/geojson-rbush/-/geojson-rbush-3.2.0.tgz", + "integrity": "sha512-oVltQTXolxvsz1sZnutlSuLDEcQAKYC/uXt9zDzJJ6bu0W+baTI8LZBaTup5afzibEH4N3jlq2p+a152wlBJ7w==", + "requires": { + "@turf/bbox": "*", + "@turf/helpers": "6.x", + "@turf/meta": "6.x", + "@types/geojson": "7946.0.8", + "rbush": "^3.0.1" + }, + "dependencies": { + "@types/geojson": { + "version": "7946.0.8", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", + "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==" + } + } + }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -8045,7 +9499,7 @@ "get-stdin": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", - "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=" + "integrity": "sha512-F5aQMywwJ2n85s4hJPTT9RPxGmubonuB10MNYo17/xph174n2MIR33HRguhzVag10O/npM7SPk73LMZNP+FaWw==" }, "get-stream": { "version": "6.0.1", @@ -8057,7 +9511,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", - "dev": true, "requires": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.1" @@ -8066,7 +9519,7 @@ "getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", "requires": { "assert-plus": "^1.0.0" } @@ -8138,7 +9591,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", - "dev": true, "requires": { "define-properties": "^1.1.3" } @@ -8186,7 +9638,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, "requires": { "get-intrinsic": "^1.1.3" } @@ -8220,7 +9671,7 @@ "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==" }, "har-validator": { "version": "5.1.5", @@ -8248,7 +9699,7 @@ "has-ansi": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", - "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==", "requires": { "ansi-regex": "^2.0.0" } @@ -8256,13 +9707,12 @@ "has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "dev": true + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==" }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" }, "has-property-descriptors": { "version": "1.0.0", @@ -8293,7 +9743,7 @@ "has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=" + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" }, "he": { "version": "1.2.0", @@ -8506,7 +9956,7 @@ "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", "requires": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", @@ -8578,7 +10028,7 @@ "immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=" + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" }, "immer": { "version": "9.0.21", @@ -8627,7 +10077,7 @@ "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "requires": { "once": "^1.3.0", "wrappy": "1" @@ -8648,7 +10098,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", "integrity": "sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==", - "dev": true, "requires": { "get-intrinsic": "^1.2.0", "has": "^1.0.3", @@ -8673,7 +10122,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", - "dev": true, "requires": { "call-bind": "^1.0.2", "get-intrinsic": "^1.2.0", @@ -8683,13 +10131,12 @@ "is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" }, "is-bigint": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dev": true, "requires": { "has-bigints": "^1.0.1" } @@ -8707,7 +10154,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dev": true, "requires": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -8722,8 +10168,7 @@ "is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==" }, "is-core-module": { "version": "2.12.1", @@ -8761,7 +10206,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", "requires": { "number-is-nan": "^1.0.0" } @@ -8793,7 +10238,7 @@ "is-in-browser": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", - "integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU=" + "integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g==" }, "is-map": { "version": "2.0.2", @@ -8820,8 +10265,7 @@ "is-negative-zero": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", - "dev": true + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==" }, "is-number": { "version": "7.0.0", @@ -8833,7 +10277,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", - "dev": true, "requires": { "has-tostringtag": "^1.0.0" } @@ -8893,7 +10336,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", - "dev": true, "requires": { "call-bind": "^1.0.2" } @@ -8908,7 +10350,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dev": true, "requires": { "has-tostringtag": "^1.0.0" } @@ -8917,7 +10358,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dev": true, "requires": { "has-symbols": "^1.0.2" } @@ -8926,7 +10366,6 @@ "version": "1.1.10", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", - "dev": true, "requires": { "available-typed-arrays": "^1.0.5", "call-bind": "^1.0.2", @@ -8938,12 +10377,12 @@ "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" }, "is-utf8": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", - "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=" + "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==" }, "is-weakmap": { "version": "2.0.1", @@ -8955,7 +10394,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dev": true, "requires": { "call-bind": "^1.0.2" } @@ -8982,17 +10420,17 @@ "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" }, "istanbul-lib-coverage": { "version": "3.2.0", @@ -11509,11 +12947,6 @@ "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz", "integrity": "sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==" }, - "js-sha256": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", - "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==" - }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -11531,7 +12964,7 @@ "jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==" }, "jsdom": { "version": "16.7.0", @@ -11604,6 +13037,11 @@ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==" }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==" + }, "json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -11628,7 +13066,7 @@ "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" }, "json5": { "version": "2.2.3", @@ -11760,14 +13198,10 @@ "pako": "~1.0.2" } }, - "keycloak-js": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-21.1.2.tgz", - "integrity": "sha512-+6r1BvmutWGJBtibo7bcFbHWIlA7XoXRCwcA4vopeJh59Nv2Js0ju2u+t8AYth+C6Cg7/BNfO3eCTbsl/dTBHw==", - "requires": { - "base64-js": "^1.5.1", - "js-sha256": "^0.9.0" - } + "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==" }, "kind-of": { "version": "6.0.3", @@ -11813,9 +13247,9 @@ } }, "leaflet": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.7.1.tgz", - "integrity": "sha512-/xwPEBidtg69Q3HlqPdU3DnrXQOvQU/CCHA1tcDQVzOwm91YMYaILjNp7L4Eaw5Z4sOYdbBz6koWyibppd8Zqw==" + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" }, "leaflet-draw": { "version": "1.0.4", @@ -11825,12 +13259,12 @@ "leaflet-fullscreen": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/leaflet-fullscreen/-/leaflet-fullscreen-1.0.2.tgz", - "integrity": "sha1-CcYcS6xF9jsu4Sav2H5c2XZQ/Bs=" + "integrity": "sha512-1Yxm8RZg6KlKX25+hbP2H/wnOAphH7hFcvuADJFb4QZTN7uOSN9Hsci5EZpow8vtNej9OGzu59Jxmn+0qKOO9Q==" }, "leaflet.locatecontrol": { - "version": "0.76.1", - "resolved": "https://registry.npmjs.org/leaflet.locatecontrol/-/leaflet.locatecontrol-0.76.1.tgz", - "integrity": "sha512-qA92Mxs2N1jgVx+EdmxtDrdzFD+f2llPJbqaKvmW1epZMSIvD6KNsBjpQYUIxz4XtJkOleqRSwWQcrm5P5NnYw==" + "version": "0.79.0", + "resolved": "https://registry.npmjs.org/leaflet.locatecontrol/-/leaflet.locatecontrol-0.79.0.tgz", + "integrity": "sha512-h64QIHFkypYdr90lkSfjKvPvvk8/b8UnP3m9WuoWdp5p2AaCWC0T1NVwyuj4rd5U4fBW3tQt4ppmZ2LceHMIDg==" }, "leaflet.markercluster": { "version": "1.5.3", @@ -11923,7 +13357,7 @@ "path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=" + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==" } } }, @@ -11978,7 +13412,7 @@ "loud-rejection": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", - "integrity": "sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=", + "integrity": "sha512-RPNliZOFkqFumDhvYqOaNY4Uz9oJM2K9tC6JWsJJsNdhuONW4LQHRBpb0qf4pJApVffI5N39SwzWZJuEhfd7eQ==", "requires": { "currently-unhandled": "^0.4.1", "signal-exit": "^3.0.0" @@ -12037,7 +13471,7 @@ "map-obj": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", - "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=" + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==" }, "mdn-data": { "version": "2.0.4", @@ -12048,7 +13482,7 @@ "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" }, "memfs": { "version": "3.5.3", @@ -12064,10 +13498,15 @@ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" }, + "memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==" + }, "meow": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", - "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=", + "integrity": "sha512-TNdwZs0skRlpPpCUK25StC4VH+tP5GgeY1HQOOGP+lQ2xtdkN2VtT/5tiX9k3IWpkBPV9b3LsAWXn4GGi/PrSA==", "requires": { "camelcase-keys": "^2.0.0", "decamelize": "^1.1.2", @@ -12084,7 +13523,7 @@ "merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" }, "merge-stream": { "version": "2.0.0", @@ -12101,7 +13540,7 @@ "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" }, "mgrs": { "version": "1.0.0", @@ -12295,6 +13734,11 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==" + }, "no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -12375,12 +13819,12 @@ "ansi-styles": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=" + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==" }, "chalk": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", "requires": { "ansi-styles": "^2.2.1", "escape-string-regexp": "^1.0.2", @@ -12392,14 +13836,14 @@ "supports-color": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" + "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==" } } }, "nopt": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", - "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", + "integrity": "sha512-4GUt3kSEYmk4ITxzB/b9vaIDfUVWN/Ml1Fwl11IlnIG2iaJ9O6WXZ9SrYM9NLI8OCBieN2Y8SWC2oJV0RQ7qYg==", "requires": { "abbrev": "1" } @@ -12440,6 +13884,107 @@ "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", "dev": true }, + "npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "requires": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + } + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==" + }, + "path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "requires": { + "pify": "^3.0.0" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==" + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + }, + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==" + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==" + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==" + } + } + }, "npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -12472,7 +14017,7 @@ "number-is-nan": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", - "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==" }, "nwsapi": { "version": "2.2.7", @@ -12488,7 +14033,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" }, "object-hash": { "version": "3.0.0", @@ -12499,8 +14044,7 @@ "object-inspect": { "version": "1.12.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", - "dev": true + "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==" }, "object-is": { "version": "1.1.5", @@ -12520,7 +14064,6 @@ "version": "4.1.4", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", - "dev": true, "requires": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -12590,10 +14133,19 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "dev": true }, + "oidc-client-ts": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-2.4.0.tgz", + "integrity": "sha512-WijhkTrlXK2VvgGoakWJiBdfIsVGz6CFzgjNNqZU1hPKV2kyeEaJgLs7RwuiSp2WhLfWBQuLvr2SxVlZnk3N1w==", + "requires": { + "crypto-js": "^4.2.0", + "jwt-decode": "^3.1.2" + } + }, "on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", "requires": { "ee-first": "1.1.1" } @@ -12607,7 +14159,7 @@ "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "requires": { "wrappy": "1" } @@ -12649,12 +14201,12 @@ "os-homedir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", - "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=" + "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==" }, "os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==" }, "osenv": { "version": "0.1.5", @@ -12769,7 +14321,7 @@ "path-exists": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", - "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", + "integrity": "sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ==", "requires": { "pinkie-promise": "^2.0.0" } @@ -12777,7 +14329,7 @@ "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" }, "path-key": { "version": "3.1.1", @@ -12793,7 +14345,7 @@ "path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" }, "path-type": { "version": "4.0.0", @@ -12803,7 +14355,7 @@ "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" }, "picocolors": { "version": "1.0.0", @@ -12816,20 +14368,25 @@ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true }, + "pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==" + }, "pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==" }, "pinkie": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", - "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=" + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==" }, "pinkie-promise": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", - "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", "requires": { "pinkie": "^2.0.0" } @@ -12910,6 +14467,14 @@ "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", "integrity": "sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==" }, + "polygon-clipping": { + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/polygon-clipping/-/polygon-clipping-0.15.3.tgz", + "integrity": "sha512-ho0Xx5DLkgxRx/+n4O74XyJ67DcyN3Tu9bGYKsnTukGAW6ssnuak6Mwcyb1wHy9MZc9xsUWqIoiazkZB5weECg==", + "requires": { + "splaytree": "^3.1.0" + } + }, "postcss": { "version": "8.4.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.26.tgz", @@ -13836,7 +15401,7 @@ "pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" + "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==" }, "psl": { "version": "1.9.0", @@ -14163,28 +15728,23 @@ "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==", "dev": true }, - "react-fast-compare": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", - "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" - }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "react-leaflet": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-3.1.0.tgz", - "integrity": "sha512-kdZS8NYbYFPmkQr7zSDR2gkKGFeWvkxqoqcmZEckzHL4d5c85dJ2gbbqhaPDpmWWgaRw9O29uA/77qpKmK4mTQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", "requires": { - "@react-leaflet/core": "^1.0.2" + "@react-leaflet/core": "^2.1.0" } }, "react-leaflet-cluster": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/react-leaflet-cluster/-/react-leaflet-cluster-1.0.4.tgz", - "integrity": "sha512-7sUtH35vf0JQIgiRHl4DWWy9JumEAhqDHfrjOlxIfCoHdeFFtnmHvdCetz/HJswHLLatwNZicCLx5DOFZzhL6g==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/react-leaflet-cluster/-/react-leaflet-cluster-2.1.0.tgz", + "integrity": "sha512-16X7XQpRThQFC4PH4OpXHimGg19ouWmjxjtpxOeBKpvERSvIRqTx7fvhTwkEPNMFTQ8zTfddz6fRTUmUEQul7g==", "requires": { "leaflet.markercluster": "^1.5.3" } @@ -14197,6 +15757,11 @@ "prop-types": "^15.7.2" } }, + "react-oidc-context": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-2.3.1.tgz", + "integrity": "sha512-WdhmEU6odNzMk9pvOScxUkf6/1aduiI/nQryr7+iCl2VDnYLASDTIV/zy58KuK4VXG3fBaRKukc/mRpMjF9a3Q==" + }, "react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", @@ -15322,7 +16887,7 @@ "read-pkg-up": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", - "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", + "integrity": "sha512-WD9MTlNtI55IwYUS27iHh9tK3YoIVhxis8yKhLpTqWtml739uXc9NWTpxoHkfZf3+DkCCsXox94/VWZniuZm6A==", "requires": { "find-up": "^1.0.0", "read-pkg": "^1.0.0" @@ -15547,7 +17112,7 @@ "repeating": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", - "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", + "integrity": "sha512-ZqtSMuVybkISo2OWvqvm7iHSWngvdaW3IpsT9/uP8v4gMi591LY6h35wdOfvQdWCKFWZWm2Y1Opp4kV7vQKT6A==", "requires": { "is-finite": "^1.0.0" } @@ -15594,7 +17159,7 @@ "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" }, "require-from-string": { "version": "2.0.2", @@ -15794,7 +17359,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.0.tgz", "integrity": "sha512-9dVEFruWIsnie89yym+xWTAYASdpw3CJV7Li/6zBewGf9z2i1j31rP6jnY0pHEO4QZh6N0K11bFjWmdR8UGdPQ==", - "dev": true, "requires": { "call-bind": "^1.0.2", "get-intrinsic": "^1.2.0", @@ -15805,8 +17369,7 @@ "isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" } } }, @@ -15819,7 +17382,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", - "dev": true, "requires": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", @@ -15896,7 +17458,7 @@ "scss-tokenizer": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz", - "integrity": "sha1-jrBtualyMzOCTT9VMGQRSYR85dE=", + "integrity": "sha512-dYE8LhncfBUar6POCxMTm0Ln+erjeczqEvCJib5/7XNkdw1FkUGgwMPY360FY0FgPWQxHWCx29Jl3oejyGLM9Q==", "requires": { "js-base64": "^2.1.8", "source-map": "^0.4.2" @@ -15905,7 +17467,7 @@ "source-map": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", - "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", + "integrity": "sha512-Y8nIfcb1s/7DcobUz1yOO1GSp7gyL+D9zLHDehT7iRESqGSxjJ448Sg7rvfgsRJCnKLdSl11uGf0s9X80cH0/A==", "requires": { "amdefine": ">=0.0.4" } @@ -16053,7 +17615,7 @@ "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" }, "setprototypeof": { "version": "1.2.0", @@ -16078,8 +17640,7 @@ "shell-quote": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", - "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", - "dev": true + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==" }, "shpjs": { "version": "3.6.3", @@ -16096,7 +17657,7 @@ "lru-cache": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz", - "integrity": "sha1-bUUk6LlV+V1PW1iFHOId1y+06VI=" + "integrity": "sha512-WpibWJ60c3AgAz8a2iYErDrcT2C7OmKnsWhIcHOjkUHFjkXncJhtLxNSqUmxRxRunpb5I8Vprd7aNSd2NtksJQ==" } } }, @@ -16104,7 +17665,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, "requires": { "call-bind": "^1.0.0", "get-intrinsic": "^1.0.2", @@ -16122,6 +17682,11 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "dev": true }, + "skmeans": { + "version": "0.9.7", + "resolved": "https://registry.npmjs.org/skmeans/-/skmeans-0.9.7.tgz", + "integrity": "sha512-hNj1/oZ7ygsfmPZ7ZfN5MUBRoGg1gtpnImuJBgLO0ljQ67DtJuiQaiYdS4lUA6s0KCwnPhGivtC/WRwIZLkHyg==" + }, "slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -16148,7 +17713,7 @@ "source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==" }, "source-map-js": { "version": "1.0.2", @@ -16270,6 +17835,11 @@ } } }, + "splaytree": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/splaytree/-/splaytree-3.1.2.tgz", + "integrity": "sha512-4OM2BJgC5UzrhVnnJA4BkHKGtjXNzzUfpQjCO8I05xYPsfS/VuQDwjCGGMi8rYQilHEV4j8NBqTFbls/PZEE7A==" + }, "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -16324,7 +17894,7 @@ "statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==" }, "stdout-stream": { "version": "1.4.1", @@ -16402,7 +17972,7 @@ "string-width": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", - "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -16425,11 +17995,20 @@ "side-channel": "^1.0.4" } }, + "string.prototype.padend": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.5.tgz", + "integrity": "sha512-DOB27b/2UTTD+4myKUFh+/fXWcu/UDyASIXfg+7VzoCNNGOfWvoyU/x5pvVHr++ztyt/oSYI1BcWBBG/hmlNjA==", + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, "string.prototype.trim": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.7.tgz", "integrity": "sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==", - "dev": true, "requires": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -16440,7 +18019,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", - "dev": true, "requires": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -16451,7 +18029,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", - "dev": true, "requires": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -16487,7 +18064,7 @@ "strip-ansi": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", "requires": { "ansi-regex": "^2.0.0" } @@ -16495,7 +18072,7 @@ "strip-bom": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", - "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", + "integrity": "sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==", "requires": { "is-utf8": "^0.2.0" } @@ -16924,7 +18501,7 @@ "to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=" + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==" }, "to-regex-range": { "version": "5.0.1", @@ -16940,10 +18517,40 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, + "topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "requires": { + "commander": "2" + }, + "dependencies": { + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + } + } + }, + "topojson-server": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/topojson-server/-/topojson-server-3.0.1.tgz", + "integrity": "sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw==", + "requires": { + "commander": "2" + }, + "dependencies": { + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + } + } + }, "toposort": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", - "integrity": "sha1-riF2gXXRVZ1IvvNUILL0li8JwzA=" + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" }, "tough-cookie": { "version": "2.5.0", @@ -16966,7 +18573,7 @@ "trim-newlines": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", - "integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM=" + "integrity": "sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw==" }, "true-case-path": { "version": "1.0.3", @@ -17042,15 +18649,20 @@ "tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "requires": { "safe-buffer": "^5.0.1" } }, + "turf-jsts": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/turf-jsts/-/turf-jsts-1.2.3.tgz", + "integrity": "sha512-Ja03QIJlPuHt4IQ2FfGex4F4JAr8m3jpaHbFbQrgwr7s7L6U8ocrHiF3J1+wf9jzhGKxvDeaCAnGDot8OjGFyA==" + }, "tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" }, "type-check": { "version": "0.4.0", @@ -17086,7 +18698,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", - "dev": true, "requires": { "call-bind": "^1.0.2", "get-intrinsic": "^1.2.1", @@ -17097,7 +18708,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", - "dev": true, "requires": { "call-bind": "^1.0.2", "for-each": "^0.3.3", @@ -17109,7 +18719,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", - "dev": true, "requires": { "available-typed-arrays": "^1.0.5", "call-bind": "^1.0.2", @@ -17122,7 +18731,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", - "dev": true, "requires": { "call-bind": "^1.0.2", "for-each": "^0.3.3", @@ -17147,7 +18755,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", - "dev": true, "requires": { "call-bind": "^1.0.2", "has-bigints": "^1.0.2", @@ -17201,7 +18808,7 @@ "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" }, "unquote": { "version": "1.1.1", @@ -17258,7 +18865,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "util.promisify": { "version": "1.0.1", @@ -17281,7 +18888,7 @@ "utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" }, "uuid": { "version": "8.3.2", @@ -17316,12 +18923,12 @@ "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, "verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", "requires": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", @@ -17668,7 +19275,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dev": true, "requires": { "is-bigint": "^1.0.1", "is-boolean-object": "^1.1.0", @@ -17698,7 +19304,6 @@ "version": "1.1.10", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.10.tgz", "integrity": "sha512-uxoA5vLUfRPdjCuJ1h5LlYdmTLbYfums398v3WLkM+i/Wltl2/XyZpQWKbN++ck5L64SR/grOHqtXCUKmlZPNA==", - "dev": true, "requires": { "available-typed-arrays": "^1.0.5", "call-bind": "^1.0.2", @@ -18025,7 +19630,7 @@ "is-fullwidth-code-point": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==" }, "string-width": { "version": "3.1.0", @@ -18050,7 +19655,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "write-file-atomic": { "version": "4.0.2", @@ -18071,7 +19676,7 @@ "xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", - "integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU=", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", "dev": true }, "xml-name-validator": { @@ -18134,7 +19739,7 @@ "is-fullwidth-code-point": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==" }, "string-width": { "version": "3.1.0", diff --git a/app/package.json b/app/package.json index 26af7df256..9b93773455 100644 --- a/app/package.json +++ b/app/package.json @@ -18,7 +18,8 @@ "lint": "eslint src/ --ext .jsx,.js,.ts,.tsx", "lint-fix": "npm run lint -- --fix", "format": "prettier --check \"./src/**/*.{js,jsx,ts,tsx,css,scss}\"", - "format-fix": "prettier --write \"./src/**/*.{js,jsx,ts,tsx,json,css,scss}\"" + "format-fix": "prettier --write \"./src/**/*.{js,jsx,ts,tsx,json,css,scss}\"", + "fix": "npm-run-all -l -s lint-fix format-fix" }, "engines": { "node": ">= 14.0.0", @@ -39,33 +40,35 @@ "@mui/x-data-grid": "^6.3.1", "@mui/x-data-grid-pro": "^6.12.1", "@mui/x-date-pickers": "^6.11.0", - "@react-keycloak/web": "^3.4.0", - "@react-leaflet/core": "~1.0.2", + "@react-leaflet/core": "~2.1.0", "@tmcw/togeojson": "~4.2.0", "@turf/bbox": "~6.3.0", "@turf/boolean-equal": "~6.3.0", "@turf/center-of-mass": "~6.5.0", "@turf/centroid": "~6.4.0", + "@turf/turf": "^6.5.0", "axios": "~0.21.4", "clsx": "~1.2.1", "express": "~4.17.1", "formik": "~2.4.1", - "keycloak-js": "^21.1.1", - "leaflet": "~1.7.1", + "leaflet": "~1.9.4", "leaflet-draw": "~1.0.4", "leaflet-fullscreen": "~1.0.2", - "leaflet.locatecontrol": "~0.76.0", + "leaflet.locatecontrol": "~0.79.0", "lodash-es": "~4.17.21", "moment": "~2.29.4", "node-sass": "~4.14.1", + "npm-run-all": "^4.1.5", + "oidc-client-ts": "^2.3.0", "proj4": "^2.9.0", "qs": "~6.9.4", "react": "^17.0.2", "react-dom": "^17.0.2", "react-dropzone": "~11.3.2", - "react-leaflet": "~3.1.0", - "react-leaflet-cluster": "~1.0.3", + "react-leaflet": "~4.2.1", + "react-leaflet-cluster": "~2.1.0", "react-number-format": "~4.5.2", + "react-oidc-context": "^2.3.1", "react-router": "^5.3.3", "react-router-dom": "^5.3.3", "react-window": "~1.8.6", @@ -83,9 +86,9 @@ "@testing-library/user-event": "~12.8.3", "@types/geojson": "~7946.0.7", "@types/jest": "~29.5.2", - "@types/leaflet": "^1.8.0", - "@types/leaflet-draw": "^1.0.5", - "@types/leaflet-fullscreen": "~1.0.6", + "@types/leaflet": "^1.9.7", + "@types/leaflet-draw": "^1.0.9", + "@types/leaflet-fullscreen": "~1.0.8", "@types/lodash-es": "~4.17.4", "@types/node": "~14.14.31", "@types/node-sass": "~4.11.2", @@ -93,7 +96,6 @@ "@types/qs": "~6.9.5", "@types/react": "^18.0.17", "@types/react-dom": "^18.0.6", - "@types/react-leaflet": "~2.8.2", "@types/react-router": "^5.1.20", "@types/react-router-dom": "^5.3.3", "@types/react-window": "~1.8.2", diff --git a/app/public/assets/icon/circle-medium.svg b/app/public/assets/icon/circle-medium.svg new file mode 100644 index 0000000000..28796ab32e --- /dev/null +++ b/app/public/assets/icon/circle-medium.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/server/index.js b/app/server/index.js index 3b2017e5c0..e6087cdcb8 100644 --- a/app/server/index.js +++ b/app/server/index.js @@ -48,7 +48,7 @@ const request = require('request'); REACT_APP_NODE_ENV: process.env.REACT_APP_NODE_ENV || 'dev', VERSION: `${process.env.VERSION || 'NA'}(build #${process.env.CHANGE_VERSION || 'NA'})`, KEYCLOAK_CONFIG: { - url: process.env.REACT_APP_KEYCLOAK_HOST, + authority: process.env.REACT_APP_KEYCLOAK_HOST, realm: process.env.REACT_APP_KEYCLOAK_REALM, clientId: process.env.REACT_APP_KEYCLOAK_CLIENT_ID }, diff --git a/app/src/App.tsx b/app/src/App.tsx index 0e87f8cf2b..6f063d6363 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,16 +1,15 @@ import CircularProgress from '@mui/material/CircularProgress'; import { ThemeProvider } from '@mui/material/styles'; -// Strange looking `type {}` import below, see: https://github.com/microsoft/TypeScript/issues/36812 -// TODO safe to remove this? -// import type {} from '@mui/material/themeAugmentation'; // this allows `@material-ui/lab` components to be themed -import { ReactKeycloakProvider } from '@react-keycloak/web'; import AppRouter from 'AppRouter'; -import { AuthStateContextProvider } from 'contexts/authStateContext'; +import { AuthStateContext, AuthStateContextProvider } from 'contexts/authStateContext'; import { ConfigContext, ConfigContextProvider } from 'contexts/configContext'; -import Keycloak from 'keycloak-js'; +import { WebStorageStateStore } from 'oidc-client-ts'; import React from 'react'; +import { AuthProvider, AuthProviderProps } from 'react-oidc-context'; import { BrowserRouter } from 'react-router-dom'; import appTheme from 'themes/appTheme'; +import ScrollToTop from 'utils/ScrollToTop'; +import { buildUrl } from 'utils/Utils'; const App: React.FC = () => { return ( @@ -22,19 +21,46 @@ const App: React.FC = () => { return ; } - const keycloak = new Keycloak(config.KEYCLOAK_CONFIG); + const logoutRedirectUri = config.SITEMINDER_LOGOUT_URL + ? `${config.SITEMINDER_LOGOUT_URL}?returl=${window.location.origin}&retnow=1` + : buildUrl(window.location.origin); + + const authConfig: AuthProviderProps = { + authority: `${config.KEYCLOAK_CONFIG.authority}/realms/${config.KEYCLOAK_CONFIG.realm}/`, + client_id: config.KEYCLOAK_CONFIG.clientId, + resource: config.KEYCLOAK_CONFIG.clientId, + // Default sign in redirect + redirect_uri: buildUrl(window.location.origin), + // Default sign out redirect + post_logout_redirect_uri: logoutRedirectUri, + // Automatically load additional user profile information + loadUserInfo: true, + userStore: new WebStorageStateStore({ store: window.localStorage }), + onSigninCallback: (_): void => { + // See https://github.com/authts/react-oidc-context#getting-started + window.history.replaceState({}, document.title, window.location.pathname); + } + }; return ( - }> + - - - + + {(authState) => { + if (!authState) { + return ; + } + + return ( + + + + + ); + }} + - + ); }} diff --git a/app/src/AppRouter.tsx b/app/src/AppRouter.tsx index 6a99d5e127..0fd56e03a4 100644 --- a/app/src/AppRouter.tsx +++ b/app/src/AppRouter.tsx @@ -1,26 +1,19 @@ -import { - AuthenticatedRouteGuard, - SystemRoleRouteGuard, - UnAuthenticatedRouteGuard -} from 'components/security/RouteGuards'; +import { AuthenticatedRouteGuard, SystemRoleRouteGuard } from 'components/security/RouteGuards'; import { SYSTEM_ROLE } from 'constants/roles'; import { CodesContextProvider } from 'contexts/codesContext'; 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 SearchPage from 'features/search/SearchPage'; import BaseLayout from 'layouts/BaseLayout'; -import RequestSubmitted from 'pages/200/RequestSubmitted'; import AccessDenied from 'pages/403/AccessDenied'; import NotFoundPage from 'pages/404/NotFoundPage'; import AccessRequestPage from 'pages/access/AccessRequestPage'; -import LoginPage from 'pages/authentication/LoginPage'; -import LogOutPage from 'pages/authentication/LogOutPage'; +import RequestSubmitted from 'pages/access/RequestSubmitted'; import { LandingPage } from 'pages/landing/LandingPage'; import { Playground } from 'pages/Playground'; import React from 'react'; -import { Redirect, Route, Switch, useLocation } from 'react-router-dom'; +import { Redirect, Switch, useLocation } from 'react-router-dom'; import RouteWithTitle from 'utils/RouteWithTitle'; import { getTitle } from 'utils/Utils'; @@ -53,7 +46,9 @@ const AppRouter: React.FC = () => { - + + + @@ -87,16 +82,6 @@ const AppRouter: React.FC = () => { - - - - - - - - - - @@ -115,20 +100,6 @@ const AppRouter: React.FC = () => { - - - - - - - - - - - - - - diff --git a/app/src/components/attachments/list/AttachmentsList.test.tsx b/app/src/components/attachments/list/AttachmentsList.test.tsx index d21be844a1..fb25a5f599 100644 --- a/app/src/components/attachments/list/AttachmentsList.test.tsx +++ b/app/src/components/attachments/list/AttachmentsList.test.tsx @@ -1,9 +1,11 @@ +import { AuthStateContext } from 'contexts/authStateContext'; import { IProjectContext, ProjectContext } from 'contexts/projectContext'; import { ISurveyContext, SurveyContext } from 'contexts/surveyContext'; import { createMemoryHistory } from 'history'; import { DataLoader } from 'hooks/useDataLoader'; import { IGetSurveyAttachment } from 'interfaces/useSurveyApi.interface'; import { Router } from 'react-router'; +import { getMockAuthState, SystemAdminAuthState } from 'test-helpers/auth-helpers'; import { cleanup, fireEvent, render, waitFor } from 'test-helpers/test-utils'; import { AttachmentType } from '../../../constants/attachments'; import AttachmentsList from './AttachmentsList'; @@ -63,20 +65,24 @@ describe('AttachmentsList', () => { } as unknown as DataLoader } as unknown as IProjectContext; + const authState = getMockAuthState({ base: SystemAdminAuthState }); + const { getByText } = render( - - - - - - - + + + + + + + + + ); expect(getByText('No Documents')).toBeInTheDocument(); @@ -99,20 +105,24 @@ describe('AttachmentsList', () => { } as unknown as DataLoader } as unknown as IProjectContext; + const authState = getMockAuthState({ base: SystemAdminAuthState }); + const { getByText } = render( - - - - - - - + + + + + + + + + ); expect(getByText('filename.test')).toBeInTheDocument(); @@ -140,20 +150,25 @@ describe('AttachmentsList', () => { } as unknown as IProjectContext; const handleDownload = jest.fn(); + + const authState = getMockAuthState({ base: SystemAdminAuthState }); + const { getByText } = render( - - - - - - - + + + + + + + + + ); expect(getByText('filename.test')).toBeInTheDocument(); @@ -183,21 +198,27 @@ describe('AttachmentsList', () => { } as unknown as IProjectContext; window.open = jest.fn(); + const handleDownload = jest.fn(); + + const authState = getMockAuthState({ base: SystemAdminAuthState }); + const { getByText } = render( - - - - - - - + + + + + + + + + ); expect(getByText('filename.test')).toBeInTheDocument(); diff --git a/app/src/components/boundary/InferredLocationDetails.tsx b/app/src/components/boundary/InferredLocationDetails.tsx index d403f0634c..ec8fc9c834 100644 --- a/app/src/components/boundary/InferredLocationDetails.tsx +++ b/app/src/components/boundary/InferredLocationDetails.tsx @@ -1,41 +1,5 @@ -import { Theme } from '@mui/material'; import Box from '@mui/material/Box'; -import { grey } from '@mui/material/colors'; -import Divider from '@mui/material/Divider'; import Typography from '@mui/material/Typography'; -import { makeStyles } from '@mui/styles'; -import React from 'react'; - -const useStyles = makeStyles((theme: Theme) => ({ - boundaryGroup: { - clear: 'both', - overflow: 'hidden', - '&:first-child': { - marginTop: 0 - } - }, - boundaryList: { - margin: 0, - padding: 0, - listStyleType: 'none', - '& li': { - display: 'inline-block', - float: 'left' - }, - '& li + li': { - marginLeft: theme.spacing(1) - } - }, - metaSectionHeader: { - color: grey[600], - fontWeight: 700, - textTransform: 'uppercase', - '& + hr': { - marginTop: theme.spacing(0.75), - marginBottom: theme.spacing(0.75) - } - } -})); export interface IInferredLayers { parks: string[]; @@ -48,30 +12,32 @@ export interface IInferredLocationDetailsProps { layers: IInferredLayers; } -const InferredLocationDetails: React.FC = (props) => { - const classes = useStyles(); - const displayInferredLayersInfo = (data: any[], type: string) => { - if (!data.length) { +const InferredLocationDetails = (props: IInferredLocationDetailsProps) => { + const displayInferredLayersInfo = (layerNames: string[], type: string) => { + if (!layerNames.length) { return; } return ( - <> - - - {type} ({data.length}) - - - - {data.map((item: string, index: number) => ( - - {item} - {index < data.length - 1 && ', '} - - ))} - + + + {type} ({layerNames.length}) + + + {layerNames.map((name: string, index: number) => ( + + {name} + {index < layerNames.length - 1 && ', '} + + ))} - + ); }; diff --git a/app/src/components/boundary/MapBoundary.tsx b/app/src/components/boundary/MapBoundary.tsx deleted file mode 100644 index b445552f49..0000000000 --- a/app/src/components/boundary/MapBoundary.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import { mdiRefresh, mdiTrayArrowUp } from '@mdi/js'; -import Icon from '@mdi/react'; -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 Grid from '@mui/material/Grid'; -import IconButton from '@mui/material/IconButton'; -import MenuItem from '@mui/material/MenuItem'; -import Paper from '@mui/material/Paper'; -import Select from '@mui/material/Select'; -import Typography from '@mui/material/Typography'; -import { makeStyles } from '@mui/styles'; -import InferredLocationDetails, { IInferredLayers } from 'components/boundary/InferredLocationDetails'; -import ComponentDialog from 'components/dialog/ComponentDialog'; -import FileUpload from 'components/file-upload/FileUpload'; -import { IUploadHandler } from 'components/file-upload/FileUploadItem'; -import MapContainer from 'components/map/MapContainer'; -import { ProjectSurveyAttachmentValidExtensions } from 'constants/attachments'; -import { FormikContextType } from 'formik'; -import { Feature } from 'geojson'; -import { LatLngBoundsExpression } from 'leaflet'; -import get from 'lodash-es/get'; -import { useEffect, useState } from 'react'; -import { - calculateUpdatedMapBounds, - handleGPXUpload, - handleKMLUpload, - handleShapeFileUpload -} from 'utils/mapBoundaryUploadHelpers'; - -const useStyles = makeStyles(() => ({ - zoomToBoundaryExtentBtn: { - padding: '3px', - borderRadius: '4px', - background: '#ffffff', - color: '#000000', - border: '2px solid rgba(0,0,0,0.2)', - backgroundClip: 'padding-box', - '&:hover': { - backgroundColor: '#eeeeee' - } - } -})); - -export interface IMapBoundaryProps { - name: string; - title: string; - mapId: string; - bounds: LatLngBoundsExpression | undefined; - formikProps: FormikContextType; -} - -/** - * Common map component. - * - * Includes support/controls for importing a boundary from a file, selecting a boundary from a layer, or drawing a - * boundary. - * - * Includes a section to display inferred boundary information (ex: what regions the boundary intersects, etc). - * - * @param {IMapBoundaryProps} props - * @return {*} - */ -const MapBoundary = (props: IMapBoundaryProps) => { - const classes = useStyles(); - - const { name, title, mapId, bounds, formikProps } = props; - - const { values, errors, setFieldValue } = formikProps; - - const [openUploadBoundary, setOpenUploadBoundary] = useState(false); - const [shouldUpdateBounds, setShouldUpdateBounds] = useState(false); - const [updatedBounds, setUpdatedBounds] = useState(undefined); - const [selectedLayer, setSelectedLayer] = useState(''); - const [inferredLayersInfo, setInferredLayersInfo] = useState({ - parks: [], - nrm: [], - env: [], - wmu: [] - }); - - useEffect(() => { - setShouldUpdateBounds(false); - }, [updatedBounds]); - - const boundaryUploadHandler = (): IUploadHandler => { - return async (file) => { - if (file?.type.includes('zip') || file?.name.includes('.zip')) { - handleShapeFileUpload(file, name, formikProps); - } else if (file?.type.includes('gpx') || file?.name.includes('.gpx')) { - await handleGPXUpload(file, name, formikProps); - } else if (file?.type.includes('kml') || file?.name.includes('.kml')) { - await handleKMLUpload(file, name, formikProps); - } - }; - }; - - return ( - <> - setOpenUploadBoundary(false)}> - - - If importing a shapefile, it must be configured with a valid projection. - - - - - - - {title} - - - Import or select a boundary from existing map layers. To select an existing boundary, choose a map layer below - and click a boundary on the map. - - - - - - - - - - - {selectedLayer && ( - - )} - - - {get(errors, name) && ( - - {get(errors, name) as string} - - )} - - - - setFieldValue(name, newGeo)} - bounds={(shouldUpdateBounds && updatedBounds) || bounds} - selectedLayer={selectedLayer} - setInferredLayersInfo={setInferredLayersInfo} - /> - {get(values, name) && get(values, name).length > 0 && ( - - { - setUpdatedBounds(calculateUpdatedMapBounds(get(values, name))); - setShouldUpdateBounds(true); - }}> - - - - )} - - {!Object.values(inferredLayersInfo).every((item: any) => !item.length) && ( - - - - )} - - - - ); -}; - -export default MapBoundary; diff --git a/app/src/components/tables/CustomDataGrid.tsx b/app/src/components/data-grid/CustomDataGrid.tsx similarity index 100% rename from app/src/components/tables/CustomDataGrid.tsx rename to app/src/components/data-grid/CustomDataGrid.tsx diff --git a/app/src/components/data-grid/DataGridValidationAlert.tsx b/app/src/components/data-grid/DataGridValidationAlert.tsx new file mode 100644 index 0000000000..219043adad --- /dev/null +++ b/app/src/components/data-grid/DataGridValidationAlert.tsx @@ -0,0 +1,164 @@ +import { mdiChevronLeft, mdiChevronRight, mdiClose } from '@mdi/js'; +import Icon from '@mdi/react'; +import { Collapse } from '@mui/material'; +import Alert from '@mui/material/Alert'; +import AlertTitle from '@mui/material/AlertTitle'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import { GridRowId } from '@mui/x-data-grid'; +import { GridApiCommunity } from '@mui/x-data-grid/internals'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +export type RowValidationError = { field: keyof T; message: string }; +export type TableValidationModel = Record[]>; + +interface ITableValidationError extends RowValidationError { + rowId: GridRowId; +} + +export interface IDataGridErrorViewerProps { + validationModel: TableValidationModel; + muiDataGridApiRef: GridApiCommunity; +} + +const DataGridValidationAlert = >(props: IDataGridErrorViewerProps) => { + const [hideAlert, setHideAlert] = useState(false); + const [index, setIndex] = useState(0); + + const sortedRowIds = useMemo( + () => props.muiDataGridApiRef?.getSortedRowIds?.() ?? [], + [props.muiDataGridApiRef.getSortedRowIds] + ); + + const sortedErrors: ITableValidationError[] = useMemo(() => { + const sortedEditableColumnNames = (props.muiDataGridApiRef?.getAllColumns?.() ?? []) + .filter((column) => column.editable) + .sort((a, b) => { + return props.muiDataGridApiRef.getColumnIndex(a.field) - props.muiDataGridApiRef.getColumnIndex(b.field); + }) + .map((column) => column.field); + + const newSortedErrors = Object.keys(props.validationModel) + .sort((a: GridRowId, b: GridRowId) => { + // Sort row errors based on the current sorting of the rows + return sortedRowIds.indexOf(a) - sortedRowIds.indexOf(b); + }) + .reduce((errors: ITableValidationError[], rowId: GridRowId) => { + props.validationModel[rowId] + .map((rowError) => ({ ...rowError, rowId })) + .sort((a: ITableValidationError, b: ITableValidationError) => { + // Sort all column errors based on the current order of the columns in the table + return ( + sortedEditableColumnNames.indexOf(String(a.field)) - sortedEditableColumnNames.indexOf(String(b.field)) + ); + }) + .forEach((error: ITableValidationError) => { + errors.push(error); + }); + + return errors; + }, []); + + return newSortedErrors; + }, [JSON.stringify(props.validationModel)]); + + const numErrors = sortedErrors.length; + + const handlePrev = useCallback(() => { + setIndex((prev) => { + const next = prev === 0 ? numErrors - 1 : prev - 1; + focusErrorAtIndex(next); + return next; + }); + }, [numErrors]); + + const handleNext = useCallback(() => { + setIndex((prev) => { + const next = prev === numErrors - 1 ? 0 : prev + 1; + focusErrorAtIndex(next); + return next; + }); + }, [numErrors]); + + const indexIndicator = useMemo(() => { + return numErrors > 0 ? `${index + 1}/${numErrors}` : '0/0'; + }, [numErrors, index]); + + const currentError = useMemo(() => { + return sortedErrors[index]; + }, [sortedErrors, index]); + + const focusErrorAtIndex = useCallback( + (errorIndex: number) => { + const focusedError = sortedErrors[errorIndex]; + if (!focusedError) { + return; + } + + const field = String(focusedError.field); + const rowIndex = props.muiDataGridApiRef.getSortedRowIds().indexOf(focusedError.rowId); + const colIndex = props.muiDataGridApiRef.getColumnIndex(field); + const pageSize = props.muiDataGridApiRef.state.pagination.paginationModel.pageSize; + const page = Math.floor((rowIndex + 1) / pageSize); + + props.muiDataGridApiRef.setPage(page); + props.muiDataGridApiRef.setCellFocus(focusedError.rowId, field); + props.muiDataGridApiRef.scrollToIndexes({ rowIndex, colIndex }); + }, + [sortedErrors] + ); + + useEffect(() => { + if (Object.keys(props.validationModel).length > 0) { + setHideAlert(false); + } + + if (index >= numErrors) { + setIndex(numErrors > 0 ? numErrors - 1 : 0); + } + }, [props.validationModel]); + + return ( + 0 && !hideAlert}> + + + + setHideAlert(true)} sx={{ ml: 2 }}> + + + + }> + Missing required fields + + Error {indexIndicator} + {currentError && `: ${currentError.message}`} + + + + ); +}; + +export default DataGridValidationAlert; diff --git a/app/src/components/data-grid/DatePickerDataGrid.tsx b/app/src/components/data-grid/DatePickerDataGrid.tsx new file mode 100644 index 0000000000..d3fd184a43 --- /dev/null +++ b/app/src/components/data-grid/DatePickerDataGrid.tsx @@ -0,0 +1,45 @@ +import useEnhancedEffect from '@mui/material/utils/useEnhancedEffect'; +import { GridRenderEditCellParams, GridValidRowModel, useGridApiContext } from '@mui/x-data-grid'; +import { DatePicker, DatePickerProps, LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'; +import moment from 'moment'; +import { useRef } from 'react'; + +interface IDatePickerDataGridProps { + dateFieldProps?: DatePickerProps; + dataGridProps: GridRenderEditCellParams; +} + +const DatePickerDataGrid = ({ + dateFieldProps, + dataGridProps +}: IDatePickerDataGridProps) => { + const apiRef = useGridApiContext(); + const ref = useRef(null); + useEnhancedEffect(() => { + if (dataGridProps.hasFocus) { + ref.current?.focus(); + } + }, [dataGridProps.hasFocus]); + return ( + + { + apiRef?.current.setEditCellValue({ id: dataGridProps.id, field: dataGridProps.field, value: value }); + }} + onAccept={(value) => { + apiRef?.current.setEditCellValue({ + id: dataGridProps.id, + field: dataGridProps.field, + value: value?.format('HH:mm:ss') + }); + }} + {...dateFieldProps} + /> + + ); +}; + +export default DatePickerDataGrid; diff --git a/app/src/components/data-grid/TextFieldDataGrid.tsx b/app/src/components/data-grid/TextFieldDataGrid.tsx new file mode 100644 index 0000000000..414f543c78 --- /dev/null +++ b/app/src/components/data-grid/TextFieldDataGrid.tsx @@ -0,0 +1,34 @@ +import TextField, { TextFieldProps } from '@mui/material/TextField/TextField'; +import useEnhancedEffect from '@mui/material/utils/useEnhancedEffect'; +import { GridRenderEditCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { useRef } from 'react'; + +interface ITextFieldCustomValidation { + textFieldProps: TextFieldProps; + dataGridProps: GridRenderEditCellParams; +} + +const TextFieldDataGrid = ({ + textFieldProps, + dataGridProps +}: ITextFieldCustomValidation) => { + const ref = useRef(); + useEnhancedEffect(() => { + if (dataGridProps.hasFocus) { + ref.current?.focus(); + } + }, [dataGridProps.hasFocus]); + return ( + + ); +}; + +export default TextFieldDataGrid; diff --git a/app/src/components/data-grid/TimePickerDataGrid.tsx b/app/src/components/data-grid/TimePickerDataGrid.tsx new file mode 100644 index 0000000000..e682a8a715 --- /dev/null +++ b/app/src/components/data-grid/TimePickerDataGrid.tsx @@ -0,0 +1,58 @@ +import useEnhancedEffect from '@mui/material/utils/useEnhancedEffect'; +import { GridRenderEditCellParams, GridValidRowModel, useGridApiContext } from '@mui/x-data-grid'; +import { LocalizationProvider, TimePicker, TimePickerProps } from '@mui/x-date-pickers'; +import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'; +import moment from 'moment'; +import { useRef } from 'react'; + +interface ITimePickerDataGridProps { + dateFieldProps?: TimePickerProps; + dataGridProps: GridRenderEditCellParams; +} + +const TimePickerDataGrid = ({ + dateFieldProps, + dataGridProps +}: ITimePickerDataGridProps) => { + const apiRef = useGridApiContext(); + const ref = useRef(null); + useEnhancedEffect(() => { + if (dataGridProps.hasFocus) { + ref.current?.focus(); + } + }, [dataGridProps.hasFocus]); + + const { slotProps, ...rest } = dateFieldProps ?? {}; + + return ( + + { + apiRef?.current.setEditCellValue({ id: dataGridProps.id, field: dataGridProps.field, value: value }); + }} + onAccept={(value) => { + apiRef?.current.setEditCellValue({ + id: dataGridProps.id, + field: dataGridProps.field, + value: value?.format('HH:mm:ss') + }); + }} + views={['hours', 'minutes', 'seconds']} + timeSteps={{ hours: 1, minutes: 1, seconds: 1 }} + ampm={false} + {...rest} + /> + + ); +}; + +export default TimePickerDataGrid; diff --git a/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx b/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx index 68d4a2c653..8b522b494d 100644 --- a/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx +++ b/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx @@ -1,13 +1,12 @@ -import { mdiMagnify } from '@mdi/js'; -import Icon from '@mdi/react'; import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete'; import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; import TextField from '@mui/material/TextField'; +import useEnhancedEffect from '@mui/material/utils/useEnhancedEffect'; import { GridRenderCellParams, GridValidRowModel, useGridApiContext } from '@mui/x-data-grid'; import { IAutocompleteDataGridOption } from 'components/data-grid/autocomplete/AutocompleteDataGrid.interface'; import { DebouncedFunc } from 'lodash-es'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; export interface IAsyncAutocompleteDataGridEditCell< DataGridType extends GridValidRowModel, @@ -37,6 +36,12 @@ export interface IAsyncAutocompleteDataGridEditCell< onSearchResults: (searchResults: IAutocompleteDataGridOption[]) => void ) => Promise >; + /** + * Indicates if there is an error with the control + * + * @memberof IAsyncAutocompleteDataGridEditCell + */ + error?: boolean; } /** @@ -54,6 +59,13 @@ const AsyncAutocompleteDataGridEditCell = (); + + useEnhancedEffect(() => { + if (dataGridProps.hasFocus) { + ref.current?.focus(); + } + }, [dataGridProps.hasFocus]); // The current data grid value const dataGridValue = dataGridProps.value; // The input field value @@ -136,7 +148,7 @@ const AsyncAutocompleteDataGridEditCell = ( - - - ), endAdornment: ( <> {isLoading ? : null} diff --git a/app/src/components/data-grid/autocomplete/AutocompleteDataGridEditCell.tsx b/app/src/components/data-grid/autocomplete/AutocompleteDataGridEditCell.tsx index 4adc182dca..7c59242d2f 100644 --- a/app/src/components/data-grid/autocomplete/AutocompleteDataGridEditCell.tsx +++ b/app/src/components/data-grid/autocomplete/AutocompleteDataGridEditCell.tsx @@ -1,8 +1,10 @@ import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete'; import Box from '@mui/material/Box'; import TextField from '@mui/material/TextField'; +import useEnhancedEffect from '@mui/material/utils/useEnhancedEffect'; import { GridRenderCellParams, GridValidRowModel, useGridApiContext } from '@mui/x-data-grid'; import { IAutocompleteDataGridOption } from 'components/data-grid/autocomplete/AutocompleteDataGrid.interface'; +import { useRef } from 'react'; export interface IAutocompleteDataGridEditCellProps< DataGridType extends GridValidRowModel, @@ -28,6 +30,13 @@ export interface IAutocompleteDataGridEditCellProps< * @memberof IAutocompleteDataGridEditCellProps */ getOptionDisabled?: (option: IAutocompleteDataGridOption) => boolean; + /** + * Indicates if the control contains an error + * + * @type {boolean} + * @memberof IAutocompleteDataGridEditCellProps + */ + error?: boolean; } /** @@ -45,6 +54,14 @@ const AutocompleteDataGridEditCell = (); + + useEnhancedEffect(() => { + if (dataGridProps.hasFocus) { + ref.current?.focus(); + } + }, [dataGridProps.hasFocus]); + // The current data grid value const dataGridValue = dataGridProps.value; @@ -70,7 +87,7 @@ const AutocompleteDataGridEditCell = ( )} renderOption={(renderProps, renderOption) => { diff --git a/app/src/components/data-grid/autocomplete/AutocompleteDataGridViewCell.tsx b/app/src/components/data-grid/autocomplete/AutocompleteDataGridViewCell.tsx index 01dc2d1954..db32a775a1 100644 --- a/app/src/components/data-grid/autocomplete/AutocompleteDataGridViewCell.tsx +++ b/app/src/components/data-grid/autocomplete/AutocompleteDataGridViewCell.tsx @@ -1,3 +1,4 @@ +import Typography from '@mui/material/Typography'; import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; import { IAutocompleteDataGridOption } from 'components/data-grid/autocomplete/AutocompleteDataGrid.interface'; @@ -19,6 +20,13 @@ export interface IAutocompleteDataGridViewCellProps< * @memberof AutocompleteDataGridViewCell */ options: IAutocompleteDataGridOption[]; + /** + * Indicates if the control contains an error + * + * @type {boolean} + * @memberof IAutocompleteDataGridViewCellProps + */ + error?: boolean; } /** @@ -33,7 +41,15 @@ const AutocompleteDataGridViewCell = ) => { const { dataGridProps, options } = props; - return <>{options.find((item) => item.value === dataGridProps.value)?.label ?? ''}; + return ( + + {options.find((item) => item.value === dataGridProps.value)?.label ?? ''} + + ); }; export default AutocompleteDataGridViewCell; diff --git a/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell.tsx b/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell.tsx index 2ba4ef3762..fadaca2fe0 100644 --- a/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell.tsx +++ b/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell.tsx @@ -28,6 +28,13 @@ export interface IConditionalAutocompleteDataGridEditCellProps< * @memberof IConditionalAutocompleteDataGridEditCellProps */ optionsGetter: (row: DataGridType, allOptions: OptionsType[]) => IAutocompleteDataGridOption[]; + /** + * Indicates if the control contains an error + * + * @type {boolean} + * @memberof IConditionalAutocompleteDataGridEditCellProps + */ + error?: boolean; } /** @@ -56,7 +63,7 @@ const ConditionalAutocompleteDataGridEditCell = < [allOptions, dataGridProps.row, optionsGetter] ); - return ; + return ; }; export default ConditionalAutocompleteDataGridEditCell; diff --git a/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell.tsx b/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell.tsx index ea5f068032..545d6c5fc7 100644 --- a/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell.tsx +++ b/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell.tsx @@ -1,3 +1,4 @@ +import { Typography } from '@mui/material'; import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; import { IAutocompleteDataGridOption } from 'components/data-grid/autocomplete/AutocompleteDataGrid.interface'; import { useMemo } from 'react'; @@ -27,6 +28,13 @@ export interface IConditionalAutocompleteDataGridViewCellProps< * @memberof IConditionalAutocompleteDataGridViewCellProps */ optionsGetter: (row: DataGridType, allOptions: OptionsType[]) => IAutocompleteDataGridOption[]; + /** + * Indicates if the control contains an error + * + * @type {boolean} + * @memberof IConditionalAutocompleteDataGridViewCellProps + */ + error?: boolean; } /** @@ -55,7 +63,11 @@ const ConditionalAutocompleteDataGridViewCell = < [allOptions, dataGridProps.row, optionsGetter] ); - return <>{options.find((item) => item.value === dataGridProps.value)?.label || ''}; + return ( + + {options.find((item) => item.value === dataGridProps.value)?.label || ''} + + ); }; export default ConditionalAutocompleteDataGridViewCell; diff --git a/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx b/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx index 0454267711..b65830fbdb 100644 --- a/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx +++ b/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx @@ -1,12 +1,14 @@ import { GridRenderEditCellParams, GridValidRowModel } from '@mui/x-data-grid'; import AsyncAutocompleteDataGridEditCell from 'components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell'; import { IAutocompleteDataGridOption } from 'components/data-grid/autocomplete/AutocompleteDataGrid.interface'; +import { TaxonomyContext } from 'contexts/taxonomyContext'; import { useBiohubApi } from 'hooks/useBioHubApi'; import debounce from 'lodash-es/debounce'; -import { useMemo } from 'react'; +import { useContext, useMemo } from 'react'; export interface ITaxonomyDataGridCellProps { dataGridProps: GridRenderEditCellParams; + error?: boolean; } /** @@ -22,18 +24,29 @@ const TaxonomyDataGridEditCell = { const { dataGridProps } = props; + const taxonomyContext = useContext(TaxonomyContext); const biohubApi = useBiohubApi(); const getCurrentOption = async ( speciesId: string | number ): Promise | null> => { - const response = await biohubApi.taxonomy.getSpeciesFromIds([Number(speciesId)]); + if (!speciesId) { + return null; + } + + const id = Number(speciesId); + + if (isNaN(id)) { + return null; + } - if (response.searchResponse.length !== 1) { + const response = taxonomyContext.getCachedSpeciesTaxonomyById(id); + + if (!response) { return null; } - return response.searchResponse.map((item) => ({ value: parseInt(item.id) as ValueType, label: item.label }))[0]; + return { value: Number(response.id) as ValueType, label: response.label }; }; const getOptions = useMemo( @@ -43,6 +56,11 @@ const TaxonomyDataGridEditCell = []) => void ) => { + if (!searchTerm) { + onSearchResults([]); + return; + } + const response = await biohubApi.taxonomy.searchSpecies(searchTerm); const options = response.searchResponse.map((item) => ({ value: parseInt(item.id) as ValueType, @@ -60,6 +78,7 @@ const TaxonomyDataGridEditCell = ); }; diff --git a/app/src/components/data-grid/taxonomy/TaxonomyDataGridViewCell.tsx b/app/src/components/data-grid/taxonomy/TaxonomyDataGridViewCell.tsx index c08f148a2a..30e4b534ed 100644 --- a/app/src/components/data-grid/taxonomy/TaxonomyDataGridViewCell.tsx +++ b/app/src/components/data-grid/taxonomy/TaxonomyDataGridViewCell.tsx @@ -1,9 +1,11 @@ +import Typography from '@mui/material/Typography'; import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import useDataLoader from 'hooks/useDataLoader'; +import { TaxonomyContext } from 'contexts/taxonomyContext'; +import { useContext } from 'react'; export interface ITaxonomyDataGridViewCellProps { dataGridProps: GridRenderCellParams; + error?: boolean; } /** @@ -18,21 +20,31 @@ const TaxonomyDataGridViewCell = ( ) => { const { dataGridProps } = props; - const biohubApi = useBiohubApi(); + const taxonomyContext = useContext(TaxonomyContext); - const taxonomyDataLoader = useDataLoader(() => biohubApi.taxonomy.getSpeciesFromIds([Number(dataGridProps.value)])); - - taxonomyDataLoader.load(); - - if (!taxonomyDataLoader.isReady) { + if (!dataGridProps.value) { return null; } - if (taxonomyDataLoader.data?.searchResponse?.length !== 1) { + const response = taxonomyContext.getCachedSpeciesTaxonomyById(dataGridProps.value); + + if (!response) { return null; } - return <>{taxonomyDataLoader.data?.searchResponse[0].label}; + return ( + + {response.label} + + ); }; export default TaxonomyDataGridViewCell; diff --git a/app/src/components/dialog/ComponentDialog.tsx b/app/src/components/dialog/ComponentDialog.tsx index d9299114a3..7fe712a50a 100644 --- a/app/src/components/dialog/ComponentDialog.tsx +++ b/app/src/components/dialog/ComponentDialog.tsx @@ -1,13 +1,13 @@ -import Button from '@mui/material/Button'; +import { LoadingButton } from '@mui/lab'; import Dialog, { DialogProps } 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 useMediaQuery from '@mui/material/useMediaQuery'; -import React, { PropsWithChildren } from 'react'; +import { PropsWithChildren } from 'react'; -export interface IComponentDialogProps { +export type IComponentDialogProps = PropsWithChildren<{ /** * The dialog window title text. * @@ -43,7 +43,12 @@ export interface IComponentDialogProps { * @memberof IComponentDialogProps */ dialogProps?: Partial; -} + + /** + * A boolean tracking if work is being done and a loading spinner needs to be displayed + */ + isLoading?: boolean; +}>; /** * A dialog to wrap any component(s) that need to be displayed as a modal. @@ -53,7 +58,7 @@ export interface IComponentDialogProps { * @param {*} props * @return {*} */ -const ComponentDialog: React.FC> = (props) => { +const ComponentDialog = (props: IComponentDialogProps) => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); @@ -73,9 +78,9 @@ const ComponentDialog: React.FC> = (pro {props.dialogTitle} {props.children} - + ); diff --git a/app/src/components/dialog/ErrorDialog.tsx b/app/src/components/dialog/ErrorDialog.tsx index 5e2ffd4cb1..e260ce9a9f 100644 --- a/app/src/components/dialog/ErrorDialog.tsx +++ b/app/src/components/dialog/ErrorDialog.tsx @@ -1,4 +1,3 @@ -import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Collapse from '@mui/material/Collapse'; import Dialog from '@mui/material/Dialog'; @@ -8,8 +7,6 @@ import DialogContentText from '@mui/material/DialogContentText'; import DialogTitle from '@mui/material/DialogTitle'; import React from 'react'; -const DEFAULT_ERROR_DIALOG_TITLE = 'Error!'; - export interface IErrorDialogProps { /** * The dialog window title text. @@ -87,40 +84,38 @@ export const ErrorDialog: React.FC = (props) => { } return ( - - - {props.dialogTitle || DEFAULT_ERROR_DIALOG_TITLE} - - - {props.dialogText} - + + {props.dialogTitle} + + {props.dialogText} {props.dialogError && ( - + <> {props.dialogError} - {!!props?.dialogErrorDetails?.length && ( + {props?.dialogErrorDetails?.length ? ( <> - )} - + ) : null} + )} - - - - - + + + + + + ); }; diff --git a/app/src/components/dialog/FileUploadDialog.tsx b/app/src/components/dialog/FileUploadDialog.tsx new file mode 100644 index 0000000000..7817101916 --- /dev/null +++ b/app/src/components/dialog/FileUploadDialog.tsx @@ -0,0 +1,81 @@ +import { LoadingButton } from '@mui/lab'; +import Button from '@mui/material/Button'; +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 useMediaQuery from '@mui/material/useMediaQuery'; +import FileUpload, { IFileUploadProps } from 'components/file-upload/FileUpload'; +import { IFileHandler, ISubtextProps, UploadFileStatus } from 'components/file-upload/FileUploadItem'; +import { useState } from 'react'; +import { getFormattedFileSize } from 'utils/Utils'; +import { IComponentDialogProps } from './ComponentDialog'; + +interface IFileUploadDialogProps extends IComponentDialogProps { + uploadButtonLabel?: string; + onUpload: (file: File) => Promise; + FileUploadProps: Partial; +} + +const SubtextComponent = (props: ISubtextProps) => ( + <>{props.status === UploadFileStatus.STAGED ? getFormattedFileSize(props.file.size) : props.error ?? props.status} +); + +const FileUploadDialog = (props: IFileUploadDialogProps) => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const [currentFile, setCurrentFile] = useState(null); + const [uploading, setUploading] = useState(false); + + const fileHandler: IFileHandler = (file: File | null) => { + setCurrentFile(file); + }; + + const handleUpload = () => { + if (!currentFile) { + return; + } + + setUploading(true); + props.onUpload(currentFile).finally(() => setUploading(false)); + }; + + return ( + + {props.dialogTitle} + + {props.children} + + + + handleUpload()} + color="primary" + variant="contained" + autoFocus> + {props.uploadButtonLabel ? props.uploadButtonLabel : 'Import'} + + + + + ); +}; + +export default FileUploadDialog; diff --git a/app/src/components/dialog/SubmitBiohubDialog.tsx b/app/src/components/dialog/SubmitBiohubDialog.tsx index da8906bb8b..e3856f8f70 100644 --- a/app/src/components/dialog/SubmitBiohubDialog.tsx +++ b/app/src/components/dialog/SubmitBiohubDialog.tsx @@ -167,7 +167,7 @@ const SubmitBiohubDialog = ( variant="contained" disabled={formikProps.values === initialValues || isSubmitting} loading={isSubmitting}> - Submit + Publish Survey + - + ); diff --git a/app/src/components/fields/AutocompleteField.tsx b/app/src/components/fields/AutocompleteField.tsx index ce54b83f53..32bba8103c 100644 --- a/app/src/components/fields/AutocompleteField.tsx +++ b/app/src/components/fields/AutocompleteField.tsx @@ -1,7 +1,6 @@ import { CircularProgress } from '@mui/material'; import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete'; -import TextField from '@mui/material/TextField'; -import { SxProps } from '@mui/system'; +import TextField, { TextFieldProps } from '@mui/material/TextField'; import { useFormikContext } from 'formik'; import get from 'lodash-es/get'; import { SyntheticEvent } from 'react'; @@ -17,7 +16,7 @@ export interface IAutocompleteField { name: string; options: IAutocompleteFieldOption[]; loading?: boolean; - sx?: SxProps; + sx?: TextFieldProps['sx']; //https://github.com/TypeStrong/fork-ts-checker-webpack-plugin/issues/271#issuecomment-1561891271 required?: boolean; filterLimit?: number; optionFilter?: 'value' | 'label'; // used to filter existing/ set data for the AutocompleteField, defaults to value in getExistingValue function diff --git a/app/src/components/fields/CbSelectField.tsx b/app/src/components/fields/CbSelectField.tsx index 96d6368ac4..15da30ee20 100644 --- a/app/src/components/fields/CbSelectField.tsx +++ b/app/src/components/fields/CbSelectField.tsx @@ -1,8 +1,9 @@ import { FormControlProps, MenuItem, SelectChangeEvent } from '@mui/material'; import { useFormikContext } from 'formik'; -import { ICbSelectRows } from 'hooks/cb_api/useLookupApi'; +import { ICbSelectRows, OrderBy } 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 React, { useEffect, useMemo } from 'react'; import { CbSelectWrapper } from './CbSelectFieldWrapper'; @@ -20,6 +21,7 @@ export interface ICbSelectField extends ICbSelectSharedProps { query?: string; disabledValues?: Record; handleChangeSideEffect?: (value: string, label: string) => void; + orderBy?: OrderBy; } interface ICbSelectOption { @@ -35,7 +37,7 @@ interface ICbSelectOption { **/ const CbSelectField: React.FC = (props) => { - const { name, label, route, param, query, handleChangeSideEffect, controlProps, disabledValues } = props; + const { name, orderBy, label, route, param, query, handleChangeSideEffect, controlProps, disabledValues } = props; const api = useCritterbaseApi(); const { data, refresh } = useDataLoader(api.lookup.getSelectOptions); @@ -45,7 +47,7 @@ const CbSelectField: React.FC = (props) => { useEffect(() => { // Only refresh when the query or param changes - refresh({ route, param, query }); + refresh({ route, param, query, orderBy }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [query, param]); @@ -82,7 +84,7 @@ const CbSelectField: React.FC = (props) => { const item = typeof a === 'string' ? { label: a, value: a } : { label: a.value, value: a.id }; return ( - {item.label} + {startCase(item.label)} ); })} diff --git a/app/src/components/fields/CustomTextField.tsx b/app/src/components/fields/CustomTextField.tsx index 2c8cf0490c..ffa7d8962e 100644 --- a/app/src/components/fields/CustomTextField.tsx +++ b/app/src/components/fields/CustomTextField.tsx @@ -1,10 +1,15 @@ -import TextField, { TextFieldProps } from '@mui/material/TextField'; +import TextField from '@mui/material/TextField'; import { FormikContextType, useFormikContext } from 'formik'; import get from 'lodash-es/get'; export interface ICustomTextField { label: string; name: string; - other?: TextFieldProps; + /* + * Needed fix: Add correct hardcoded type + * Note: TextFieldProps causes build compile issue + * https://github.com/mui/material-ui/issues/30038 + */ + other?: any; //Additionally add a handlBlur if touced properties not updating correclty. handleBlur?: FormikContextType['handleBlur']; @@ -12,9 +17,9 @@ export interface ICustomTextField { } const CustomTextField: React.FC> = (props) => { - const { touched, errors, values, handleChange } = useFormikContext(); + const { touched, errors, values, handleChange, handleBlur } = useFormikContext(); - const { name, label, other, handleBlur } = props; + const { name, label, other } = props; return ( > = (pr onChange={handleChange} onBlur={handleBlur} variant="outlined" - value={get(values, name)} + value={get(values, name) ?? ''} fullWidth={true} error={get(touched, name) && Boolean(get(errors, name))} helperText={get(touched, name) && (get(errors, name) as string)} diff --git a/app/src/components/fields/DateTimeFields.tsx b/app/src/components/fields/DateTimeFields.tsx new file mode 100644 index 0000000000..54fdf5412b --- /dev/null +++ b/app/src/components/fields/DateTimeFields.tsx @@ -0,0 +1,166 @@ +import Icon from '@mdi/react'; +import Grid from '@mui/material/Grid'; +import { DatePicker, TimePicker } from '@mui/x-date-pickers'; +import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { DATE_FORMAT, DATE_LIMIT, TIME_FORMAT } from 'constants/dateTimeFormats'; +import { ISurveySampleMethodData } from 'features/surveys/components/MethodForm'; +import { FormikContextType } from 'formik'; +import get from 'lodash-es/get'; +import moment from 'moment'; +import React from 'react'; + +interface IDateTimeFieldsProps { + date: { + dateLabel: string; + dateName: string; + dateId: string; + dateRequired: boolean; + dateHelperText?: string; + dateIcon: string; + }; + time: { + timeLabel: string; + timeName: string; + timeId: string; + timeRequired: boolean; + timeErrorHelper?: string; + timeHelperText?: string; + timeIcon: string; + }; + parentName: string; + formikProps: FormikContextType; +} + +export const DateTimeFields: React.FC = (props) => { + const { + formikProps: { values, errors, touched, setFieldValue }, + date: { dateLabel, dateName, dateId, dateRequired, dateHelperText, dateIcon }, + time: { timeLabel, timeName, timeId, timeRequired, timeHelperText, timeIcon }, + parentName + } = props; + + const DateIcon = () => { + return ; + }; + + const TimeIcon = () => { + return ; + }; + + const rawDateValue = get(values, dateName); + const formattedDateValue = + (rawDateValue && + moment(rawDateValue, DATE_FORMAT.ShortDateFormat).isValid() && + moment(rawDateValue, DATE_FORMAT.ShortDateFormat)) || + null; + + const rawTimeValue = get(values, timeName); + const formattedTimeValue = + (rawTimeValue && + moment(rawTimeValue, TIME_FORMAT.LongTimeFormat24Hour).isValid() && + moment(rawTimeValue, TIME_FORMAT.LongTimeFormat24Hour)) || + null; + + return ( + + + + { + if (!value || String(value.creationData().input) === '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(dateName, null); + return; + } + + setFieldValue(dateName, moment(value).format(DATE_FORMAT.ShortDateFormat)); + }} + /> + + + { + if (!value || !moment(value).isValid()) { + // Check if the value is null or invalid, and if so, clear the field. + setFieldValue(timeName, null); + return; + } + + setFieldValue(timeName, moment(value).format(TIME_FORMAT.LongTimeFormat24Hour)); + }} + views={['hours', 'minutes', 'seconds']} + timeSteps={{ hours: 1, minutes: 1, seconds: 1 }} + ampm={false} + /> + + + + ); +}; diff --git a/app/src/components/fields/MultiAutocompleteField.tsx b/app/src/components/fields/MultiAutocompleteField.tsx index a90265e062..5cb5d5f792 100644 --- a/app/src/components/fields/MultiAutocompleteField.tsx +++ b/app/src/components/fields/MultiAutocompleteField.tsx @@ -1,5 +1,6 @@ import CheckBox from '@mui/icons-material/CheckBox'; import CheckBoxOutlineBlank from '@mui/icons-material/CheckBoxOutlineBlank'; +import { ListItemText } from '@mui/material'; import Autocomplete, { AutocompleteChangeReason, AutocompleteInputChangeReason, @@ -16,6 +17,7 @@ import { useEffect, useState } from 'react'; export interface IMultiAutocompleteFieldOption { value: string | number; label: string; + subText?: string; } export interface IMultiAutocompleteField { @@ -138,7 +140,7 @@ const MultiAutocompleteField: React.FC = (props) => { value={renderOption.value} color="default" /> - {renderOption.label} + ); }} diff --git a/app/src/components/fields/SingleDateField.tsx b/app/src/components/fields/SingleDateField.tsx index 43313cd1d7..01a47c0c41 100644 --- a/app/src/components/fields/SingleDateField.tsx +++ b/app/src/components/fields/SingleDateField.tsx @@ -50,10 +50,10 @@ const SingleDateField: React.FC = (props) => { openPickerIcon: CalendarIcon }} slotProps={{ - openPickerButton: { id: 'date_input_button' }, + openPickerButton: { id: props.name }, inputAdornment: { - onBlur: handleBlur, - id: 'date_input_adornment' + id: props.name, + onBlur: handleBlur }, textField: { id: 'date_field', @@ -79,6 +79,7 @@ const SingleDateField: React.FC = (props) => { maxDate={moment(DATE_LIMIT.max)} value={formattedDateValue} onChange={(value) => { + other?.onChange?.(value); if (!value || String(value.creationData().input) === '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. diff --git a/app/src/components/fields/TelemetrySelectField.tsx b/app/src/components/fields/TelemetrySelectField.tsx index 6d67372eea..4778077ad7 100644 --- a/app/src/components/fields/TelemetrySelectField.tsx +++ b/app/src/components/fields/TelemetrySelectField.tsx @@ -1,5 +1,5 @@ import { FormControl, FormControlProps, FormHelperText, InputLabel, MenuItem, Select } from '@mui/material'; -import { useFormikContext } from 'formik'; +import { FormikContextType, useFormikContext } from 'formik'; import useDataLoader from 'hooks/useDataLoader'; import get from 'lodash-es/get'; import React from 'react'; @@ -10,6 +10,8 @@ interface ITelemetrySelectField { id: string; fetchData: () => Promise<(string | number)[]>; controlProps?: FormControlProps; + handleBlur?: FormikContextType['handleBlur']; + handleChange?: FormikContextType['handleChange']; } interface ISelectOption { @@ -27,6 +29,8 @@ const TelemetrySelectField: React.FC = (props) => { bctwLookupLoader.load(); } + const value = bctwLookupLoader.hasLoaded && get(values, props.name) ? get(values, props.name) : ''; + return ( {props.label} @@ -34,9 +38,9 @@ const TelemetrySelectField: React.FC = (props) => { name={props.name} labelId="telemetry_select" label={props.label} - value={get(values, props.name) ?? ''} - onChange={handleChange} - onBlur={handleBlur} + value={value ?? ''} + onChange={props.handleChange ?? handleChange} + onBlur={props.handleBlur ?? handleBlur} displayEmpty> {bctwLookupLoader.data?.map((bctwValue: string | number) => { return ( diff --git a/app/src/components/layout/Header.test.tsx b/app/src/components/layout/Header.test.tsx index 913831fc76..8357e71f51 100644 --- a/app/src/components/layout/Header.test.tsx +++ b/app/src/components/layout/Header.test.tsx @@ -1,9 +1,9 @@ +import { SYSTEM_IDENTITY_SOURCE } from 'constants/auth'; import { AuthStateContext } from 'contexts/authStateContext'; import { createMemoryHistory } from 'history'; -import { SYSTEM_IDENTITY_SOURCE } from 'hooks/useKeycloakWrapper'; import { Router } from 'react-router-dom'; import { getMockAuthState, SystemAdminAuthState, SystemUserAuthState } from 'test-helpers/auth-helpers'; -import { render } from 'test-helpers/test-utils'; +import { fireEvent, render, waitFor } from 'test-helpers/test-utils'; import Header from './Header'; const history = createMemoryHistory(); @@ -27,7 +27,7 @@ describe('Header', () => { it('renders correctly with system admin role (BCeID Business)', () => { const authState = getMockAuthState({ base: SystemAdminAuthState, - overrides: { keycloakWrapper: { getIdentitySource: () => SYSTEM_IDENTITY_SOURCE.BCEID_BUSINESS } } + overrides: { simsUserWrapper: { identitySource: SYSTEM_IDENTITY_SOURCE.BCEID_BUSINESS } } }); const { getByText } = render( @@ -45,7 +45,7 @@ describe('Header', () => { it('renders correctly with system admin role (BCeID Basic)', () => { const authState = getMockAuthState({ base: SystemAdminAuthState, - overrides: { keycloakWrapper: { getIdentitySource: () => SYSTEM_IDENTITY_SOURCE.BCEID_BASIC } } + overrides: { simsUserWrapper: { identitySource: SYSTEM_IDENTITY_SOURCE.BCEID_BASIC } } }); const { getByText } = render( @@ -63,7 +63,7 @@ describe('Header', () => { it('renders the username and logout button', () => { const authState = getMockAuthState({ base: SystemAdminAuthState, - overrides: { keycloakWrapper: { getIdentitySource: () => SYSTEM_IDENTITY_SOURCE.BCEID_BASIC } } + overrides: { simsUserWrapper: { identitySource: SYSTEM_IDENTITY_SOURCE.BCEID_BASIC } } }); const { getByTestId, getByText } = render( @@ -79,19 +79,63 @@ describe('Header', () => { expect(getByText('BCeID Basic/admin-username')).toBeVisible(); }); - describe('Log Out', () => { - it('redirects to the `/logout` page', async () => { - const authState = getMockAuthState({ base: SystemUserAuthState }); + describe('Log out', () => { + describe('expanded menu button', () => { + it('calls logout', async () => { + const signoutRedirectStub = jest.fn(); - const { getByTestId } = render( - - -
- - - ); + const authState = getMockAuthState({ + base: SystemUserAuthState, + overrides: { auth: { signoutRedirect: signoutRedirectStub } } + }); - expect(getByTestId('menu_log_out')).toHaveAttribute('href', '/logout'); + const { getByTestId } = render( + + +
+ + + ); + + const logoutButton = getByTestId('menu_log_out'); + + expect(logoutButton).toBeInTheDocument(); + + fireEvent.click(logoutButton); + + await waitFor(() => { + expect(signoutRedirectStub).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('collapsed menu button', () => { + it('calls logout', async () => { + const signoutRedirectStub = jest.fn(); + + const authState = getMockAuthState({ + base: SystemUserAuthState, + overrides: { auth: { signoutRedirect: signoutRedirectStub } } + }); + + const { getByTestId } = render( + + +
+ + + ); + + const logoutButton = getByTestId('collapsed_menu_log_out'); + + expect(logoutButton).toBeInTheDocument(); + + fireEvent.click(logoutButton); + + await waitFor(() => { + expect(signoutRedirectStub).toHaveBeenCalledTimes(1); + }); + }); }); }); }); diff --git a/app/src/components/layout/Header.tsx b/app/src/components/layout/Header.tsx index 1f08a2cade..79d5009801 100644 --- a/app/src/components/layout/Header.tsx +++ b/app/src/components/layout/Header.tsx @@ -15,16 +15,14 @@ import Typography from '@mui/material/Typography'; import headerImageLarge from 'assets/images/gov-bc-logo-horiz.png'; import headerImageSmall from 'assets/images/gov-bc-logo-vert.png'; import { AuthGuard, SystemRoleGuard, UnAuthGuard } from 'components/security/Guards'; +import { SYSTEM_IDENTITY_SOURCE } from 'constants/auth'; import { SYSTEM_ROLE } from 'constants/roles'; -import { AuthStateContext } from 'contexts/authStateContext'; -import { SYSTEM_IDENTITY_SOURCE } from 'hooks/useKeycloakWrapper'; -import React, { useContext, useMemo } from 'react'; +import { useAuthStateContext } from 'hooks/useAuthStateContext'; +import React from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { getFormattedIdentitySource } from 'utils/Utils'; const Header: React.FC = () => { - const { keycloakWrapper } = useContext(AuthStateContext); - const loginUrl = useMemo(() => keycloakWrapper?.getLoginUrl(), [keycloakWrapper]); const [anchorEl, setAnchorEl] = React.useState(null); const [open, setOpen] = React.useState(false); @@ -54,8 +52,10 @@ const Header: React.FC = () => { // Authenticated view const LoggedInUser = () => { - const identitySource = keycloakWrapper?.getIdentitySource() || ''; - const userIdentifier = keycloakWrapper?.getUserIdentifier() || ''; + const authStateContext = useAuthStateContext(); + + const identitySource = authStateContext.simsUserWrapper.identitySource ?? ''; + const userIdentifier = authStateContext.simsUserWrapper.userIdentifier ?? ''; const formattedUsername = [getFormattedIdentitySource(identitySource as SYSTEM_IDENTITY_SOURCE), userIdentifier] .filter(Boolean) .join('/'); @@ -90,7 +90,7 @@ const Header: React.FC = () => { + + + )} + + ); }; diff --git a/app/src/components/publish/SurveySubmissionAlertBar.tsx b/app/src/components/publish/SurveySubmissionAlertBar.tsx index f36c08bc07..c0e54c4ff1 100644 --- a/app/src/components/publish/SurveySubmissionAlertBar.tsx +++ b/app/src/components/publish/SurveySubmissionAlertBar.tsx @@ -5,7 +5,7 @@ import AlertTitle from '@mui/material/AlertTitle'; import Box from '@mui/material/Box'; import IconButton from '@mui/material/IconButton'; import { SurveyContext } from 'contexts/surveyContext'; -import { IGetObservationSubmissionResponse } from 'interfaces/useObservationApi.interface'; +import { IGetObservationSubmissionResponse } from 'interfaces/useDwcaApi.interface'; import { IGetSummaryResultsResponse } from 'interfaces/useSummaryResultsApi.interface'; import { IGetSurveyAttachmentsResponse } from 'interfaces/useSurveyApi.interface'; import { useContext, useState } from 'react'; diff --git a/app/src/components/publish/components/PublishSurveyContent.tsx b/app/src/components/publish/components/PublishSurveyContent.tsx new file mode 100644 index 0000000000..67172d80d4 --- /dev/null +++ b/app/src/components/publish/components/PublishSurveyContent.tsx @@ -0,0 +1,150 @@ +import Box from '@mui/material/Box'; +import Checkbox from '@mui/material/Checkbox'; +import Divider from '@mui/material/Divider'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormGroup from '@mui/material/FormGroup'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import CustomTextField from 'components/fields/CustomTextField'; +import { useFormikContext } from 'formik'; +import { ISubmitSurvey } from '../PublishSurveyDialog'; + +/** + * Survey Publish Content. + * + * @return {*} + */ +const PublishSurveyContent = () => { + const { values, setFieldValue } = useFormikContext(); + + return ( + } + sx={{ + maxWidth: '800px' + }}> + + + Published data submitted as part of this survey may be secured according to the{' '} + {/* eslint-disable-next-line react/jsx-no-target-blank */} + + Species and Ecosystems Data and Information Security (SEDIS) Policy. + + + + + + + Additional Information + + Information about this survey that data stewards should be aware of, including any reasons why this survey + should be secured. + + + + + Agreements + + setFieldValue('agreement1', !values.agreement1)} + name="agreement1" + /> + } + /> + + All published data for this survey meets or exceed the{' '} + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + + Freedom of Information and Protection of Privacy Act (FOIPPA) + {' '} + requirements. + + } + control={ + setFieldValue('agreement2', !values.agreement2)} + name="agreement2" + /> + } + /> + + All data and information for this survey has been collected legally, and in accordance with Section 1 + of the{' '} + + {' '} + Species and Ecosystems Data and Information Security (SEDIS) + {' '} + procedures. + + } + control={ + setFieldValue('agreement3', !values.agreement3)} + name="agreement3" + /> + } + /> + + + + + ); +}; + +export default PublishSurveyContent; diff --git a/app/src/components/security/Guards.tsx b/app/src/components/security/Guards.tsx index 249cb46207..2b73e356a8 100644 --- a/app/src/components/security/Guards.tsx +++ b/app/src/components/security/Guards.tsx @@ -1,8 +1,8 @@ import { PROJECT_PERMISSION, PROJECT_ROLE, SYSTEM_ROLE } from 'constants/roles'; -import { AuthStateContext } from 'contexts/authStateContext'; import { ProjectAuthStateContext } from 'contexts/projectAuthStateContext'; +import { useAuthStateContext } from 'hooks/useAuthStateContext'; import { PropsWithChildren, ReactElement, useContext } from 'react'; -import { isAuthenticated } from 'utils/authUtils'; +import { hasAtLeastOneValidValue } from 'utils/authUtils'; interface IGuardProps { /** @@ -55,9 +55,10 @@ export interface IProjectRoleGuardProps extends IGuardProps { * @return {*} */ export const SystemRoleGuard = (props: PropsWithChildren) => { - const { keycloakWrapper } = useContext(AuthStateContext); + const authStateContext = useAuthStateContext(); const { validSystemRoles } = props; - const hasSystemRole = keycloakWrapper?.hasSystemRole(validSystemRoles); + + const hasSystemRole = hasAtLeastOneValidValue(validSystemRoles, authStateContext.simsUserWrapper.roleNames); if (!hasSystemRole) { if (props.fallback) { @@ -119,9 +120,9 @@ export const HasProjectOrSystemRole = (roles: IProjectRoleGuardProps): boolean = * @return {*} */ export const AuthGuard = (props: PropsWithChildren) => { - const { keycloakWrapper } = useContext(AuthStateContext); + const authStateContext = useAuthStateContext(); - if (!isAuthenticated(keycloakWrapper)) { + if (!authStateContext.auth.isAuthenticated || authStateContext.simsUserWrapper.isLoading) { if (props.fallback) { return <>{props.fallback}; } else { @@ -139,9 +140,9 @@ export const AuthGuard = (props: PropsWithChildren) => { * @return {*} */ export const UnAuthGuard = (props: PropsWithChildren) => { - const { keycloakWrapper } = useContext(AuthStateContext); + const authStateContext = useAuthStateContext(); - if (isAuthenticated(keycloakWrapper)) { + if (authStateContext.auth.isAuthenticated) { if (props.fallback) { return <>{props.fallback}; } else { diff --git a/app/src/components/security/RouteGuards.test.tsx b/app/src/components/security/RouteGuards.test.tsx new file mode 100644 index 0000000000..4e345c9b17 --- /dev/null +++ b/app/src/components/security/RouteGuards.test.tsx @@ -0,0 +1,685 @@ +import { + AuthenticatedRouteGuard, + SystemRoleRouteGuard, + UnAuthenticatedRouteGuard +} from 'components/security/RouteGuards'; +import { SYSTEM_ROLE } from 'constants/roles'; +import { AuthStateContext } from 'contexts/authStateContext'; +import { createMemoryHistory } from 'history'; +import { AuthContextProps } from 'react-oidc-context'; +import { Router } from 'react-router'; +import { getMockAuthState } from 'test-helpers/auth-helpers'; +import { cleanup, render, waitFor } from 'test-helpers/test-utils'; + +// Everything set to null, undefined, false, empty, etc +const nullAuthState = getMockAuthState({ + base: { + auth: { + isLoading: false, + isAuthenticated: false, + signinRedirect: () => { + // do nothing + } + } as unknown as AuthContextProps, + simsUserWrapper: { + isLoading: false, + systemUserId: undefined, + userGuid: null, + userIdentifier: undefined, + displayName: undefined, + email: undefined, + agency: undefined, + roleNames: [], + identitySource: null, + hasAccessRequest: false, + hasOneOrMoreProjectRoles: false, + refresh: () => { + // do nothing + } + }, + critterbaseUserWrapper: { + isLoading: false, + critterbaseUserUuid: 'fakeguid' + } + } +}); + +const Fail = () => { + throw new Error('Fail - This component should not have been rendered'); +}; + +const Success = () => { + return
; +}; + +describe('RouteGuards', () => { + describe('SystemRoleRouteGuard', () => { + describe('loading', () => { + afterAll(() => { + cleanup(); + }); + + it('renders a spinner if the auth context is still loading', async () => { + const authState = getMockAuthState({ + base: nullAuthState, + overrides: { + auth: { + isLoading: true, + isAuthenticated: true + }, + simsUserWrapper: { + isLoading: false + } + } + }); + + const initialPath = '/'; + const history = createMemoryHistory({ initialEntries: [initialPath] }); + + const validRoles = [SYSTEM_ROLE.PROJECT_CREATOR]; + + const { getByTestId } = render( + + + + + + + + ); + + await waitFor(() => { + expect(getByTestId('system-role-guard-spinner')).toBeVisible(); + }); + }); + + it('renders a spinner if the sims user is still loading', async () => { + const authState = getMockAuthState({ + base: nullAuthState, + overrides: { + auth: { + isLoading: false, + isAuthenticated: true + }, + simsUserWrapper: { + isLoading: true + } + } + }); + + const initialPath = '/'; + const history = createMemoryHistory({ initialEntries: [initialPath] }); + + const validRoles = [SYSTEM_ROLE.PROJECT_CREATOR]; + + const { getByTestId } = render( + + + + + + + + ); + + await waitFor(() => { + expect(getByTestId('system-role-guard-spinner')).toBeVisible(); + expect(history.location.pathname).toEqual(initialPath); + }); + }); + }); + + describe('authenticated', () => { + afterAll(() => { + cleanup(); + }); + + it('redirects to the forbidden page if the user has insufficient system roles', async () => { + const authState = getMockAuthState({ + base: nullAuthState, + overrides: { + auth: { + isLoading: false, + isAuthenticated: true + }, + simsUserWrapper: { + isLoading: false, + roleNames: [SYSTEM_ROLE.PROJECT_CREATOR] + } + } + }); + + const initialPath = '/'; + const history = createMemoryHistory({ initialEntries: [initialPath] }); + + const validRoles = [SYSTEM_ROLE.DATA_ADMINISTRATOR]; + + render( + + + + + + + + ); + + const expectedPath = '/forbidden'; + + await waitFor(() => { + expect(history.location.pathname).toEqual(expectedPath); + }); + }); + + it('redirects to the forbidden page if the user has no system roles', async () => { + const authState = getMockAuthState({ + base: nullAuthState, + overrides: { + auth: { + isLoading: false, + isAuthenticated: true + }, + simsUserWrapper: { + isLoading: false, + roleNames: [] + } + } + }); + + const initialPath = '/'; + const history = createMemoryHistory({ initialEntries: [initialPath] }); + + const validRoles = [SYSTEM_ROLE.DATA_ADMINISTRATOR]; + + render( + + + + + + + + ); + + const expectedPath = '/forbidden'; + + await waitFor(() => { + expect(history.location.pathname).toEqual(expectedPath); + }); + }); + + it('renders the route if no valid system roles specified', async () => { + const authState = getMockAuthState({ + base: nullAuthState, + overrides: { + auth: { + isLoading: false, + isAuthenticated: true + }, + simsUserWrapper: { + isLoading: false, + roleNames: [] + } + } + }); + + const initialPath = '/'; + const history = createMemoryHistory({ initialEntries: [initialPath] }); + + const validRoles: SYSTEM_ROLE[] = []; + + const { getByTestId } = render( + + + + + + + + ); + + await waitFor(() => { + expect(history.location.pathname).toEqual(initialPath); + expect(getByTestId('success-component')).toBeVisible(); + }); + }); + + it('renders the route if the user has sufficient system roles', async () => { + const authState = getMockAuthState({ + base: nullAuthState, + overrides: { + auth: { + isLoading: false, + isAuthenticated: true + }, + simsUserWrapper: { + isLoading: false, + roleNames: [SYSTEM_ROLE.DATA_ADMINISTRATOR] + } + } + }); + + const initialPath = '/'; + const history = createMemoryHistory({ initialEntries: [initialPath] }); + + const validRoles = [SYSTEM_ROLE.DATA_ADMINISTRATOR]; + + const { getByTestId } = render( + + + + + + + + ); + + await waitFor(() => { + expect(history.location.pathname).toEqual(initialPath); + expect(getByTestId('success-component')).toBeVisible(); + }); + }); + }); + }); + + describe('AuthenticatedRouteGuard', () => { + describe('loading', () => { + afterAll(() => { + cleanup(); + }); + + it('renders a spinner if the auth context is still loading', async () => { + const signinRedirectStub = jest.fn(); + + const authState = getMockAuthState({ + base: nullAuthState, + overrides: { + auth: { + isLoading: true, + isAuthenticated: true, + signinRedirect: signinRedirectStub + }, + simsUserWrapper: { + isLoading: false + } + } + }); + + const initialPath = '/'; + const history = createMemoryHistory({ initialEntries: [initialPath] }); + + const { getByTestId } = render( + + + + + + + + ); + + await waitFor(() => { + expect(getByTestId('authenticated-route-guard-spinner')).toBeVisible(); + expect(signinRedirectStub).toHaveBeenCalledTimes(0); + }); + }); + + it('renders a spinner if the sims user is still loading', async () => { + const signinRedirectStub = jest.fn(); + + const authState = getMockAuthState({ + base: nullAuthState, + overrides: { + auth: { + isLoading: false, + isAuthenticated: true, + signinRedirect: signinRedirectStub + }, + simsUserWrapper: { + isLoading: true + } + } + }); + + const initialPath = '/'; + const history = createMemoryHistory({ initialEntries: [initialPath] }); + + const { getByTestId } = render( + + + + + + + + ); + + await waitFor(() => { + expect(getByTestId('authenticated-route-guard-spinner')).toBeVisible(); + expect(history.location.pathname).toEqual(initialPath); + expect(signinRedirectStub).toHaveBeenCalledTimes(0); + }); + }); + }); + + describe('unauthenticated', () => { + afterAll(() => { + cleanup(); + }); + + it('renders a spinner and calls `signinRedirect` if the user is not authenticated', async () => { + const signinRedirectStub = jest.fn(); + + const authState = getMockAuthState({ + base: nullAuthState, + overrides: { + auth: { + isLoading: false, + isAuthenticated: false, + signinRedirect: signinRedirectStub + }, + simsUserWrapper: { + isLoading: false + } + } + }); + + const initialPath = '/'; + const history = createMemoryHistory({ initialEntries: [initialPath] }); + + const { getByTestId } = render( + + + + + + + + ); + + await waitFor(() => { + expect(getByTestId('authenticated-route-guard-spinner')).toBeVisible(); + expect(history.location.pathname).toEqual(initialPath); + expect(signinRedirectStub).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('authenticated', () => { + afterAll(() => { + cleanup(); + }); + + it('redirects to the request submitted page if the user is not registered and has a pending access request', async () => { + const signinRedirectStub = jest.fn(); + + const authState = getMockAuthState({ + base: nullAuthState, + overrides: { + auth: { + isLoading: false, + isAuthenticated: true, + signinRedirect: signinRedirectStub + }, + simsUserWrapper: { + isLoading: false, + systemUserId: undefined, // not a registered user + hasAccessRequest: true + } + } + }); + + const initialPath = '/'; + const history = createMemoryHistory({ initialEntries: [initialPath] }); + + const { getByTestId } = render( + + + + + + + + ); + + const expectedPath = '/request-submitted'; + + await waitFor(() => { + expect(history.location.pathname).toEqual(expectedPath); + expect(getByTestId('success-component')).toBeVisible(); + expect(signinRedirectStub).toHaveBeenCalledTimes(0); + }); + }); + + it('redirects to the forbidden page if the user is not registered, has no pending access request, and attempted to access a protected page', async () => { + const signinRedirectStub = jest.fn(); + + const authState = getMockAuthState({ + base: nullAuthState, + overrides: { + auth: { + isLoading: false, + isAuthenticated: true, + signinRedirect: signinRedirectStub + }, + simsUserWrapper: { + isLoading: false, + systemUserId: undefined, // not a registered user + hasAccessRequest: false + } + } + }); + + const initialPath = '/admin/projects'; // protected route + const history = createMemoryHistory({ initialEntries: [initialPath] }); + + render( + + + + + + + + ); + + const expectedPath = '/forbidden'; + + await waitFor(() => { + expect(history.location.pathname).toEqual(expectedPath); + expect(signinRedirectStub).toHaveBeenCalledTimes(0); + }); + }); + + it('renders the route if the user is not registered, has no pending access request, and attempted to access the landing page', async () => { + const signinRedirectStub = jest.fn(); + + const authState = getMockAuthState({ + base: nullAuthState, + overrides: { + auth: { + isLoading: false, + isAuthenticated: true, + signinRedirect: signinRedirectStub + }, + simsUserWrapper: { + isLoading: false, + systemUserId: undefined, // not a registered user + hasAccessRequest: false + } + } + }); + + const initialPath = '/'; + const history = createMemoryHistory({ initialEntries: [initialPath] }); + + const { getByTestId } = render( + + + + + + + + ); + + await waitFor(() => { + expect(history.location.pathname).toEqual(initialPath); + expect(getByTestId('success-component')).toBeVisible(); + expect(signinRedirectStub).toHaveBeenCalledTimes(0); + }); + }); + + it('renders the route if the user is not registered, has no pending access request, and attempted to access the access request page', async () => { + const signinRedirectStub = jest.fn(); + + const authState = getMockAuthState({ + base: nullAuthState, + overrides: { + auth: { + isLoading: false, + isAuthenticated: true, + signinRedirect: signinRedirectStub + }, + simsUserWrapper: { + isLoading: false, + systemUserId: undefined, // not a registered user + hasAccessRequest: false + } + } + }); + + const initialPath = '/access-request'; + const history = createMemoryHistory({ initialEntries: [initialPath] }); + + const { getByTestId } = render( + + + + + + + + ); + + await waitFor(() => { + expect(history.location.pathname).toEqual(initialPath); + expect(getByTestId('success-component')).toBeVisible(); + expect(signinRedirectStub).toHaveBeenCalledTimes(0); + }); + }); + + it('renders the route if the user is a registered user', async () => { + const signinRedirectStub = jest.fn(); + + const authState = getMockAuthState({ + base: nullAuthState, + overrides: { + auth: { + isLoading: false, + isAuthenticated: true, + signinRedirect: signinRedirectStub + }, + simsUserWrapper: { + isLoading: false, + systemUserId: 1, // registered user + hasAccessRequest: false + } + } + }); + + const initialPath = '/admin/projects'; // protected route + const history = createMemoryHistory({ initialEntries: [initialPath] }); + + const { getByTestId } = render( + + + + + + + + ); + + await waitFor(() => { + expect(history.location.pathname).toEqual(initialPath); + expect(getByTestId('success-component')).toBeVisible(); + expect(signinRedirectStub).toHaveBeenCalledTimes(0); + }); + }); + }); + }); + + describe('UnAuthenticatedRouteGuard', () => { + describe('unauthenticated', () => { + afterAll(() => { + cleanup(); + }); + + it('renders the route', async () => { + const authState = getMockAuthState({ + base: nullAuthState, + overrides: { + auth: { + isAuthenticated: false + } + } + }); + + const initialPath = '/unauth/route'; + const history = createMemoryHistory({ initialEntries: [initialPath] }); + + const { getByTestId } = render( + + + + + + + + ); + + await waitFor(() => { + expect(history.location.pathname).toEqual(initialPath); + expect(getByTestId('success-component')).toBeVisible(); + }); + }); + }); + + describe('authenticated', () => { + afterAll(() => { + cleanup(); + }); + + it('redirects to the landing page', async () => { + const authState = getMockAuthState({ + base: nullAuthState, + overrides: { + auth: { + isAuthenticated: true + } + } + }); + + const initialPath = '/auth/route'; + const history = createMemoryHistory({ initialEntries: [initialPath] }); + + render( + + + + + + + + ); + + const expectedPath = '/'; + + await waitFor(() => { + expect(history.location.pathname).toEqual(expectedPath); + }); + }); + }); + }); +}); diff --git a/app/src/components/security/RouteGuards.tsx b/app/src/components/security/RouteGuards.tsx index d6990b1848..aef477a645 100644 --- a/app/src/components/security/RouteGuards.tsx +++ b/app/src/components/security/RouteGuards.tsx @@ -1,9 +1,13 @@ import CircularProgress from '@mui/material/CircularProgress'; -import { AuthStateContext } from 'contexts/authStateContext'; +import { PROJECT_PERMISSION, PROJECT_ROLE, SYSTEM_ROLE } from 'constants/roles'; import { ProjectAuthStateContext } from 'contexts/projectAuthStateContext'; -import useRedirect from 'hooks/useRedirect'; -import React, { PropsWithChildren, useContext } from 'react'; +import { useAuthStateContext } from 'hooks/useAuthStateContext'; +import { useRedirectUri } from 'hooks/useRedirect'; +import { useContext, useEffect } from 'react'; +import { hasAuthParams } from 'react-oidc-context'; import { Redirect, Route, RouteProps, useLocation } from 'react-router'; +import { hasAtLeastOneValidValue } from 'utils/authUtils'; +import { buildUrl } from 'utils/Utils'; export interface ISystemRoleRouteGuardProps extends RouteProps { /** @@ -11,9 +15,9 @@ export interface ISystemRoleRouteGuardProps extends RouteProps { * * Note: The user only needs 1 of the valid roles, when multiple are specified. * - * @type {string[]} + * @type {SYSTEM_ROLE[]} */ - validRoles?: string[]; + validRoles?: SYSTEM_ROLE[]; } export interface IProjectRoleRouteGuardProps extends RouteProps { @@ -22,27 +26,27 @@ export interface IProjectRoleRouteGuardProps extends RouteProps { * * Note: The user only needs 1 of the valid roles, when multiple are specified. * - * @type {string[]} + * @type {PROJECT_ROLE[]} */ - validProjectRoles?: string[]; + validProjectRoles?: PROJECT_ROLE[]; /** * Indicates the sufficient project permissions needed to access this route, if any. * * Note: The user only needs 1 of the valid roles, when multiple are specified. * - * @type {string[]} + * @type {PROJECT_PERMISSION[]} */ - validProjectPermissions?: string[]; + validProjectPermissions?: PROJECT_PERMISSION[]; /** * Indicates the sufficient system roles that will grant access to this route, if any. * * Note: The user only needs 1 of the valid roles, when multiple are specified. * - * @type {string[]} + * @type {SYSTEM_ROLE[]} */ - validSystemRoles?: string[]; + validSystemRoles?: SYSTEM_ROLE[]; } /** @@ -50,19 +54,24 @@ export interface IProjectRoleRouteGuardProps extends RouteProps { * * Note: Does not check if they are already authenticated. * - * @param {*} { children, validRoles, ...rest } + * @param {ISystemRoleRouteGuardProps} props * @return {*} */ export const SystemRoleRouteGuard = (props: ISystemRoleRouteGuardProps) => { const { validRoles, children, ...rest } = props; - return ( - - - {children} - - - ); + const authStateContext = useAuthStateContext(); + + if (authStateContext.auth.isLoading || authStateContext.simsUserWrapper.isLoading) { + // User data has not been loaded, can not yet determine if user has sufficient roles + return ; + } + + if (!hasAtLeastOneValidValue(props.validRoles, authStateContext.simsUserWrapper.roleNames)) { + return ; + } + + return {children}; }; /** @@ -70,220 +79,117 @@ export const SystemRoleRouteGuard = (props: ISystemRoleRouteGuardProps) => { * * Note: Does not check if they are already authenticated. * - * @param {*} { children, validRoles, ...rest } + * @param {IProjectRoleRouteGuardProps} props * @return {*} */ export const ProjectRoleRouteGuard = (props: IProjectRoleRouteGuardProps) => { const { validSystemRoles, validProjectRoles, validProjectPermissions, children, ...rest } = props; - return ( - - - {children} - - - ); -}; - -/** - * Route guard that requires the user to be authenticated. - * - * @param {*} { children, ...rest } - * @return {*} - */ -export const AuthenticatedRouteGuard = (props: RouteProps) => { - const { children, ...rest } = props; - - return ( - - - - {children} - - - - ); -}; - -/** - * Route guard that requires the user to not be authenticated. - * - * @param {*} { children, ...rest } - * @return {*} - */ -export const UnAuthenticatedRouteGuard = (props: RouteProps) => { - const { children, ...rest } = props; - - return ( - - {children} - - ); -}; + const authStateContext = useAuthStateContext(); -/** - * Checks for query param `authLogin=true`. If set, force redirect the user to the keycloak login page. - * - * Redirects the user as appropriate, or renders the `children`. - * - * @param {*} { children } - * @return {*} - */ -const CheckForKeycloakAuthenticated = (props: PropsWithChildren>) => { - const { keycloakWrapper } = useContext(AuthStateContext); - - const location = useLocation(); + const projectAuthStateContext = useContext(ProjectAuthStateContext); - if (!keycloakWrapper?.keycloak.authenticated) { - // Trigger login, then redirect to the desired route - return ; + if ( + authStateContext.auth.isLoading || + authStateContext.simsUserWrapper.isLoading || + !projectAuthStateContext.hasLoadedParticipantInfo + ) { + // Participant data has not been loaded, can not yet determine if user has sufficient roles + return ; } - return <>{props.children}; -}; - -/** - * Waits for the keycloakWrapper to finish loading user info. - * - * Renders a spinner or the `children`. - * - * @param {*} { children } - * @return {*} - */ -const WaitForKeycloakToLoadUserInfo: React.FC = ({ children }) => { - const { keycloakWrapper } = useContext(AuthStateContext); - - if (!keycloakWrapper?.hasLoadedAllUserInfo) { - // User data has not been loaded, can not yet determine if user has sufficient roles - return ; + if ( + !projectAuthStateContext.hasProjectRole(validProjectRoles) && + !projectAuthStateContext.hasSystemRole(validSystemRoles) && + !projectAuthStateContext.hasProjectPermission(validProjectPermissions) + ) { + return ; } - return <>{children}; + return {children}; }; /** - * Waits for the projectAuthStateContext to finish loading project participant info. + * Route guard that requires the user to be authenticated and registered with Sims. * - * Renders a spinner or the `children`. - * - * @param {*} { children } + * @param {RouteProps} props * @return {*} */ -const WaitForProjectParticipantInfo = (props: PropsWithChildren>) => { - const projectAuthStateContext = useContext(ProjectAuthStateContext); +export const AuthenticatedRouteGuard = (props: RouteProps) => { + const { children, ...rest } = props; - if (!projectAuthStateContext.hasLoadedParticipantInfo) { - // Participant data has not been loaded, can not yet determine if user has sufficient roles - return ; - } + const authStateContext = useAuthStateContext(); - return <>{props.children}; -}; + const location = useLocation(); -/** - * Checks if the user is a registered user or has a pending access request. - * - * Redirects the user as appropriate, or renders the `children`. - * - * @param {*} { children } - * @return {*} - */ -const CheckIfAuthenticatedUser = (props: PropsWithChildren>) => { - const { keycloakWrapper } = useContext(AuthStateContext); + useEffect(() => { + if ( + !authStateContext.auth.isLoading && + !hasAuthParams() && + !authStateContext.auth.isAuthenticated && + !authStateContext.auth.activeNavigator + ) { + // User is not authenticated and has no active authentication navigator, redirect to the keycloak login page + authStateContext.auth.signinRedirect({ redirect_uri: buildUrl(window.location.origin, location.pathname) }); + } + }, [authStateContext.auth, location.pathname]); - const location = useLocation(); + if ( + authStateContext.auth.isLoading || + authStateContext.simsUserWrapper.isLoading || + !authStateContext.auth.isAuthenticated + ) { + return ; + } - if (!keycloakWrapper?.isSystemUser()) { + if (!authStateContext.simsUserWrapper.systemUserId) { // User is not a registered system user - if (keycloakWrapper?.hasAccessRequest) { - // The user has a pending access request, restrict them to the request-submitted or logout pages - if (location.pathname !== '/request-submitted' && location.pathname !== '/logout') { - return ; - } - } else { - // The user does not have a pending access request, restrict them to the access-request, request-submitted or logout pages - if (!['/access-request', '/request-submitted', '/logout'].includes(location.pathname)) { - /** - * User attempted to go to restricted page. If the request to fetch user data fails, the user - * can never navigate away from the forbidden page unless they refetch the user data by refreshing - * the browser. We can preemptively re-attempt to load the user data again each time they attempt to navigate - * away from the forbidden page. - */ - keycloakWrapper?.refresh(); - // Redirect to forbidden page - return ; - } + if (authStateContext.simsUserWrapper.hasAccessRequest && !['/request-submitted'].includes(location.pathname)) { + // The user has a pending access request and isn't already navigating to the request submitted page + return ; + } + + // The user does not have a pending access request, restrict them to public pages + if (!['/', '/access-request', '/request-submitted'].includes(location.pathname)) { + /** + * User attempted to go to a non-public page. If the request to fetch user data fails, the user + * can never navigate away from the forbidden page unless they refetch the user data by refreshing + * the browser. We can preemptively re-attempt to load the user data again each time they attempt to navigate + * away from the forbidden page. + */ + authStateContext.simsUserWrapper.refresh(); + // Redirect to forbidden page + return ; } } - return <>{props.children}; + // The user is a registered system user + return {children}; }; /** - * Checks if the user is not a registered user. - * - * Redirects the user as appropriate, or renders the `children`. + * Route guard that requires the user to not be authenticated. * - * @param {*} { children } + * @param {RouteProps} props * @return {*} */ -const CheckIfNotAuthenticatedUser = (props: PropsWithChildren>) => { - const { keycloakWrapper } = useContext(AuthStateContext); - const { redirect } = useRedirect('/'); +export const UnAuthenticatedRouteGuard = (props: RouteProps) => { + const { children, ...rest } = props; + + const authStateContext = useAuthStateContext(); - if (keycloakWrapper?.keycloak.authenticated) { + const redirectUri = useRedirectUri('/'); + + if (authStateContext.auth.isAuthenticated) { /** * If the user happens to be authenticated, rather than just redirecting them to `/`, we can * check if the URL contains a redirect query param, and send them there instead (for * example, links to `/login` generated by SIMS will typically include a redirect query param). * If there is no redirect query param, they will be sent to `/` as a fallback. */ - redirect(); - - return <>; - } - - return <>{props.children}; -}; - -/** - * Checks if a user has at least 1 of the specified `validRoles`. - * - * Redirects the user as appropriate, or renders the `children`. - * - * @param {*} { children, validRoles } - * @return {*} - */ -const CheckIfUserHasSystemRole = (props: ISystemRoleRouteGuardProps) => { - const { keycloakWrapper } = useContext(AuthStateContext); - - if (!keycloakWrapper?.hasSystemRole(props.validRoles)) { - return ; - } - - return <>{props.children}; -}; - -/** - * Checks if a user has at least 1 of the specified `validRoles`. - * - * Redirects the user as appropriate, or renders the `children`. - * - * @param {*} { children, validRoles } - * @return {*} - */ -const CheckIfUserHasProjectRole = (props: IProjectRoleRouteGuardProps) => { - const { validProjectRoles, validSystemRoles, validProjectPermissions, children } = props; - const { hasProjectRole, hasSystemRole, hasProjectPermission } = useContext(ProjectAuthStateContext); - - if ( - hasProjectRole(validProjectRoles) || - hasSystemRole(validSystemRoles) || - hasProjectPermission(validProjectPermissions) - ) { - return <>{children}; + return ; } - return ; + return {children}; }; diff --git a/app/src/components/surveys/SurveysList.test.tsx b/app/src/components/surveys/SurveysList.test.tsx index 5e46b29294..580084432f 100644 --- a/app/src/components/surveys/SurveysList.test.tsx +++ b/app/src/components/surveys/SurveysList.test.tsx @@ -1,8 +1,10 @@ +import { AuthStateContext } from 'contexts/authStateContext'; import { CodesContext, ICodesContext } from 'contexts/codesContext'; import { IProjectContext, ProjectContext } from 'contexts/projectContext'; import { createMemoryHistory } from 'history'; 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 { getProjectForViewResponse } from 'test-helpers/project-helpers'; import { getSurveyForListResponse } from 'test-helpers/survey-helpers'; @@ -31,14 +33,18 @@ describe('SurveysList', () => { projectId: 1 }; + const authState = getMockAuthState({ base: SystemAdminAuthState }); + const { getByText, queryByText } = render( - - - - - - - + + + + + + + + + ); expect(queryByText('No Surveys')).toBeNull(); @@ -61,14 +67,18 @@ describe('SurveysList', () => { projectId: 1 }; + const authState = getMockAuthState({ base: SystemAdminAuthState }); + const { getByText } = render( - - - - - - - + + + + + + + + + ); expect(getByText('No Surveys')).toBeInTheDocument(); diff --git a/app/src/components/surveys/SurveysList.tsx b/app/src/components/surveys/SurveysList.tsx index f532bc31a2..614b34bf4a 100644 --- a/app/src/components/surveys/SurveysList.tsx +++ b/app/src/components/surveys/SurveysList.tsx @@ -11,7 +11,6 @@ import assert from 'assert'; import { SubmitStatusChip } from 'components/chips/SubmitStatusChip'; import { SystemRoleGuard } from 'components/security/Guards'; import { SYSTEM_ROLE } from 'constants/roles'; -import { CodesContext } from 'contexts/codesContext'; import { ProjectContext } from 'contexts/projectContext'; import React, { useContext, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; @@ -19,14 +18,11 @@ import { Link as RouterLink } from 'react-router-dom'; //TODO: PRODUCTION_BANDAGE: Remove const SurveysList: React.FC = () => { - const codesContext = useContext(CodesContext); const projectContext = useContext(ProjectContext); const surveys = projectContext.surveysListDataLoader.data || []; - const codes = codesContext.codesDataLoader.data; assert(projectContext.surveysListDataLoader.data); - assert(codesContext.codesDataLoader.data); const [rowsPerPage] = useState(30); const [page] = useState(0); @@ -50,7 +46,6 @@ const SurveysList: React.FC = () => { Name Focal Species - Purpose Status @@ -64,18 +59,12 @@ const SurveysList: React.FC = () => { - {row.surveyData.survey_details.survey_name} + {row.surveyData.name} - {row.surveyData.species.focal_species_names.join('; ')} - - {row.surveyData.purpose_and_methodology.intended_outcome_id && - codes?.intended_outcomes?.find( - (item: any) => item.id === row.surveyData.purpose_and_methodology.intended_outcome_id - )?.name} - + {row.surveyData.focal_species_names.join('; ')} diff --git a/app/src/constants/auth.ts b/app/src/constants/auth.ts new file mode 100644 index 0000000000..0b67e2e555 --- /dev/null +++ b/app/src/constants/auth.ts @@ -0,0 +1,40 @@ +export enum SYSTEM_IDENTITY_SOURCE { + BCEID_BUSINESS = 'BCEIDBUSINESS', + BCEID_BASIC = 'BCEIDBASIC', + IDIR = 'IDIR', + DATABASE = 'DATABASE', + UNVERIFIED = 'UNVERIFIED' +} + +interface IUserInfo { + sub: string; + email_verified: boolean; + preferred_username: string; + identity_source: string; + display_name: string; + email: string; +} + +export interface IIDIRUserInfo extends IUserInfo { + idir_user_guid: string; + idir_username: string; + name: string; + given_name: string; + family_name: string; + identity_provider: 'idir'; +} + +interface IBCEIDUserInfo { + bceid_user_guid: string; + bceid_username: string; +} + +export interface IBCEIDBasicUserInfo extends IBCEIDUserInfo, IUserInfo { + identity_provider: 'bceidbasic'; +} + +export interface IBCEIDBusinessUserInfo extends IBCEIDUserInfo, IUserInfo { + bceid_business_guid: string; + bceid_business_name: string; + identity_provider: 'bceidbusiness'; +} diff --git a/app/src/constants/dateTimeFormats.ts b/app/src/constants/dateTimeFormats.ts index 99b4c384bb..5373bfe342 100644 --- a/app/src/constants/dateTimeFormats.ts +++ b/app/src/constants/dateTimeFormats.ts @@ -30,5 +30,6 @@ export enum DATE_LIMIT { * */ export enum TIME_FORMAT { - ShortTimeFormatAmPm = 'hh:mm A' //11:00 AM + ShortTimeFormatAmPm = 'hh:mm A', //11:00 AM + LongTimeFormat24Hour = 'HH:mm:ss' //23:00:00 } diff --git a/app/src/constants/i18n.ts b/app/src/constants/i18n.ts index 71c05a3dca..3882675367 100644 --- a/app/src/constants/i18n.ts +++ b/app/src/constants/i18n.ts @@ -1,3 +1,5 @@ +import { pluralize as p } from 'utils/Utils'; + export const CreateProjectI18N = { cancelTitle: 'Discard changes and exit?', cancelText: 'Any changes you have made will not be saved. Do you want to proceed?', @@ -290,9 +292,9 @@ export const SubmitProjectBiohubI18N = { }; export const SubmitSurveyBiohubI18N = { - submitSurveyBiohubDialogTitle: 'Submit Survey Information', - submitSurveyBiohubSuccessDialogTitle: 'Survey data submitted', - submitSurveyBiohubSuccessDialogText: 'Thank you for submitting your survey data to Biohub.', + submitSurveyBiohubDialogTitle: 'Publish Survey to BioHub BC', + submitSurveyBiohubSuccessDialogTitle: 'Survey published', + submitSurveyBiohubSuccessDialogText: 'Your survey has successfully been published to BioHub BC.', submitSurveyBiohubNoSubmissionDataDialogTitle: 'No survey data to submit', submitSurveyBiohubNoSubmissionDataDialogText: 'No new data or information has been added to this survey to submit.' }; @@ -305,28 +307,28 @@ export const SurveyAnimalsI18N = { animalSectionComment: (section: string) => `Add comment about this ${section}`, animalGeneralTitle: 'General', animalGeneralHelp: 'General information about this animal.', - animalCaptureTitle: 'Capture Information', + animalCaptureTitle: 'Capture Events', animalCaptureTitle2: 'Capture Event', animalCaptureHelp: 'Capture Events are when animals have been deliberately handled or immobilized. All capture events should be reported by adding a Capture Event to a new or existing individual.', animalCaptureAddBtn: 'Add Capture Event', - animalCaptureReleaseRadio: 'This individual was released at a different location than where it was captured', + animalCaptureReleaseRadio: 'This individual was released at a different location', animalMarkingTitle: 'Markings', animalMarkingTitle2: 'Animal Marking', animalMarkingHelp: - 'Markings are physical, chemical, or electronic tags or characteristics that uniquely identify individuals.', + 'Physical or chemical characteristics of an animal, or electronic tags that uniquely identify an individual.', animalMarkingAddBtn: 'Add Marking', animalMeasurementTitle: 'Measurements', animalMeasurementTitle2: 'Animal Measurement', animalMeasurementHelp: - 'Measurements are quantitative or categorical attributes, such as body mass or body condition, that describe an individual.', + 'Quantitative or categorical attributes, such as body mass or body condition, that describe an individual.', animalMeasurementAddBtn: 'Add Measurement', animalFamilyTitle: 'Family', animalFamilyTitle2: 'Animal Relationship', animalFamilyHelp: 'Family Relationships describe how multiple individuals are related to one another. You must add an individual before it can be referenced as a parent or child of another individual.', animalFamilyAddBtn: 'Add Relationship', - animalMortalityTitle: 'Mortality', + animalMortalityTitle: 'Mortality Events', animalMortalityTitle2: 'Mortality Event', animalMortalityHelp: "Mortality Events describe an individual's death, including the suspected location, date, and cause of death. An individual can only have one Mortality Event.", @@ -334,15 +336,17 @@ export const SurveyAnimalsI18N = { animalCollectionUnitTitle: 'Ecological Units', animalCollectionUnitTitle2: 'Ecological Unit', animalCollectionUnitHelp: - 'Ecological units are groups such as population units, herds, and packs. Different species may different units and unit names.', + 'Ecological units are groups such as population units, herds, and packs. Different species may have different units and unit names.', animalCollectionUnitAddBtn: 'Add Unit', // Input help strings taxonHelp: 'The species or taxon of the animal. If the species is unknown, select the lowest-ranking known taxon, such as the genus or family.', taxonLabelHelp: 'A unique name for you to recognize this individual.', wlhIdHelp: 'An ID used to identify animals in the BC Wildlife Health Program', - sexHelp: 'The sex of this critter. Leave as Unknown if unsure.' -}; + sexHelp: 'The sex of this critter. Leave as Unknown if unsure.', + telemetryDeviceHelp: + 'Devices transmit telemetry data while they are attached to an animal during a deployment. Animals may have multiple devices and deployments, however a single device may not have overlapping deployments.' +} as const; export const FundingSourceI18N = { cancelTitle: 'Discard changes and exit?', @@ -392,9 +396,41 @@ export const CreateSamplingSiteI18N = { export const ObservationsTableI18N = { removeAllDialogTitle: 'Discard changes?', removeAllDialogText: 'Are you sure you want to discard all your changes? This action cannot be undone.', - removeRecordDialogTitle: 'Delete record?', - removeRecordDialogText: 'Are you sure you want to delete this record? This action cannot be undone.', + removeSingleRecordDialogTitle: 'Delete record?', + removeSingleRecordDialogText: 'Are you sure you want to delete this record? This action cannot be undone.', + removeSingleRecordButtonText: 'Delete Record', + removeMultipleRecordsDialogTitle: (count: number) => `Delete ${count} ${p(count, 'record')}?`, + removeMultipleRecordsDialogText: 'Are you sure you want to delete these records? This action cannot be undone.', + removeMultipleRecordsButtonText: 'Delete Records', submitRecordsErrorDialogTitle: 'Error Updating Observation Records', submitRecordsErrorDialogText: - 'An error has occurred while attempting to update the observation records for this survey. Please try again. If the error persists, please contact your system administrator.' + 'An error has occurred while attempting to update the observation records for this survey. Please try again. If the error persists, please contact your system administrator.', + removeRecordsErrorDialogTitle: 'Error Deleting Observation Records', + removeRecordsErrorDialogText: + 'An error has occurred while attempting to delete observation records for this survey. Please try again. If the error persists, please contact your system administrator.', + saveRecordsSuccessSnackbarMessage: 'Observations updated successfully.', + deleteSingleRecordSuccessSnackbarMessage: 'Deleted observation record successfully.', + deleteMultipleRecordSuccessSnackbarMessage: (count: number) => + `Deleted ${count} observation ${p(count, 'record')} successfully.` +}; + +export const TelemetryTableI18N = { + removeAllDialogTitle: 'Discard changes?', + removeAllDialogText: 'Are you sure you want to discard all your changes? This action cannot be undone.', + removeSingleRecordDialogTitle: 'Delete record?', + removeSingleRecordDialogText: 'Are you sure you want to delete this record? This action cannot be undone.', + removeSingleRecordButtonText: 'Delete Record', + removeMultipleRecordsDialogTitle: (count: number) => `Delete ${count} ${p(count, 'record')}?`, + removeMultipleRecordsDialogText: 'Are you sure you want to delete these records? This action cannot be undone.', + removeMultipleRecordsButtonText: 'Delete Records', + submitRecordsErrorDialogTitle: 'Error Updating Telemetry Records', + submitRecordsErrorDialogText: + 'An error has occurred while attempting to update the telemetry records for this survey. Please try again. If the error persists, please contact your system administrator.', + removeRecordsErrorDialogTitle: 'Error Deleting Telemetry Records', + removeRecordsErrorDialogText: + 'An error has occurred while attempting to delete telemetry records for this survey. Please try again. If the error persists, please contact your system administrator.', + saveRecordsSuccessSnackbarMessage: 'Telemetry updated successfully.', + deleteSingleRecordSuccessSnackbarMessage: 'Deleted telemetry record successfully.', + deleteMultipleRecordSuccessSnackbarMessage: (count: number) => + `Deleted ${count} telemetry ${p(count, 'record')} successfully.` }; diff --git a/app/src/constants/spatial.ts b/app/src/constants/spatial.ts index 69c82d8f32..4fa76e5d3a 100644 --- a/app/src/constants/spatial.ts +++ b/app/src/constants/spatial.ts @@ -3,6 +3,7 @@ import { Feature } from 'geojson'; export const MAP_DEFAULT_ZOOM = 6; export const MAP_MIN_ZOOM = 6; export const MAP_MAX_ZOOM = 15; +export const MAP_DEFAULT_CENTER: L.LatLngExpression = [55, -128]; export const ALL_OF_BC_BOUNDARY: Feature = { type: 'Feature', diff --git a/app/src/contexts/authStateContext.test.tsx b/app/src/contexts/authStateContext.test.tsx index a7ff6509cb..1789ba1b29 100644 --- a/app/src/contexts/authStateContext.test.tsx +++ b/app/src/contexts/authStateContext.test.tsx @@ -1,17 +1,7 @@ -import Keycloak from 'keycloak-js'; import { getMockAuthState, SystemUserAuthState, UnauthenticatedUserAuthState } from 'test-helpers/auth-helpers'; import { cleanup, render, waitFor } from 'test-helpers/test-utils'; import { AuthStateContext } from './authStateContext'; -jest.mock('@react-keycloak/web', () => ({ - useKeycloak: jest.fn(() => ({ - initialized: true, - keycloak: { - authenticated: false - } as unknown as Keycloak - })) -})); - describe('AuthStateContext', () => { afterAll(() => { cleanup(); @@ -34,19 +24,7 @@ describe('AuthStateContext', () => { ); await waitFor(() => { - expect(captureAuthStateValue).toHaveBeenCalledWith({ - ...authState, - keycloakWrapper: { - ...authState.keycloakWrapper, - getIdentitySource: expect.any(Function), - getUserIdentifier: expect.any(Function), - getUserGuid: expect.any(Function), - refresh: expect.any(Function), - hasSystemRole: expect.any(Function), - getLoginUrl: expect.any(Function), - isSystemUser: expect.any(Function) - } - }); + expect(captureAuthStateValue).toHaveBeenCalledWith(authState); }); }); diff --git a/app/src/contexts/authStateContext.tsx b/app/src/contexts/authStateContext.tsx index c948a70b3e..2a5e3c428b 100644 --- a/app/src/contexts/authStateContext.tsx +++ b/app/src/contexts/authStateContext.tsx @@ -1,21 +1,53 @@ -import useKeycloakWrapper, { IKeycloakWrapper } from 'hooks/useKeycloakWrapper'; +import { default as useCritterbaseUserWrapper } from 'hooks/useCritterbaseUserWrapper'; +import useSimsUserWrapper, { ISimsUserWrapper } from 'hooks/useSimsUserWrapper'; import React from 'react'; +import { AuthContextProps, useAuth } from 'react-oidc-context'; export interface IAuthState { - keycloakWrapper?: IKeycloakWrapper; + /** + * The logged in user's Keycloak information. + * + * @type {AuthContextProps} + * @memberof IAuthState + */ + auth: AuthContextProps; + /** + * The logged in user's SIMS user information. + * + * @type {ISimsUserWrapper} + * @memberof IAuthState + */ + simsUserWrapper: ISimsUserWrapper; + /** + * THe logged in user's Critterbase user information. + * + * @type {ReturnType} + * @memberof IAuthState + */ + critterbaseUserWrapper: ReturnType; } -export const AuthStateContext = React.createContext({ - keycloakWrapper: undefined -}); +export const AuthStateContext = React.createContext(undefined); +/** + * Provides access to user and authentication (keycloak) data about the logged in user. + * + * @param {*} props + * @return {*} + */ export const AuthStateContextProvider: React.FC = (props) => { - const keycloakWrapper = useKeycloakWrapper(); + const auth = useAuth(); + + const simsUserWrapper = useSimsUserWrapper(); + + const critterbaseUserWrapper = useCritterbaseUserWrapper(simsUserWrapper); return ( {props.children} diff --git a/app/src/contexts/configContext.tsx b/app/src/contexts/configContext.tsx index 87e4b0ada5..bb158b520c 100644 --- a/app/src/contexts/configContext.tsx +++ b/app/src/contexts/configContext.tsx @@ -1,5 +1,4 @@ import axios from 'axios'; -import { KeycloakConfig } from 'keycloak-js'; import React, { useEffect, useState } from 'react'; import { ensureProtocol } from 'utils/Utils'; @@ -9,11 +8,16 @@ export interface IConfig { NODE_ENV: string; REACT_APP_NODE_ENV: string; VERSION: string; - KEYCLOAK_CONFIG: KeycloakConfig; + KEYCLOAK_CONFIG: { + authority: string; + realm: string; + clientId: string; + }; SITEMINDER_LOGOUT_URL: string; MAX_UPLOAD_NUM_FILES: number; MAX_UPLOAD_FILE_SIZE: number; S3_PUBLIC_HOST_URL: string; + BIOHUB_FEATURE_FLAG: boolean; } export const ConfigContext = React.createContext({ @@ -23,14 +27,15 @@ export const ConfigContext = React.createContext({ REACT_APP_NODE_ENV: '', VERSION: '', KEYCLOAK_CONFIG: { - url: '', + authority: '', realm: '', clientId: '' }, SITEMINDER_LOGOUT_URL: '', MAX_UPLOAD_NUM_FILES: 10, MAX_UPLOAD_FILE_SIZE: 52428800, - S3_PUBLIC_HOST_URL: '' + S3_PUBLIC_HOST_URL: '', + BIOHUB_FEATURE_FLAG: false }); /** @@ -53,14 +58,15 @@ const getLocalConfig = (): IConfig => { REACT_APP_NODE_ENV: process.env.REACT_APP_NODE_ENV || 'dev', VERSION: `${process.env.VERSION || 'NA'}(build #${process.env.CHANGE_VERSION || 'NA'})`, KEYCLOAK_CONFIG: { - url: process.env.REACT_APP_KEYCLOAK_HOST || '', + authority: process.env.REACT_APP_KEYCLOAK_HOST || '', realm: process.env.REACT_APP_KEYCLOAK_REALM || '', clientId: process.env.REACT_APP_KEYCLOAK_CLIENT_ID || '' }, SITEMINDER_LOGOUT_URL: process.env.REACT_APP_SITEMINDER_LOGOUT_URL || '', MAX_UPLOAD_NUM_FILES: Number(process.env.REACT_APP_MAX_UPLOAD_NUM_FILES) || 10, MAX_UPLOAD_FILE_SIZE: Number(process.env.REACT_APP_MAX_UPLOAD_FILE_SIZE) || 52428800, - S3_PUBLIC_HOST_URL: ensureProtocol(`${OBJECT_STORE_URL}/${OBJECT_STORE_BUCKET_NAME}`, 'https://') + S3_PUBLIC_HOST_URL: ensureProtocol(`${OBJECT_STORE_URL}/${OBJECT_STORE_BUCKET_NAME}`, 'https://'), + BIOHUB_FEATURE_FLAG: process.env.REACT_APP_BIOHUB_FEATURE_FLAG === 'true' }; }; diff --git a/app/src/contexts/dialogContext.tsx b/app/src/contexts/dialogContext.tsx index f990334251..a7be176451 100644 --- a/app/src/contexts/dialogContext.tsx +++ b/app/src/contexts/dialogContext.tsx @@ -60,6 +60,7 @@ export interface ISnackbarProps { severity?: Color; color?: Color; snackbarMessage: ReactNode; + snackbarAutoCloseMs?: number; //ms } export const defaultYesNoDialogProps: IYesNoDialogProps = { @@ -156,7 +157,7 @@ export const DialogContextProvider: React.FC = (props) horizontal: 'center' }} open={snackbarProps.open} - autoHideDuration={6000} + autoHideDuration={snackbarProps?.snackbarAutoCloseMs ?? 6000} onClose={() => setSnackbar({ open: false })} message={snackbarProps.snackbarMessage} action={ diff --git a/app/src/contexts/observationsContext.tsx b/app/src/contexts/observationsContext.tsx index 27c6dd7d45..dd15ccc976 100644 --- a/app/src/contexts/observationsContext.tsx +++ b/app/src/contexts/observationsContext.tsx @@ -1,33 +1,9 @@ -import { GridRowModelUpdate, useGridApiRef } from '@mui/x-data-grid'; -import { GridApiCommunity } from '@mui/x-data-grid/internals'; -import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; -import { ObservationsTableI18N } from 'constants/i18n'; -import { DialogContext } from 'contexts/dialogContext'; -import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader, { DataLoader } from 'hooks/useDataLoader'; import { IGetSurveyObservationsResponse } from 'interfaces/useObservationApi.interface'; -import { createContext, PropsWithChildren, useCallback, useContext, useState } from 'react'; -import { v4 as uuidv4 } from 'uuid'; +import { createContext, PropsWithChildren, useContext } from 'react'; import { SurveyContext } from './surveyContext'; -export interface IObservationRecord { - survey_observation_id: number | undefined; - wldtaxonomic_units_id: number | undefined; - survey_sample_site_id: number | undefined; - survey_sample_method_id: number | undefined; - survey_sample_period_id: number | undefined; - count: number | undefined; - observation_date: Date | undefined; - observation_time: string | undefined; - latitude: number | undefined; - longitude: number | undefined; -} - -export interface IObservationTableRow extends Partial { - id: string; -} - /** * Context object that stores information about survey observations * @@ -36,196 +12,26 @@ export interface IObservationTableRow extends Partial { */ export type IObservationsContext = { /** - * Appends a new blank record to the observation rows - */ - createNewRecord: () => void; - /** - * Commits all observation rows to the database, including those that are currently being edited in the Observation - * Table - */ - saveRecords: () => Promise; - /** - * Reverts all changes made to observation records within the Observation Table - */ - revertRecords: () => Promise; - /** - * Refreshes the Observation Table with already existing records - */ - refreshRecords: () => Promise; - /** - * Marks the given record as unsaved - */ - markRecordWithUnsavedChanges: (id: string | number) => void; - /** - * Indicates all observation table rows that have unsaved changes, include IDs of rows that have been deleted. - */ - unsavedRecordIds: string[]; - /** - * Indicates whether the observation table has any unsaved changes - */ - hasUnsavedChanges: () => boolean; - /** - * Data Loader used for retrieving existing records + * Data Loader used for retrieving survey observations */ observationsDataLoader: DataLoader<[], IGetSurveyObservationsResponse, unknown>; - /** - * API ref used to interface with an MUI DataGrid representing the observation records - */ - _muiDataGridApiRef: React.MutableRefObject; - /** - * The initial rows the data grid should render, if any. - */ - initialRows: IObservationTableRow[]; - /** - * A setState setter for the `initialRows` - */ - setInitialRows: React.Dispatch>; }; export const ObservationsContext = createContext({ - _muiDataGridApiRef: null as unknown as React.MutableRefObject, - observationsDataLoader: {} as DataLoader, - unsavedRecordIds: [], - initialRows: [], - setInitialRows: () => {}, - markRecordWithUnsavedChanges: () => {}, - hasUnsavedChanges: () => false, - createNewRecord: () => {}, - revertRecords: () => Promise.resolve(), - saveRecords: () => Promise.resolve(), - refreshRecords: () => Promise.resolve() + observationsDataLoader: {} as DataLoader }); export const ObservationsContextProvider = (props: PropsWithChildren>) => { const { projectId, surveyId } = useContext(SurveyContext); - const observationsDataLoader = useDataLoader(() => biohubApi.observation.getObservationRecords(projectId, surveyId)); - const _muiDataGridApiRef = useGridApiRef(); const biohubApi = useBiohubApi(); - const dialogContext = useContext(DialogContext); - const surveyContext = useContext(SurveyContext); - - const [unsavedRecordIds, _setUnsavedRecordIds] = useState([]); - const [initialRows, setInitialRows] = useState([]); - - const _hideErrorDialog = () => { - dialogContext.setErrorDialog({ - open: false - }); - }; - const _showErrorDialog = (textDialogProps?: Partial) => { - dialogContext.setErrorDialog({ - ...textDialogProps, - onOk: _hideErrorDialog, - onClose: _hideErrorDialog, - dialogTitle: ObservationsTableI18N.submitRecordsErrorDialogTitle, - dialogText: ObservationsTableI18N.submitRecordsErrorDialogText, - open: true - }); - }; + const observationsDataLoader = useDataLoader(() => biohubApi.observation.getObservationRecords(projectId, surveyId)); observationsDataLoader.load(); - const markRecordWithUnsavedChanges = (id: string | number) => { - const unsavedRecordSet = new Set(unsavedRecordIds); - unsavedRecordSet.add(String(id)); - - _setUnsavedRecordIds(Array.from(unsavedRecordSet)); - }; - - const createNewRecord = () => { - const id = uuidv4(); - markRecordWithUnsavedChanges(id); - - _muiDataGridApiRef.current.updateRows([ - { - id, - survey_observation_id: null, - wldtaxonomic_units: undefined, - survey_sample_site_id: undefined, - survey_sample_method_id: undefined, - survey_sample_period_id: undefined, - count: undefined, - observation_date: undefined, - observation_time: undefined, - latitude: undefined, - longitude: undefined - } as GridRowModelUpdate - ]); - - _muiDataGridApiRef.current.startRowEditMode({ id, fieldToFocus: 'wldtaxonomic_units' }); - }; - - const _getRows = (): IObservationTableRow[] => { - return Array.from(_muiDataGridApiRef.current.getRowModels?.()?.values()) as IObservationTableRow[]; - }; - - const _getActiveRecords = (): IObservationTableRow[] => { - return _getRows().map((row) => { - const editRow = _muiDataGridApiRef.current.state.editRows[row.id]; - - if (!editRow) { - return row; - } - return Object.entries(editRow).reduce( - (newRow, entry) => ({ ...row, ...newRow, _isModified: true, [entry[0]]: entry[1].value }), - {} - ); - }) as IObservationTableRow[]; - }; - - const saveRecords = async () => { - const editingIds = Object.keys(_muiDataGridApiRef.current.state.editRows); - editingIds.forEach((id) => _muiDataGridApiRef.current.stopRowEditMode({ id })); - - const { projectId, surveyId } = surveyContext; - const rows = _getActiveRecords(); - - try { - await biohubApi.observation.insertUpdateObservationRecords(projectId, surveyId, rows); - _setUnsavedRecordIds([]); - return refreshRecords(); - } catch (error) { - const apiError = error as APIError; - _showErrorDialog({ dialogErrorDetails: apiError.errors }); - return; - } - }; - - // TODO deleting a row and then calling method currently fails to recover said row... - const revertRecords = async () => { - // Mark all rows as saved - _setUnsavedRecordIds([]); - - // Revert any current edits - const editingIds = Object.keys(_muiDataGridApiRef.current.state.editRows); - editingIds.forEach((id) => _muiDataGridApiRef.current.stopRowEditMode({ id, ignoreModifications: true })); - - // Remove any rows that are newly created - _muiDataGridApiRef.current.setRows(initialRows); - }; - - const refreshRecords = async (): Promise => { - return observationsDataLoader.refresh(); - }; - - const hasUnsavedChanges = useCallback(() => { - return unsavedRecordIds.length > 0; - }, [unsavedRecordIds]); - const observationsContext: IObservationsContext = { - createNewRecord, - revertRecords, - saveRecords, - refreshRecords, - hasUnsavedChanges, - markRecordWithUnsavedChanges, - unsavedRecordIds, - observationsDataLoader, - _muiDataGridApiRef, - initialRows, - setInitialRows + observationsDataLoader }; return {props.children}; diff --git a/app/src/contexts/observationsTableContext.tsx b/app/src/contexts/observationsTableContext.tsx new file mode 100644 index 0000000000..bc50f04072 --- /dev/null +++ b/app/src/contexts/observationsTableContext.tsx @@ -0,0 +1,697 @@ +import Typography from '@mui/material/Typography'; +import { GridRowId, GridRowSelectionModel, GridValidRowModel, useGridApiRef } from '@mui/x-data-grid'; +import { GridApiCommunity } from '@mui/x-data-grid/internals'; +import { ObservationsTableI18N } from 'constants/i18n'; +import { DialogContext } from 'contexts/dialogContext'; +import { ObservationsContext } from 'contexts/observationsContext'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import moment from 'moment'; +import { createContext, PropsWithChildren, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import { RowValidationError, TableValidationModel } from '../components/data-grid/DataGridValidationAlert'; +import { SurveyContext } from './surveyContext'; +import { TaxonomyContext } from './taxonomyContext'; + +export interface IObservationRecord { + survey_observation_id: number; + wldtaxonomic_units_id: number; + survey_sample_site_id: number | null; + survey_sample_method_id: number | null; + survey_sample_period_id: number | null; + count: number | null; + observation_date: Date; + observation_time: string; + latitude: number | null; + longitude: number | null; +} + +export interface ISupplementaryObservationData { + observationCount: number; +} + +export interface IObservationTableRow extends Partial { + id: GridRowId; +} + +export type ObservationTableValidationModel = TableValidationModel; +export type ObservationRowValidationError = RowValidationError; + +/** + * Context object that provides helper functions for working with a survey observations data grid. + * + * @export + * @interface IObservationsTableContext + */ +export type IObservationsTableContext = { + /** + * API ref used to interface with an MUI DataGrid representing the observation records + */ + _muiDataGridApiRef: React.MutableRefObject; + /** + * The rows the data grid should render. + */ + rows: IObservationTableRow[]; + /** + * A setState setter for the `rows` + */ + setRows: React.Dispatch>; + /** + * Appends a new blank record to the observation rows + */ + addObservationRecord: () => void; + /** + * Transitions all rows in edit mode to view mode and triggers a commit of all observation rows to the database. + */ + saveObservationRecords: () => void; + /** + * Deletes all of the given records and removes them from the Observation table. + */ + deleteObservationRecords: (observationRecords: IObservationTableRow[]) => void; + /** + * Deletes all of the currently selected records and removes them from the Observation table. + */ + deleteSelectedObservationRecords: () => void; + /** + * Reverts all changes made to observation records within the Observation Table + */ + revertObservationRecords: () => void; + /** + * Refreshes the Observation Table with already existing records + */ + refreshObservationRecords: () => Promise; + /** + * Returns all of the observation table records that have been selected + */ + getSelectedObservationRecords: () => IObservationTableRow[]; + /** + * Indicates whether the observation table has any unsaved changes + */ + hasUnsavedChanges: boolean; + /** + * Callback that should be called when a row enters edit mode. + */ + onRowEditStart: (id: GridRowId) => void; + /** + * The IDs of the selected observation table rows + */ + rowSelectionModel: GridRowSelectionModel; + /** + * Sets the IDs of the selected observation table rows + */ + onRowSelectionModelChange: (rowSelectionModel: GridRowSelectionModel) => void; + /** + * Indicates if the data is in the process of being persisted to the server. + */ + isSaving: boolean; + /** + * Indicates whether or not content in the observations table is loading. + */ + isLoading: boolean; + /** + * The state of the validation model + */ + validationModel: ObservationTableValidationModel; + /** + * Reflects the count of total observations for the survey + */ + observationCount: number; + /** + * Updates the total observation count for the survey + */ + setObservationCount: (observationCount: number) => void; +}; + +export const ObservationsTableContext = createContext({ + _muiDataGridApiRef: null as unknown as React.MutableRefObject, + rows: [], + setRows: () => {}, + addObservationRecord: () => {}, + saveObservationRecords: () => {}, + deleteObservationRecords: () => undefined, + deleteSelectedObservationRecords: () => undefined, + revertObservationRecords: () => undefined, + refreshObservationRecords: () => Promise.resolve(), + getSelectedObservationRecords: () => [], + hasUnsavedChanges: false, + onRowEditStart: () => {}, + rowSelectionModel: [], + onRowSelectionModelChange: () => {}, + isSaving: false, + isLoading: false, + validationModel: {}, + observationCount: 0, + setObservationCount: () => undefined +}); + +export const ObservationsTableContextProvider = (props: PropsWithChildren>) => { + const { projectId, surveyId } = useContext(SurveyContext); + + const _muiDataGridApiRef = useGridApiRef(); + + const observationsContext = useContext(ObservationsContext); + const taxonomyContext = useContext(TaxonomyContext); + const dialogContext = useContext(DialogContext); + + const biohubApi = useBiohubApi(); + + // The data grid rows + const [rows, setRows] = useState([]); + // Stores the currently selected row ids + const [rowSelectionModel, setRowSelectionModel] = useState([]); + // Existing rows that are in edit mode + const [modifiedRowIds, setModifiedRowIds] = useState([]); + // New rows (regardless of mode) + const [addedRowIds, setAddedRowIds] = useState([]); + // True if the rows are in the process of transitioning from edit to view mode + const [_isStoppingEdit, _setIsStoppingEdit] = useState(false); + // True if the taxonomy cache has been initialized + const [hasInitializedTaxonomyCache, setHasInitializedTaxonomyCache] = useState(false); + // True if the records are in the process of being saved to the server + const [_isSaving, _setIsSaving] = useState(false); + // Stores the current count of observations for this survey + const [observationCount, setObservationCount] = useState(0); + // Stores the current validation state of the table + const [validationModel, setValidationModel] = useState({}); + + /** + * Gets all rows from the table, including values that have been edited in the table. + */ + const _getRowsWithEditedValues = (): IObservationTableRow[] => { + const rowValues = Array.from(_muiDataGridApiRef.current.getRowModels?.()?.values()) as IObservationTableRow[]; + + return rowValues.map((row) => { + const editRow = _muiDataGridApiRef.current.state.editRows[row.id]; + if (!editRow) { + return row; + } + + return Object.entries(editRow).reduce( + (newRow, entry) => ({ ...row, ...newRow, _isModified: true, [entry[0]]: entry[1].value }), + {} + ); + }) as IObservationTableRow[]; + }; + + /** + * Validates all rows belonging to the table. Returns null if validation passes, otherwise + * returns the validation model + */ + const _validateRows = (): ObservationTableValidationModel | null => { + const rowValues = _getRowsWithEditedValues(); + const tableColumns = _muiDataGridApiRef.current.getAllColumns(); + + const requiredColumns: (keyof IObservationTableRow)[] = [ + 'count', + 'latitude', + 'longitude', + 'observation_date', + 'observation_time', + 'wldtaxonomic_units_id' + ]; + + const samplingRequiredColumns: (keyof IObservationTableRow)[] = [ + 'survey_sample_site_id', + 'survey_sample_method_id', + 'survey_sample_period_id' + ]; + + const validation = rowValues.reduce((tableModel: ObservationTableValidationModel, row: IObservationTableRow) => { + const rowErrors: ObservationRowValidationError[] = []; + + // Validate missing columns + const missingColumns: Set = new Set(requiredColumns.filter((column) => !row[column])); + const missingSamplingColumns: (keyof IObservationTableRow)[] = samplingRequiredColumns.filter( + (column) => !row[column] + ); + + // If an observation is not an incidental record, then all sampling columns are required. + if (!missingSamplingColumns.includes('survey_sample_site_id')) { + // Record is non-incidental, namely one or more of its sampling columns is non-empty. + missingSamplingColumns.forEach((column) => missingColumns.add(column)); + + if (missingColumns.has('survey_sample_site_id')) { + // If sampling site is missing, then a sampling method may not be selected + missingColumns.add('survey_sample_method_id'); + } + + if (missingColumns.has('survey_sample_method_id')) { + // If sampling method is missing, then a sampling period may not be selected + missingColumns.add('survey_sample_period_id'); + } + } + + Array.from(missingColumns).forEach((field: keyof IObservationTableRow) => { + const columnName = tableColumns.find((column) => column.field === field)?.headerName ?? field; + rowErrors.push({ field, message: `Missing column: ${columnName}` }); + }); + + // Validate date value + if (row.observation_date && !moment(row.observation_date).isValid()) { + rowErrors.push({ field: 'observation_date', message: 'Invalid date' }); + } + + // Validate time value + if (row.observation_time === 'Invalid date') { + rowErrors.push({ field: 'observation_time', message: 'Invalid time' }); + } + + if (rowErrors.length > 0) { + tableModel[row.id] = rowErrors; + } + + return tableModel; + }, {}); + + setValidationModel(validation); + + return Object.keys(validation).length > 0 ? validation : null; + }; + + const _deleteRecords = useCallback( + async (observationRecords: IObservationTableRow[]): Promise => { + if (!observationRecords.length) { + return; + } + + const allRowIdsToDelete = observationRecords.map((item) => String(item.id)); + + // Get all row ids that are new, which only need to be removed from local state + const addedRowIdsToDelete = allRowIdsToDelete.filter((id) => addedRowIds.includes(id)); + + // Get all row ids that are not new, which need to be deleted from the server + const modifiedRowIdsToDelete = allRowIdsToDelete.filter((id) => !addedRowIds.includes(id)); + + try { + if (modifiedRowIdsToDelete.length) { + const response = await biohubApi.observation.deleteObservationRecords( + projectId, + surveyId, + modifiedRowIdsToDelete + ); + setObservationCount(response.supplementaryObservationData.observationCount); + } + + // Remove row IDs from validation model + setValidationModel((prevValidationModel) => + allRowIdsToDelete.reduce((newValidationModel, rowId) => { + delete newValidationModel[rowId]; + return newValidationModel; + }, prevValidationModel) + ); + + // Update all rows, removing deleted rows + setRows((current) => current.filter((item) => !allRowIdsToDelete.includes(String(item.id)))); + + // Update added rows, removing deleted rows + setAddedRowIds((current) => current.filter((id) => !addedRowIdsToDelete.includes(id))); + + // Updated editing rows, removing deleted rows + setModifiedRowIds((current) => current.filter((id) => !allRowIdsToDelete.includes(id))); + + // Close yes-no dialog + dialogContext.setYesNoDialog({ open: false }); + + // Show snackbar for successful deletion + dialogContext.setSnackbar({ + snackbarMessage: ( + + {observationRecords.length === 1 + ? ObservationsTableI18N.deleteSingleRecordSuccessSnackbarMessage + : ObservationsTableI18N.deleteMultipleRecordSuccessSnackbarMessage(observationRecords.length)} + + ), + open: true + }); + } catch { + // Close yes-no dialog + dialogContext.setYesNoDialog({ open: false }); + + // Show error dialog + dialogContext.setErrorDialog({ + onOk: () => dialogContext.setErrorDialog({ open: false }), + onClose: () => dialogContext.setErrorDialog({ open: false }), + dialogTitle: ObservationsTableI18N.removeRecordsErrorDialogTitle, + dialogText: ObservationsTableI18N.removeRecordsErrorDialogText, + open: true + }); + } + }, + [addedRowIds, dialogContext, biohubApi.observation, projectId, surveyId] + ); + + const getSelectedObservationRecords: () => IObservationTableRow[] = useCallback(() => { + if (!_muiDataGridApiRef?.current?.getRowModels) { + // Data grid is not fully initialized + return []; + } + + const rowValues = Array.from(_muiDataGridApiRef.current.getRowModels(), ([_, value]) => value); + return rowValues.filter((row): row is IObservationTableRow => + rowSelectionModel.includes((row as IObservationTableRow).id) + ); + }, [_muiDataGridApiRef, rowSelectionModel]); + + const deleteObservationRecords = useCallback( + (observationRecords: IObservationTableRow[]) => { + if (!observationRecords.length) { + return; + } + + dialogContext.setYesNoDialog({ + dialogTitle: + observationRecords.length === 1 + ? ObservationsTableI18N.removeSingleRecordDialogTitle + : ObservationsTableI18N.removeMultipleRecordsDialogTitle(observationRecords.length), + dialogText: + observationRecords.length === 1 + ? ObservationsTableI18N.removeSingleRecordDialogText + : ObservationsTableI18N.removeMultipleRecordsDialogText, + yesButtonProps: { + color: 'error', + loading: false + }, + yesButtonLabel: + observationRecords.length === 1 + ? ObservationsTableI18N.removeSingleRecordButtonText + : ObservationsTableI18N.removeMultipleRecordsButtonText, + noButtonProps: { color: 'primary', variant: 'outlined', disabled: false }, + noButtonLabel: 'Cancel', + open: true, + onYes: () => _deleteRecords(observationRecords), + onClose: () => dialogContext.setYesNoDialog({ open: false }), + onNo: () => dialogContext.setYesNoDialog({ open: false }) + }); + }, + [_deleteRecords, dialogContext] + ); + + const deleteSelectedObservationRecords = useCallback(() => { + const selectedRecords = getSelectedObservationRecords(); + if (!selectedRecords.length) { + return; + } + + deleteObservationRecords(selectedRecords); + }, [deleteObservationRecords, getSelectedObservationRecords]); + + const onRowEditStart = (id: GridRowId) => { + setModifiedRowIds((current) => Array.from(new Set([...current, String(id)]))); + }; + + /** + * Add a new empty record to the data grid. + */ + const addObservationRecord = useCallback(() => { + const id = uuidv4(); + + const newRecord: IObservationTableRow = { + id, + survey_observation_id: null as unknown as number, + wldtaxonomic_units_id: null as unknown as number, + survey_sample_site_id: null as unknown as number, + survey_sample_method_id: null as unknown as number, + survey_sample_period_id: null, + count: null as unknown as number, + observation_date: null as unknown as Date, + observation_time: '', + latitude: null as unknown as number, + longitude: null as unknown as number + }; + + // Append new record to initial rows + setRows([...rows, newRecord]); + + setAddedRowIds((current) => [...current, id]); + + // Set edit mode for the new row + _muiDataGridApiRef.current.startRowEditMode({ id, fieldToFocus: 'wldtaxonomic_units' }); + }, [_muiDataGridApiRef, rows]); + + /** + * Transition all editable rows from edit mode to view mode. + */ + const saveObservationRecords = useCallback(() => { + if (_isStoppingEdit) { + // Stop edit mode already in progress + return; + } + + // Validate rows + const validationErrors = _validateRows(); + + if (validationErrors) { + return; + } + + _setIsStoppingEdit(true); + + // Collect the ids of all rows in edit mode + const allEditingIds = Object.keys(_muiDataGridApiRef.current.state.editRows); + + // Remove any row ids that the data grid might still be tracking, but which have been removed from local state + const editingIdsToSave = allEditingIds.filter((id) => rows.find((row) => String(row.id) === id)); + + if (!editingIdsToSave.length) { + // No rows in edit mode, nothing to stop or save + _setIsStoppingEdit(false); + return; + } + + // Transition all rows in edit mode to view mode + for (const id of editingIdsToSave) { + _muiDataGridApiRef.current.stopRowEditMode({ id }); + } + + // Store ids of rows that were in edit mode + setModifiedRowIds(editingIdsToSave); + }, [_muiDataGridApiRef, _isStoppingEdit, rows]); + + /** + * Transition all rows tracked by `modifiedRowIds` to view mode. + */ + const _revertAllRowsEditMode = useCallback(() => { + modifiedRowIds.forEach((id) => _muiDataGridApiRef.current.startRowEditMode({ id })); + }, [_muiDataGridApiRef, modifiedRowIds]); + + const revertObservationRecords = useCallback(() => { + // Mark all rows as saved + setModifiedRowIds([]); + setAddedRowIds([]); + + // Revert any current edits + const editingIds = Object.keys(_muiDataGridApiRef.current.state.editRows); + editingIds.forEach((id) => _muiDataGridApiRef.current.stopRowEditMode({ id, ignoreModifications: true })); + + // Remove any rows that are newly created + setRows(rows.filter((row) => !addedRowIds.includes(String(row.id)))); + }, [_muiDataGridApiRef, addedRowIds, rows]); + + const refreshObservationRecords = useCallback(async () => { + return observationsContext.observationsDataLoader.refresh(); + }, [observationsContext.observationsDataLoader]); + + // True if the data grid contains at least 1 unsaved record + const hasUnsavedChanges = modifiedRowIds.length > 0 || addedRowIds.length > 0; + + /** + * Send all observation rows to the backend. + */ + const _saveRecords = useCallback( + async (rowsToSave: GridValidRowModel[]) => { + try { + await biohubApi.observation.insertUpdateObservationRecords( + projectId, + surveyId, + rowsToSave as IObservationTableRow[] + ); + + setModifiedRowIds([]); + setAddedRowIds([]); + + dialogContext.setSnackbar({ + snackbarMessage: ( + + {ObservationsTableI18N.saveRecordsSuccessSnackbarMessage} + + ), + open: true + }); + + return refreshObservationRecords(); + } catch (error) { + _revertAllRowsEditMode(); + const apiError = error as APIError; + dialogContext.setErrorDialog({ + onOk: () => dialogContext.setErrorDialog({ open: false }), + onClose: () => dialogContext.setErrorDialog({ open: false }), + dialogTitle: ObservationsTableI18N.submitRecordsErrorDialogTitle, + dialogText: ObservationsTableI18N.submitRecordsErrorDialogText, + dialogErrorDetails: apiError.errors, + open: true + }); + } finally { + _setIsSaving(false); + } + }, + [biohubApi.observation, projectId, surveyId, dialogContext, refreshObservationRecords, _revertAllRowsEditMode] + ); + + const isLoading: boolean = useMemo(() => { + return !hasInitializedTaxonomyCache || observationsContext.observationsDataLoader.isLoading; + }, [hasInitializedTaxonomyCache, observationsContext.observationsDataLoader.isLoading]); + + const isSaving: boolean = useMemo(() => { + return _isSaving || _isStoppingEdit; + }, [_isSaving, _isStoppingEdit]); + + /** + * Runs when observation context data has changed. This does not occur when records are + * deleted; Only on initial page load, and whenever records are saved. + */ + useEffect(() => { + if (!observationsContext.observationsDataLoader.hasLoaded) { + // Existing observation records have not yet loaded + return; + } + + if (!observationsContext.observationsDataLoader.data) { + // Existing observation data doesn't exist + return; + } + + // Collect rows from the observations data loader + const rows: IObservationTableRow[] = observationsContext.observationsDataLoader.data.surveyObservations.map( + (row: IObservationRecord) => ({ ...row, id: String(row.survey_observation_id) }) + ); + + // Set initial rows for the table context + setRows(rows); + + // Set initial observations count + setObservationCount(observationsContext.observationsDataLoader.data.supplementaryObservationData.observationCount); + }, [observationsContext.observationsDataLoader.data, observationsContext.observationsDataLoader.hasLoaded]); + + /** + * Runs onces on initial page load. + */ + useEffect(() => { + if (taxonomyContext.isLoading || hasInitializedTaxonomyCache) { + // Taxonomy cache is currently loading, or has already loaded + return; + } + + // Only attempt to initialize the cache once + setHasInitializedTaxonomyCache(true); + + if (!observationsContext.observationsDataLoader.data?.surveyObservations.length) { + // No taxonomy records to fetch and cache + return; + } + + const uniqueTaxonomicIds: number[] = Array.from( + observationsContext.observationsDataLoader.data.surveyObservations.reduce( + (acc: Set, record: IObservationRecord) => { + acc.add(record.wldtaxonomic_units_id); + return acc; + }, + new Set([]) + ) + ); + + // Fetch and cache all unique taxonomic IDs + taxonomyContext.cacheSpeciesTaxonomyByIds(uniqueTaxonomicIds).catch(() => {}); + }, [ + hasInitializedTaxonomyCache, + observationsContext.observationsDataLoader.data?.surveyObservations, + taxonomyContext + ]); + + /** + * Runs when row records are being saved and transitioned from Edit mode to View mode. + */ + useEffect(() => { + if (!_muiDataGridApiRef?.current?.getRowModels) { + // Data grid is not fully initialized + return; + } + + if (!_isStoppingEdit) { + // Stop edit mode not in progress, cannot save yet + return; + } + + if (!modifiedRowIds.length) { + // No rows to save + return; + } + + if (_isSaving) { + // Saving already in progress + return; + } + + if (modifiedRowIds.some((id) => _muiDataGridApiRef.current.getRowMode(id) === 'edit')) { + // Not all rows have transitioned to view mode, cannot save yet + return; + } + + // All rows have transitioned to view mode + _setIsStoppingEdit(false); + + // Start saving records + _setIsSaving(true); + + const rowModels = _muiDataGridApiRef.current.getRowModels(); + const rowValues = Array.from(rowModels, ([_, value]) => value); + + _saveRecords(rowValues); + }, [_muiDataGridApiRef, _saveRecords, _isSaving, _isStoppingEdit, modifiedRowIds]); + + const observationsTableContext: IObservationsTableContext = useMemo( + () => ({ + _muiDataGridApiRef, + rows, + setRows, + addObservationRecord, + saveObservationRecords, + deleteObservationRecords, + deleteSelectedObservationRecords, + revertObservationRecords, + refreshObservationRecords, + getSelectedObservationRecords, + hasUnsavedChanges, + onRowEditStart, + rowSelectionModel, + onRowSelectionModelChange: setRowSelectionModel, + isLoading, + isSaving, + validationModel, + observationCount, + setObservationCount + }), + [ + _muiDataGridApiRef, + rows, + addObservationRecord, + saveObservationRecords, + deleteObservationRecords, + deleteSelectedObservationRecords, + revertObservationRecords, + refreshObservationRecords, + getSelectedObservationRecords, + hasUnsavedChanges, + rowSelectionModel, + isLoading, + validationModel, + isSaving, + observationCount + ] + ); + + return ( + + {props.children} + + ); +}; diff --git a/app/src/contexts/projectAuthStateContext.tsx b/app/src/contexts/projectAuthStateContext.tsx index 06a3f4fb65..c9886e726f 100644 --- a/app/src/contexts/projectAuthStateContext.tsx +++ b/app/src/contexts/projectAuthStateContext.tsx @@ -1,9 +1,10 @@ +import { useAuthStateContext } from 'hooks/useAuthStateContext'; import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader from 'hooks/useDataLoader'; import { IGetUserProjectParticipantResponse } from 'interfaces/useProjectApi.interface'; -import React, { useCallback, useContext, useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useParams } from 'react-router'; -import { AuthStateContext } from './authStateContext'; +import { hasAtLeastOneValidValue } from 'utils/authUtils'; export interface IProjectAuthStateContext { getProjectParticipant: () => IGetUserProjectParticipantResponse; @@ -28,7 +29,7 @@ export const ProjectAuthStateContextProvider: React.FC const participantDataLoader = useDataLoader((projectId: number) => biohubApi.projectParticipants.getUserProjectParticipant(projectId) ); - const { keycloakWrapper } = useContext(AuthStateContext); + const authStateContext = useAuthStateContext(); const urlParams: Record = useParams(); const projectId: string | number | undefined = urlParams['id']; @@ -90,9 +91,9 @@ export const ProjectAuthStateContextProvider: React.FC return false; } - return !!keycloakWrapper && keycloakWrapper.hasSystemRole(validSystemRoles); + return hasAtLeastOneValidValue(validSystemRoles, authStateContext.simsUserWrapper.roleNames); }, - [keycloakWrapper] + [authStateContext.simsUserWrapper] ); React.useEffect(() => { diff --git a/app/src/contexts/projectContext.tsx b/app/src/contexts/projectContext.tsx index 87d179b66d..51161c22c0 100644 --- a/app/src/contexts/projectContext.tsx +++ b/app/src/contexts/projectContext.tsx @@ -55,7 +55,7 @@ export const ProjectContext = createContext({ export const ProjectContextProvider = (props: PropsWithChildren>) => { const biohubApi = useBiohubApi(); const projectDataLoader = useDataLoader(biohubApi.project.getProjectForView); - const surveysListDataLoader = useDataLoader(biohubApi.survey.getSurveysList); + const surveysListDataLoader = useDataLoader(biohubApi.survey.getSurveysBasicFieldsByProjectId); const artifactDataLoader = useDataLoader(biohubApi.project.getProjectAttachments); const urlParams: Record = useParams(); diff --git a/app/src/contexts/surveyContext.tsx b/app/src/contexts/surveyContext.tsx index d3b6c19b9e..6f805ca614 100644 --- a/app/src/contexts/surveyContext.tsx +++ b/app/src/contexts/surveyContext.tsx @@ -1,8 +1,10 @@ +import { IAnimalDeployment } from 'features/surveys/view/survey-animals/telemetry-device/device'; import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader, { DataLoader } from 'hooks/useDataLoader'; -import { IGetObservationSubmissionResponse } from 'interfaces/useObservationApi.interface'; +import { IGetObservationSubmissionResponse } from 'interfaces/useDwcaApi.interface'; import { IGetSummaryResultsResponse } from 'interfaces/useSummaryResultsApi.interface'; import { + IDetailedCritterWithInternalId, IGetSampleSiteResponse, IGetSurveyAttachmentsResponse, IGetSurveyForViewResponse @@ -61,6 +63,16 @@ export interface ISurveyContext { */ sampleSiteDataLoader: DataLoader<[project_id: number, survey_id: number], IGetSampleSiteResponse, unknown>; + deploymentDataLoader: DataLoader<[project_id: number, survey_id: number], IAnimalDeployment[], unknown>; + + /** + * The Data Loader used to load critters for a given survey + * + * @type {DataLoader<[project_id: number, survey_id: number], IDetailedCritterWithInternalId[], unknown>} + * @memberof ISurveyContext + */ + critterDataLoader: DataLoader<[project_id: number, survey_id: number], IDetailedCritterWithInternalId[], unknown>; + /** * The project ID belonging to the current project * @@ -88,6 +100,12 @@ export const SurveyContext = createContext({ summaryDataLoader: {} as DataLoader<[project_id: number, survey_id: number], IGetSummaryResultsResponse, unknown>, artifactDataLoader: {} as DataLoader<[project_id: number, survey_id: number], IGetSurveyAttachmentsResponse, unknown>, sampleSiteDataLoader: {} as DataLoader<[project_id: number, survey_id: number], IGetSampleSiteResponse, unknown>, + deploymentDataLoader: {} as DataLoader<[project_id: number, survey_id: number], IAnimalDeployment[], unknown>, + critterDataLoader: {} as DataLoader< + [project_id: number, survey_id: number], + IDetailedCritterWithInternalId[], + unknown + >, projectId: -1, surveyId: -1 }); @@ -95,10 +113,12 @@ export const SurveyContext = createContext({ export const SurveyContextProvider = (props: PropsWithChildren>) => { const biohubApi = useBiohubApi(); const surveyDataLoader = useDataLoader(biohubApi.survey.getSurveyForView); - const observationDataLoader = useDataLoader(biohubApi.observation.getObservationSubmission); + const observationDataLoader = useDataLoader(biohubApi.dwca.getObservationSubmission); const summaryDataLoader = useDataLoader(biohubApi.survey.getSurveySummarySubmission); const artifactDataLoader = useDataLoader(biohubApi.survey.getSurveyAttachments); const sampleSiteDataLoader = useDataLoader(biohubApi.samplingSite.getSampleSites); + const deploymentDataLoader = useDataLoader(biohubApi.survey.getDeploymentsInSurvey); + const critterDataLoader = useDataLoader(biohubApi.survey.getSurveyCritters); const urlParams: Record = useParams(); @@ -122,6 +142,8 @@ export const SurveyContextProvider = (props: PropsWithChildren ITaxonomy | null; + /** + * Caches taxonomy data for the given IDs. + */ + cacheSpeciesTaxonomyByIds: (ids: number[]) => Promise; +} + +export const TaxonomyContext = createContext({ + isLoading: false, + getCachedSpeciesTaxonomyById: () => null, + cacheSpeciesTaxonomyByIds: () => Promise.resolve() +}); + +export const TaxonomyContextProvider = (props: PropsWithChildren) => { + const biohubApi = useBiohubApi(); + + const isMounted = useIsMounted(); + + const [_taxonomyCache, _setTaxonomyCache] = useState>({}); + const _dispatchedIds = useRef>(new Set([])); + + const [isLoading, setIsLoading] = useState(false); + + const cacheSpeciesTaxonomyByIds = useCallback( + async (ids: number[]) => { + setIsLoading(true); + ids.forEach((id) => _dispatchedIds.current.add(id)); + + await biohubApi.taxonomy + .getSpeciesFromIds(ids) + .then((result) => { + const newTaxonomyItems: Record = {}; + + for (const item of result.searchResponse) { + newTaxonomyItems[item.id] = item; + } + + if (!isMounted()) { + return; + } + + _setTaxonomyCache((previous) => ({ ...previous, ...newTaxonomyItems })); + }) + .catch(() => {}) + .finally(() => { + if (!isMounted()) { + return; + } + + setIsLoading(false); + }); + }, + [biohubApi.taxonomy, isMounted] + ); + + const getCachedSpeciesTaxonomyById = useCallback( + (id: number): ITaxonomy | null => { + if (hasProperty(_taxonomyCache, id)) { + // Taxonomy id was found in the cache, return cached data + return getProperty(_taxonomyCache, id); + } + + if (_dispatchedIds.current.has(id)) { + // Request to fetch this taxon id is still pending + return null; + } + + // Dispatch a request to fetch the taxonomy and cache the result + cacheSpeciesTaxonomyByIds([id]); + + return null; + }, + [_taxonomyCache, cacheSpeciesTaxonomyByIds] + ); + + const taxonomyContext: ITaxonomyContext = useMemo( + () => ({ + isLoading, + getCachedSpeciesTaxonomyById, + cacheSpeciesTaxonomyByIds + }), + [isLoading, getCachedSpeciesTaxonomyById, cacheSpeciesTaxonomyByIds] + ); + + return {props.children}; +}; diff --git a/app/src/contexts/telemetryDataContext.tsx b/app/src/contexts/telemetryDataContext.tsx new file mode 100644 index 0000000000..7986a33f3c --- /dev/null +++ b/app/src/contexts/telemetryDataContext.tsx @@ -0,0 +1,23 @@ +import useDataLoader, { DataLoader } from 'hooks/useDataLoader'; +import { ITelemetry, useTelemetryApi } from 'hooks/useTelemetryApi'; +import { createContext, PropsWithChildren } from 'react'; + +export type ITelemetryDataContext = { + telemetryDataLoader: DataLoader<[ids: string[]], ITelemetry[], unknown>; +}; + +export const TelemetryDataContext = createContext({ + telemetryDataLoader: {} as DataLoader<[ids: string[]], ITelemetry[], unknown> +}); + +export const TelemetryDataContextProvider = (props: PropsWithChildren>) => { + const telemetryApi = useTelemetryApi(); + + const telemetryDataLoader = useDataLoader(telemetryApi.getAllTelemetry); + + const telemetryDataContext: ITelemetryDataContext = { + telemetryDataLoader + }; + + return {props.children}; +}; diff --git a/app/src/contexts/telemetryTableContext.tsx b/app/src/contexts/telemetryTableContext.tsx new file mode 100644 index 0000000000..1b01a11aca --- /dev/null +++ b/app/src/contexts/telemetryTableContext.tsx @@ -0,0 +1,665 @@ +import Typography from '@mui/material/Typography'; +import { GridRowId, GridRowSelectionModel, GridValidRowModel, useGridApiRef } from '@mui/x-data-grid'; +import { GridApiCommunity } from '@mui/x-data-grid/internals'; +import { TelemetryTableI18N } from 'constants/i18n'; +import { DialogContext } from 'contexts/dialogContext'; +import { APIError } from 'hooks/api/useAxios'; +import { ICreateManualTelemetry, IUpdateManualTelemetry, useTelemetryApi } from 'hooks/useTelemetryApi'; +import moment from 'moment'; +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import { RowValidationError, TableValidationModel } from '../components/data-grid/DataGridValidationAlert'; +import { TelemetryDataContext } from './telemetryDataContext'; + +export interface IManualTelemetryRecord { + deployment_id: string; + latitude: number; + longitude: number; + date: string; + time: string; + telemetry_type: string; +} + +export interface IManualTelemetryTableRow extends Partial { + id: GridRowId; +} + +export type TelemetryTableValidationModel = TableValidationModel; +export type TelemetryRowValidationError = RowValidationError; + +export type ITelemetryTableContext = { + /** + * API ref used to interface with an MUI DataGrid representing the telemetry records + */ + _muiDataGridApiRef: React.MutableRefObject; + /** + * The rows the data grid should render. + */ + rows: IManualTelemetryTableRow[]; + /** + * A setState setter for the `rows` + */ + setRows: React.Dispatch>; + /** + * Appends a new blank record to the telemetry rows + */ + addRecord: () => void; + /** + * Transitions all rows in edit mode to view mode and triggers a commit of all telemetry rows to the database. + */ + saveRecords: () => void; + /** + * Deletes all of the given records and removes them from the Telemetry table. + */ + deleteRecords: (telemetryRecords: IManualTelemetryTableRow[]) => void; + /** + * Deletes all of the currently selected records and removes them from the Telemetry table. + */ + deleteSelectedRecords: () => void; + /** + * Reverts all changes made to telemetry records within the Telemetry Table + */ + revertRecords: () => void; + /** + * Refreshes the Telemetry Table with already existing records + */ + refreshRecords: () => Promise; + /** + * Returns all of the telemetry table records that have been selected + */ + getSelectedRecords: () => IManualTelemetryTableRow[]; + /** + * Indicates whether the telemetry table has any unsaved changes + */ + hasUnsavedChanges: boolean; + /** + * Callback that should be called when a row enters edit mode. + */ + onRowEditStart: (id: GridRowId) => void; + /** + * The IDs of the selected telemetry table rows + */ + rowSelectionModel: GridRowSelectionModel; + /** + * Sets the IDs of the selected telemetry table rows + */ + onRowSelectionModelChange: (rowSelectionModel: GridRowSelectionModel) => void; + /** + * Indicates if the data is in the process of being persisted to the server. + */ + isSaving: boolean; + /** + * Indicates whether or not content in the telemetry table is loading. + */ + isLoading: boolean; + /** + * The state of the validation model + */ + validationModel: TelemetryTableValidationModel; + /** + * Reflects the total count of telemetry records for the survey + */ + recordCount: number; + /** + * Updates the total telemetry count for the survey + */ + setRecordCount: (count: number) => void; +}; + +export const TelemetryTableContext = createContext({ + _muiDataGridApiRef: null as unknown as React.MutableRefObject, + rows: [], + setRows: () => {}, + addRecord: () => {}, + saveRecords: () => {}, + deleteRecords: () => undefined, + deleteSelectedRecords: () => undefined, + revertRecords: () => undefined, + refreshRecords: () => Promise.resolve(), + getSelectedRecords: () => [], + hasUnsavedChanges: false, + onRowEditStart: () => {}, + rowSelectionModel: [], + onRowSelectionModelChange: () => {}, + isSaving: false, + isLoading: false, + validationModel: {}, + recordCount: 0, + setRecordCount: () => undefined +}); + +interface ITelemetryTableContextProviderProps { + deployment_ids: string[]; + children?: React.ReactNode; +} + +export const TelemetryTableContextProvider: React.FC = (props) => { + const { children, deployment_ids } = props; + + const _muiDataGridApiRef = useGridApiRef(); + + const telemetryApi = useTelemetryApi(); + + const telemetryDataContext = useContext(TelemetryDataContext); + const dialogContext = useContext(DialogContext); + + // The data grid rows + const [rows, setRows] = useState([]); + // Stores the currently selected row ids + const [rowSelectionModel, setRowSelectionModel] = useState([]); + // Existing rows that are in edit mode + const [modifiedRowIds, setModifiedRowIds] = useState([]); + // New rows (regardless of mode) + const [addedRowIds, setAddedRowIds] = useState([]); + // True if the rows are in the process of transitioning from edit to view mode + const [isStoppingEdit, setIsStoppingEdit] = useState(false); + // True if the records are in the process of being saved to the server + const [isCurrentlySaving, setIsCurrentlySaving] = useState(false); + // Stores the current count of telemetry records for this survey + const [recordCount, setRecordCount] = useState(0); + // Stores the current validation state of the table + const [validationModel, setValidationModel] = useState({}); + + /** + * Gets all rows from the table, including values that have been edited in the table. + */ + const _getRowsWithEditedValues = (): IManualTelemetryTableRow[] => { + const rowValues = Array.from(_muiDataGridApiRef.current.getRowModels?.()?.values()) as IManualTelemetryTableRow[]; + + return rowValues.map((row) => { + const editRow = _muiDataGridApiRef.current.state.editRows[row.id]; + if (!editRow) { + return row; + } + + return Object.entries(editRow).reduce( + (newRow, entry) => ({ ...row, ...newRow, _isModified: true, [entry[0]]: entry[1].value }), + {} + ); + }) as IManualTelemetryTableRow[]; + }; + + /** + * Validates all rows belonging to the table. Returns null if validation passes, otherwise + * returns the validation model + */ + const _validateRows = (): TelemetryTableValidationModel | null => { + const rowValues = _getRowsWithEditedValues(); + const tableColumns = _muiDataGridApiRef.current.getAllColumns(); + + const requiredColumns: (keyof IManualTelemetryTableRow)[] = [ + 'deployment_id', + 'latitude', + 'longitude', + 'date', + 'time' + ]; + + const validation = rowValues.reduce((tableModel: TelemetryTableValidationModel, row: IManualTelemetryTableRow) => { + const rowErrors: TelemetryRowValidationError[] = []; + + // Validate missing columns + const missingColumns: Set = new Set( + requiredColumns.filter((column) => !row[column]) + ); + + Array.from(missingColumns).forEach((field: keyof IManualTelemetryTableRow) => { + const columnName = tableColumns.find((column) => column.field === field)?.headerName ?? field; + rowErrors.push({ field, message: `Missing column: ${columnName}` }); + }); + + // Validate date value + if (row.date && !moment(row.date).isValid()) { + rowErrors.push({ field: 'date', message: 'Invalid date' }); + } + + // Validate time value + if (row.time === 'Invalid date') { + rowErrors.push({ field: 'time', message: 'Invalid time' }); + } + + if (rowErrors.length > 0) { + tableModel[row.id] = rowErrors; + } + + return tableModel; + }, {}); + + setValidationModel(validation); + + return Object.keys(validation).length > 0 ? validation : null; + }; + + useEffect(() => { + setRecordCount(rows.length); + }, [rows]); + + const _commitDeleteRecords = useCallback( + async (telemetryRecords: IManualTelemetryTableRow[]): Promise => { + if (!telemetryRecords.length) { + return; + } + + const allRowIdsToDelete = telemetryRecords.map((item) => String(item.id)); + + // Get all row ids that are new, which only need to be removed from local state + const addedRowIdsToDelete = allRowIdsToDelete.filter((id) => addedRowIds.includes(id)); + + // Get all row ids that are not new, which need to be deleted from the server + const modifiedRowIdsToDelete = allRowIdsToDelete.filter((id) => !addedRowIds.includes(id)); + + try { + if (modifiedRowIdsToDelete.length) { + await telemetryApi.deleteManualTelemetry(modifiedRowIdsToDelete); + } + + // Remove row IDs from validation model + setValidationModel((prevValidationModel) => + allRowIdsToDelete.reduce((newValidationModel, rowId) => { + delete newValidationModel[rowId]; + return newValidationModel; + }, prevValidationModel) + ); + + // Update all rows, removing deleted rows + setRows((current) => current.filter((item) => !allRowIdsToDelete.includes(String(item.id)))); + + // Update added rows, removing deleted rows + setAddedRowIds((current) => current.filter((id) => !addedRowIdsToDelete.includes(id))); + + // Updated editing rows, removing deleted rows + setModifiedRowIds((current) => current.filter((id) => !allRowIdsToDelete.includes(id))); + + // Close yes-no dialog + dialogContext.setYesNoDialog({ open: false }); + + // Show snackbar for successful deletion + dialogContext.setSnackbar({ + snackbarMessage: ( + + {telemetryRecords.length === 1 + ? TelemetryTableI18N.deleteSingleRecordSuccessSnackbarMessage + : TelemetryTableI18N.deleteMultipleRecordSuccessSnackbarMessage(telemetryRecords.length)} + + ), + open: true + }); + } catch { + // Close yes-no dialog + dialogContext.setYesNoDialog({ open: false }); + + // Show error dialog + dialogContext.setErrorDialog({ + onOk: () => dialogContext.setErrorDialog({ open: false }), + onClose: () => dialogContext.setErrorDialog({ open: false }), + dialogTitle: TelemetryTableI18N.removeRecordsErrorDialogTitle, + dialogText: TelemetryTableI18N.removeRecordsErrorDialogText, + open: true + }); + } + }, + [addedRowIds, dialogContext] + ); + + const getSelectedRecords: () => IManualTelemetryTableRow[] = useCallback(() => { + if (!_muiDataGridApiRef?.current?.getRowModels) { + // Data grid is not fully initialized + return []; + } + + const rowValues = Array.from(_muiDataGridApiRef.current.getRowModels(), ([_, value]) => value); + return rowValues.filter((row): row is IManualTelemetryTableRow => + rowSelectionModel.includes((row as IManualTelemetryTableRow).id) + ); + }, [_muiDataGridApiRef, rowSelectionModel]); + + const deleteRecords = useCallback( + (telemetryRecords: IManualTelemetryTableRow[]) => { + if (!telemetryRecords.length) { + return; + } + + dialogContext.setYesNoDialog({ + dialogTitle: + telemetryRecords.length === 1 + ? TelemetryTableI18N.removeSingleRecordDialogTitle + : TelemetryTableI18N.removeMultipleRecordsDialogTitle(telemetryRecords.length), + dialogText: + telemetryRecords.length === 1 + ? TelemetryTableI18N.removeSingleRecordDialogText + : TelemetryTableI18N.removeMultipleRecordsDialogText, + yesButtonProps: { + color: 'error', + loading: false + }, + yesButtonLabel: + telemetryRecords.length === 1 + ? TelemetryTableI18N.removeSingleRecordButtonText + : TelemetryTableI18N.removeMultipleRecordsButtonText, + noButtonProps: { color: 'primary', variant: 'outlined', disabled: false }, + noButtonLabel: 'Cancel', + open: true, + onYes: () => { + _commitDeleteRecords(telemetryRecords); + }, + onClose: () => dialogContext.setYesNoDialog({ open: false }), + onNo: () => dialogContext.setYesNoDialog({ open: false }) + }); + }, + [_commitDeleteRecords, dialogContext] + ); + + const deleteSelectedRecords = useCallback(() => { + const selectedRecords = getSelectedRecords(); + if (!selectedRecords.length) { + return; + } + + deleteRecords(selectedRecords); + }, [deleteRecords, getSelectedRecords]); + + const onRowEditStart = (id: GridRowId) => { + setModifiedRowIds((current) => Array.from(new Set([...current, String(id)]))); + }; + + /** + * Add a new empty record to the data grid. + */ + const addRecord = useCallback(() => { + const id = uuidv4(); + + const newRecord: IManualTelemetryTableRow = { + id, + deployment_id: '', + latitude: null as unknown as number, + longitude: null as unknown as number, + date: '', + time: '', + telemetry_type: 'MANUAL' + }; + + // Append new record to initial rows + setRows([...rows, newRecord]); + + setAddedRowIds((current) => [...current, id]); + + // Set edit mode for the new row + _muiDataGridApiRef.current.startRowEditMode({ id, fieldToFocus: 'wldtaxonomic_units' }); + }, [_muiDataGridApiRef, rows]); + + /** + * Transition all editable rows from edit mode to view mode. + */ + const saveRecords = useCallback(() => { + if (isStoppingEdit) { + // Stop edit mode already in progress + return; + } + + // Validate rows + const validationErrors = _validateRows(); + + if (validationErrors) { + return; + } + + setIsStoppingEdit(true); + + // Collect the ids of all rows in edit mode + const allEditingIds = Object.keys(_muiDataGridApiRef.current.state.editRows); + + // Remove any row ids that the data grid might still be tracking, but which have been removed from local state + const editingIdsToSave = allEditingIds.filter((id) => rows.find((row) => String(row.id) === id)); + + if (!editingIdsToSave.length) { + // No rows in edit mode, nothing to stop or save + setIsStoppingEdit(false); + return; + } + + // Transition all rows in edit mode to view mode + for (const id of editingIdsToSave) { + _muiDataGridApiRef.current.stopRowEditMode({ id }); + } + + // Store ids of rows that were in edit mode + setModifiedRowIds(editingIdsToSave); + }, [_muiDataGridApiRef, isStoppingEdit, rows]); + + /** + * Transition all rows tracked by `modifiedRowIds` to view mode. + */ + const _revertAllRowsEditMode = useCallback(() => { + modifiedRowIds.forEach((id) => _muiDataGridApiRef.current.startRowEditMode({ id })); + }, [_muiDataGridApiRef, modifiedRowIds]); + + const revertRecords = useCallback(() => { + // Mark all rows as saved + setModifiedRowIds([]); + setAddedRowIds([]); + + // Revert any current edits + const editingIds = Object.keys(_muiDataGridApiRef.current.state.editRows); + editingIds.forEach((id) => _muiDataGridApiRef.current.stopRowEditMode({ id, ignoreModifications: true })); + + // Remove any rows that are newly created + setRows(rows.filter((row) => !addedRowIds.includes(String(row.id)))); + }, [_muiDataGridApiRef, addedRowIds, rows]); + + const refreshRecords = useCallback(async () => { + if (telemetryDataContext.telemetryDataLoader.isReady) { + telemetryDataContext.telemetryDataLoader.refresh(deployment_ids); + } + }, [telemetryDataContext.telemetryDataLoader]); + + // True if the data grid contains at least 1 unsaved record + const hasUnsavedChanges = modifiedRowIds.length > 0 || addedRowIds.length > 0; + + /** + * Send all telemetry rows to the backend. + */ + const _saveRecords = useCallback( + async (rowsToSave: GridValidRowModel[]) => { + try { + const createData: ICreateManualTelemetry[] = []; + const updateData: IUpdateManualTelemetry[] = []; + // loop through records and decide based on initial data loaded if a record should be created or updated + (rowsToSave as IManualTelemetryTableRow[]).forEach((item) => { + if (item.telemetry_type === 'MANUAL') { + const found = telemetryDataContext.telemetryDataLoader.data?.find( + (search) => search.telemetry_manual_id === item.id + ); + if (found) { + // existing ID found, update record + updateData.push({ + telemetry_manual_id: String(item.id), + latitude: Number(item.latitude), + longitude: Number(item.longitude), + acquisition_date: moment(`${moment(item.date).format('YYYY-MM-DD')} ${item.time}`).format( + 'YYYY-MM-DD HH:mm:ss' + ) + }); + } else { + // nothing found, create a new record + createData.push({ + deployment_id: String(item.deployment_id), + latitude: Number(item.latitude), + longitude: Number(item.longitude), + acquisition_date: moment(`${moment(item.date).format('YYYY-MM-DD')} ${item.time}`).format( + 'YYYY-MM-DD HH:mm:ss' + ) + }); + } + } + }); + + if (createData.length) { + await telemetryApi.createManualTelemetry(createData); + } + + if (updateData.length) { + await telemetryApi.updateManualTelemetry(updateData); + } + + setModifiedRowIds([]); + setAddedRowIds([]); + + dialogContext.setSnackbar({ + snackbarMessage: ( + + {TelemetryTableI18N.saveRecordsSuccessSnackbarMessage} + + ), + open: true + }); + + return refreshRecords(); + } catch (error) { + _revertAllRowsEditMode(); + const apiError = error as APIError; + dialogContext.setErrorDialog({ + onOk: () => dialogContext.setErrorDialog({ open: false }), + onClose: () => dialogContext.setErrorDialog({ open: false }), + dialogTitle: TelemetryTableI18N.submitRecordsErrorDialogTitle, + dialogText: TelemetryTableI18N.submitRecordsErrorDialogText, + dialogErrorDetails: apiError.errors, + open: true + }); + } finally { + setIsCurrentlySaving(false); + } + }, + [dialogContext, refreshRecords, _revertAllRowsEditMode] + ); + + const isLoading: boolean = useMemo(() => { + return telemetryDataContext.telemetryDataLoader.isLoading; + }, [telemetryDataContext.telemetryDataLoader.isLoading]); + + const isSaving: boolean = useMemo(() => { + return isCurrentlySaving || isStoppingEdit; + }, [isCurrentlySaving, isStoppingEdit]); + + useEffect(() => { + // Begin fetching telemetry once we have deployments ids + if (deployment_ids.length) { + telemetryDataContext.telemetryDataLoader.load(deployment_ids); + } + }, [deployment_ids]); + + /** + * Runs when telemetry context data has changed. This does not occur when records are + * deleted; Only on initial page load, and whenever records are saved. + */ + useEffect(() => { + if (telemetryDataContext.telemetryDataLoader.isLoading) { + // Existing telemetry records have not yet loaded + return; + } + + // Collect rows from the telemetry data loader + const totalTelemetry = telemetryDataContext.telemetryDataLoader.data ?? []; + + const rows: IManualTelemetryTableRow[] = totalTelemetry.map((item) => { + return { + id: item.id, + deployment_id: item.deployment_id, + latitude: item.latitude, + longitude: item.longitude, + date: moment(item.acquisition_date).format('YYYY-MM-DD'), + time: moment(item.acquisition_date).format('HH:mm:ss'), + telemetry_type: item.telemetry_type + }; + }); + + // Set initial rows for the table context + setRows(rows); + + // Set initial record count + setRecordCount(rows.length); + }, [telemetryDataContext.telemetryDataLoader.isLoading]); + + /** + * Runs when row records are being saved and transitioned from Edit mode to View mode. + */ + useEffect(() => { + if (!_muiDataGridApiRef?.current?.getRowModels) { + // Data grid is not fully initialized + return; + } + + if (!isStoppingEdit) { + // Stop edit mode not in progress, cannot save yet + return; + } + + if (!modifiedRowIds.length) { + // No rows to save + return; + } + + if (isCurrentlySaving) { + // Saving already in progress + return; + } + + if (modifiedRowIds.some((id) => _muiDataGridApiRef.current.getRowMode(id) === 'edit')) { + // Not all rows have transitioned to view mode, cannot save yet + return; + } + + // All rows have transitioned to view mode + setIsStoppingEdit(false); + + // Start saving records + setIsCurrentlySaving(true); + + const rowModels = _muiDataGridApiRef.current.getRowModels(); + const rowValues = Array.from(rowModels, ([_, value]) => value); + + _saveRecords(rowValues); + }, [_muiDataGridApiRef, _saveRecords, isCurrentlySaving, isStoppingEdit, modifiedRowIds]); + + const telemetryTableContext: ITelemetryTableContext = useMemo( + () => ({ + _muiDataGridApiRef, + rows, + setRows, + addRecord, + saveRecords, + deleteRecords, + deleteSelectedRecords, + revertRecords, + refreshRecords, + getSelectedRecords, + hasUnsavedChanges, + onRowEditStart, + rowSelectionModel, + onRowSelectionModelChange: setRowSelectionModel, + isLoading, + isSaving, + validationModel, + recordCount, + setRecordCount + }), + [ + _muiDataGridApiRef, + rows, + addRecord, + saveRecords, + deleteRecords, + deleteSelectedRecords, + revertRecords, + refreshRecords, + getSelectedRecords, + hasUnsavedChanges, + rowSelectionModel, + isLoading, + validationModel, + isSaving, + recordCount + ] + ); + + return {children}; +}; diff --git a/app/src/features/admin/users/AccessRequestList.test.tsx b/app/src/features/admin/users/AccessRequestList.test.tsx index 67b93462b7..a7513728c1 100644 --- a/app/src/features/admin/users/AccessRequestList.test.tsx +++ b/app/src/features/admin/users/AccessRequestList.test.tsx @@ -1,6 +1,6 @@ +import { SYSTEM_IDENTITY_SOURCE } from 'constants/auth'; import AccessRequestList from 'features/admin/users/AccessRequestList'; import { useBiohubApi } from 'hooks/useBioHubApi'; -import { SYSTEM_IDENTITY_SOURCE } from 'hooks/useKeycloakWrapper'; import { IAccessRequestDataObject, IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; import { codes } from 'test-helpers/code-helpers'; diff --git a/app/src/features/admin/users/ActiveUsersList.test.tsx b/app/src/features/admin/users/ActiveUsersList.test.tsx index 43575c3dc2..ee027dcca9 100644 --- a/app/src/features/admin/users/ActiveUsersList.test.tsx +++ b/app/src/features/admin/users/ActiveUsersList.test.tsx @@ -1,6 +1,8 @@ +import { AuthStateContext } from 'contexts/authStateContext'; import { createMemoryHistory } from 'history'; import { useBiohubApi } from 'hooks/useBioHubApi'; 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 ActiveUsersList, { IActiveUsersListProps } from './ActiveUsersList'; @@ -21,10 +23,14 @@ const mockUseApi = { }; const renderContainer = (props: IActiveUsersListProps) => { + const authState = getMockAuthState({ base: SystemAdminAuthState }); + return render( - - - + + + + + ); }; diff --git a/app/src/features/admin/users/ActiveUsersList.tsx b/app/src/features/admin/users/ActiveUsersList.tsx index f5fd2c4acc..c757ceafc1 100644 --- a/app/src/features/admin/users/ActiveUsersList.tsx +++ b/app/src/features/admin/users/ActiveUsersList.tsx @@ -19,6 +19,7 @@ import { CustomMenuButton, CustomMenuIconButton } from 'components/toolbar/Actio import { AddSystemUserI18N, DeleteSystemUserI18N, UpdateSystemUserI18N } from 'constants/i18n'; import { DialogContext, ISnackbarProps } from 'contexts/dialogContext'; import { APIError } from 'hooks/api/useAxios'; +import { useAuthStateContext } from 'hooks/useAuthStateContext'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; import { ISystemUser } from 'interfaces/useUserApi.interface'; @@ -45,8 +46,10 @@ export interface IActiveUsersListProps { * @return {*} */ const ActiveUsersList: React.FC = (props) => { + const { activeUsers, codes, refresh } = props; + + const authStateContext = useAuthStateContext(); const biohubApi = useBiohubApi(); - const { activeUsers, codes } = props; const history = useHistory(); const [rowsPerPage, setRowsPerPage] = useState(20); @@ -103,7 +106,13 @@ const ActiveUsersList: React.FC = (props) => { open: true }); - props.refresh(); + if (authStateContext.simsUserWrapper.systemUserId === user.system_user_id) { + // User is deleting themselves + authStateContext.simsUserWrapper.refresh(); + } else { + // Refresh users list + refresh(); + } } catch (error) { const apiError = error as APIError; @@ -168,7 +177,13 @@ const ActiveUsersList: React.FC = (props) => { open: true }); - props.refresh(); + if (authStateContext.simsUserWrapper.systemUserId === user.system_user_id) { + // User is changing their own role + authStateContext.simsUserWrapper.refresh(); + } else { + // Refresh users list + refresh(); + } } catch (error) { const apiError = error as APIError; dialogContext.setErrorDialog({ @@ -201,7 +216,8 @@ const ActiveUsersList: React.FC = (props) => { ); } - props.refresh(); + // Refresh users list + refresh(); dialogContext.setSnackbar({ open: true, @@ -358,7 +374,7 @@ const ActiveUsersList: React.FC = (props) => { element: ( { + codes?.system_roles?.map((item) => { return { value: item.id, label: item.name }; }) || [] } diff --git a/app/src/features/admin/users/AddSystemUsersForm.tsx b/app/src/features/admin/users/AddSystemUsersForm.tsx index 69a8c8c527..02fafd2ba8 100644 --- a/app/src/features/admin/users/AddSystemUsersForm.tsx +++ b/app/src/features/admin/users/AddSystemUsersForm.tsx @@ -10,8 +10,8 @@ import InputLabel from '@mui/material/InputLabel'; import MenuItem from '@mui/material/MenuItem'; import Select from '@mui/material/Select'; import CustomTextField from 'components/fields/CustomTextField'; +import { SYSTEM_IDENTITY_SOURCE } from 'constants/auth'; import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; -import { SYSTEM_IDENTITY_SOURCE } from 'hooks/useKeycloakWrapper'; import React from 'react'; import yup from 'utils/YupSchema'; diff --git a/app/src/features/admin/users/ManageUsersPage.test.tsx b/app/src/features/admin/users/ManageUsersPage.test.tsx index 7893a59bbd..3cd33589cc 100644 --- a/app/src/features/admin/users/ManageUsersPage.test.tsx +++ b/app/src/features/admin/users/ManageUsersPage.test.tsx @@ -1,16 +1,22 @@ +import { AuthStateContext } from 'contexts/authStateContext'; import { createMemoryHistory } from 'history'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { Router } from 'react-router'; +import { getMockAuthState, SystemAdminAuthState } from 'test-helpers/auth-helpers'; import { cleanup, render, waitFor } from 'test-helpers/test-utils'; import ManageUsersPage from './ManageUsersPage'; const history = createMemoryHistory(); const renderContainer = () => { + const authState = getMockAuthState({ base: SystemAdminAuthState }); + return render( - - - + + + + + ); }; diff --git a/app/src/features/admin/users/ReviewAccessRequestForm.test.tsx b/app/src/features/admin/users/ReviewAccessRequestForm.test.tsx index cfa191f339..0dde419791 100644 --- a/app/src/features/admin/users/ReviewAccessRequestForm.test.tsx +++ b/app/src/features/admin/users/ReviewAccessRequestForm.test.tsx @@ -1,9 +1,9 @@ +import { SYSTEM_IDENTITY_SOURCE } from 'constants/auth'; import ReviewAccessRequestForm, { ReviewAccessRequestFormInitialValues, ReviewAccessRequestFormYupSchema } from 'features/admin/users/ReviewAccessRequestForm'; import { Formik } from 'formik'; -import { SYSTEM_IDENTITY_SOURCE } from 'hooks/useKeycloakWrapper'; import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; import { codes } from 'test-helpers/code-helpers'; import { render, waitFor } from 'test-helpers/test-utils'; diff --git a/app/src/features/admin/users/ReviewAccessRequestForm.tsx b/app/src/features/admin/users/ReviewAccessRequestForm.tsx index a1c719feb7..51f4f2c7b9 100644 --- a/app/src/features/admin/users/ReviewAccessRequestForm.tsx +++ b/app/src/features/admin/users/ReviewAccessRequestForm.tsx @@ -2,9 +2,9 @@ import Box from '@mui/material/Box'; import Grid from '@mui/material/Grid'; 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 { useFormikContext } from 'formik'; -import { SYSTEM_IDENTITY_SOURCE } from 'hooks/useKeycloakWrapper'; import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; import React from 'react'; import { getFormattedDate, getFormattedIdentitySource } from 'utils/Utils'; diff --git a/app/src/features/funding-sources/list/FundingSourcesTable.tsx b/app/src/features/funding-sources/list/FundingSourcesTable.tsx index a3b2c4ee31..8771d35efc 100644 --- a/app/src/features/funding-sources/list/FundingSourcesTable.tsx +++ b/app/src/features/funding-sources/list/FundingSourcesTable.tsx @@ -1,5 +1,5 @@ import { GridColDef } from '@mui/x-data-grid'; -import { CustomDataGrid } from 'components/tables/CustomDataGrid'; +import { CustomDataGrid } from 'components/data-grid/CustomDataGrid'; import { IGetFundingSourcesResponse } from 'interfaces/useFundingSourceApi.interface'; import { getFormattedAmount } from 'utils/Utils'; import TableActionsMenu from './FundingSourcesTableActionsMenu'; diff --git a/app/src/features/projects/components/ProjectLocationForm.test.tsx b/app/src/features/projects/components/ProjectLocationForm.test.tsx deleted file mode 100644 index fd47164c4c..0000000000 --- a/app/src/features/projects/components/ProjectLocationForm.test.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import MapBoundary from 'components/boundary/MapBoundary'; -import { Formik } from 'formik'; -import { cleanup, render, waitFor } from 'test-helpers/test-utils'; -import ProjectLocationForm, { - IProjectLocationForm, - ProjectLocationFormInitialValues, - ProjectLocationFormYupSchema -} from './ProjectLocationForm'; - -// Mock MapBoundary component -jest.mock('../../../components/boundary/MapBoundary'); -const mockMapBoundary = MapBoundary as jest.Mock; - -describe('ProjectLocationForm', () => { - beforeEach(() => { - mockMapBoundary.mockImplementation(() =>
); - }); - - afterEach(() => { - cleanup(); - }); - - it('renders correctly with default values', async () => { - const { getByLabelText, getByTestId } = render( - - {() => } - - ); - - await waitFor(() => { - // Assert MapBoundary was rendered with the right propsF - expect(MapBoundary).toHaveBeenCalledWith( - { - name: 'location.geometry', - title: 'Define Project Boundary', - mapId: 'project_location_form_map', - bounds: undefined, - formikProps: expect.objectContaining({ values: ProjectLocationFormInitialValues }) - }, - expect.anything() - ); - // Assert description field is visible and populated correctly - expect(getByLabelText('Location Description', { exact: false })).toBeVisible(); - expect(getByTestId('location.location_description')).toHaveValue(''); - }); - }); - - it('renders correctly with non default values', async () => { - const existingFormValues: IProjectLocationForm = { - location: { - location_description: 'this is a location description', - geometry: [ - { - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [125.6, 10.1] - }, - properties: { - name: 'Dinagat Islands' - } - } - ] - } - }; - - const { getByLabelText, getByTestId } = render( - - {() => } - - ); - - await waitFor(() => { - // Assert MapBoundary was rendered with the right props - expect(MapBoundary).toHaveBeenCalledWith( - { - name: 'location.geometry', - title: 'Define Project Boundary', - mapId: 'project_location_form_map', - bounds: undefined, - formikProps: expect.objectContaining({ values: existingFormValues }) - }, - expect.anything() - ); - // Assert description field is visible and populated correctly - expect(getByLabelText('Location Description', { exact: false })).toBeVisible(); - expect(getByTestId('location.location_description')).toHaveValue('this is a location description'); - }); - }); -}); diff --git a/app/src/features/projects/components/ProjectLocationForm.tsx b/app/src/features/projects/components/ProjectLocationForm.tsx deleted file mode 100644 index 5bbb3eaeae..0000000000 --- a/app/src/features/projects/components/ProjectLocationForm.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import MapBoundary from 'components/boundary/MapBoundary'; -import CustomTextField from 'components/fields/CustomTextField'; -import { useFormikContext } from 'formik'; -import { Feature } from 'geojson'; -import yup from 'utils/YupSchema'; - -export interface IProjectLocationForm { - location: { - location_description: string; - geometry: Feature[]; - }; -} - -export const ProjectLocationFormInitialValues: IProjectLocationForm = { - location: { - location_description: '', - geometry: [] - } -}; - -export const ProjectLocationFormYupSchema = yup.object().shape({ - location: yup.object().shape({ - location_description: yup.string().max(3000, 'Cannot exceed 3000 characters'), - geometry: yup.array().min(1, 'A project boundary is required').required('A project boundary is required') - }) -}); - -/** - * Create project - Location section - * - * @return {*} - */ -const ProjectLocationForm = () => { - const formikProps = useFormikContext(); - - const { handleSubmit } = formikProps; - - return ( -
- - - - Describe the location of this project - - - - - ); -}; - -export default ProjectLocationForm; diff --git a/app/src/features/projects/create/CreateProjectForm.tsx b/app/src/features/projects/create/CreateProjectForm.tsx index ee0696c07a..72a24400a8 100644 --- a/app/src/features/projects/create/CreateProjectForm.tsx +++ b/app/src/features/projects/create/CreateProjectForm.tsx @@ -5,18 +5,17 @@ import { makeStyles } from '@mui/styles'; import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; import { ScrollToFormikError } from 'components/formik/ScrollToFormikError'; import { PROJECT_ROLE } from 'constants/roles'; -import { AuthStateContext } from 'contexts/authStateContext'; import { Formik, FormikProps } from 'formik'; +import { useAuthStateContext } from 'hooks/useAuthStateContext'; import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; import { ICreateProjectRequest, IGetProjectParticipant } from 'interfaces/useProjectApi.interface'; -import React, { useContext } from 'react'; +import React from 'react'; import { alphabetizeObjects } from 'utils/Utils'; import ProjectDetailsForm, { ProjectDetailsFormInitialValues, ProjectDetailsFormYupSchema } from '../components/ProjectDetailsForm'; import { ProjectIUCNFormInitialValues } from '../components/ProjectIUCNForm'; -import { ProjectLocationFormInitialValues } from '../components/ProjectLocationForm'; import ProjectObjectivesForm, { ProjectObjectivesFormInitialValues, ProjectObjectivesFormYupSchema @@ -44,15 +43,12 @@ export interface ICreateProjectForm { export const initialProjectFieldData: ICreateProjectRequest = { ...ProjectDetailsFormInitialValues, ...ProjectObjectivesFormInitialValues, - ...ProjectLocationFormInitialValues, ...ProjectIUCNFormInitialValues, ...ProjectUserRoleFormInitialValues }; export const validationProjectYupSchema = ProjectDetailsFormYupSchema.concat(ProjectObjectivesFormYupSchema).concat(ProjectUserRoleYupSchema); -// TODO: (https://apps.nrs.gov.bc.ca/int/jira/browse/SIMSBIOHUB-161) Commenting out location form (yup schema) temporarily, while its decided where exactly project/survey locations should be defined -// .concat(ProjectLocationFormYupSchema) // TODO: (https://apps.nrs.gov.bc.ca/int/jira/browse/SIMSBIOHUB-162) Commenting out IUCN form (yup schema) temporarily, while its decided if IUCN information is desired // .concat(ProjectIUCNFormYupSchema) @@ -76,7 +72,7 @@ const CreateProjectForm: React.FC = (props) => { props.handleSubmit(formikData); }; - const { keycloakWrapper } = useContext(AuthStateContext); + const authStateContext = useAuthStateContext(); const getProjectParticipants = (): IGetProjectParticipant[] => { let participants: IGetProjectParticipant[] = []; @@ -86,14 +82,13 @@ const CreateProjectForm: React.FC = (props) => { participants = props.initialValues?.participants as IGetProjectParticipant[]; } else { // this is a fresh form and the logged in user needs to be added as a participant - const loggedInUser = keycloakWrapper?.user; participants = [ { - system_user_id: loggedInUser?.system_user_id, - display_name: loggedInUser?.display_name, - email: loggedInUser?.email, - agency: loggedInUser?.agency, - identity_source: loggedInUser?.identity_source, + system_user_id: authStateContext.simsUserWrapper?.systemUserId, + display_name: authStateContext.simsUserWrapper?.displayName, + email: authStateContext.simsUserWrapper?.email, + agency: authStateContext.simsUserWrapper?.agency, + identity_source: authStateContext.simsUserWrapper?.identitySource, project_role_names: [PROJECT_ROLE.COORDINATOR] } as IGetProjectParticipant ]; @@ -168,15 +163,6 @@ const CreateProjectForm: React.FC = (props) => { summary="Specify team members and their associated role for this project." component={} /> - - {/* TODO: (https://apps.nrs.gov.bc.ca/int/jira/browse/SIMSBIOHUB-161) Commenting out location form temporarily, while its decided where exactly project/survey locations should be defined */} - {/* - - }> */} - diff --git a/app/src/features/projects/create/CreateProjectPage.test.tsx b/app/src/features/projects/create/CreateProjectPage.test.tsx index 0fde631a9f..237a40afa3 100644 --- a/app/src/features/projects/create/CreateProjectPage.test.tsx +++ b/app/src/features/projects/create/CreateProjectPage.test.tsx @@ -1,8 +1,8 @@ +import { AuthStateContext } from 'contexts/authStateContext'; import { CodesContext, ICodesContext } from 'contexts/codesContext'; import { DialogContextProvider } from 'contexts/dialogContext'; import { ProjectDetailsFormInitialValues } from 'features/projects/components/ProjectDetailsForm'; import { ProjectIUCNFormInitialValues } from 'features/projects/components/ProjectIUCNForm'; -import { ProjectLocationFormInitialValues } from 'features/projects/components/ProjectLocationForm'; import { ProjectObjectivesFormInitialValues } from 'features/projects/components/ProjectObjectivesForm'; import CreateProjectPage from 'features/projects/create/CreateProjectPage'; import { createMemoryHistory } from 'history'; @@ -14,6 +14,7 @@ import { IDraftResponse } from 'interfaces/useDraftApi.interface'; import { ICreateProjectResponse } from 'interfaces/useProjectApi.interface'; import { ISystemUser } from 'interfaces/useUserApi.interface'; import { MemoryRouter, Router } from 'react-router'; +import { getMockAuthState, SystemAdminAuthState } from 'test-helpers/auth-helpers'; import { codes } from 'test-helpers/code-helpers'; import { cleanup, @@ -58,15 +59,19 @@ const mockCodesContext: ICodesContext = { } as DataLoader }; +const authState = getMockAuthState({ base: SystemAdminAuthState }); + const renderContainer = () => { return render( - - - - , - - - + + + + + , + + + + ); }; @@ -101,9 +106,6 @@ describe('CreateProjectPage', () => { expect(getByText('Create New Project')).toBeVisible(); expect(getByText('General Information')).toBeVisible(); - - // TODO: (https://apps.nrs.gov.bc.ca/int/jira/browse/SIMSBIOHUB-161) Commenting out location form temporarily, while its decided where exactly project/survey locations should be defined - // expect(getByText('Location and Boundary')).toBeVisible(); }); }); @@ -178,17 +180,18 @@ describe('CreateProjectPage', () => { data: { project: ProjectDetailsFormInitialValues.project, objectives: ProjectObjectivesFormInitialValues.objectives, - location: ProjectLocationFormInitialValues.location, iucn: ProjectIUCNFormInitialValues.iucn } }); const { queryAllByText } = render( - - - - - + + + + + + + ); await waitFor(() => { @@ -203,17 +206,18 @@ describe('CreateProjectPage', () => { data: { project: ProjectDetailsFormInitialValues.project, objectives: ProjectObjectivesFormInitialValues.objectives, - location: ProjectLocationFormInitialValues.location, iucn: ProjectIUCNFormInitialValues.iucn } }); const { getByText, findAllByText } = render( - - - - - + + + + + + + ); const deleteButton = await findAllByText('Delete Draft', { exact: false }); @@ -236,17 +240,18 @@ describe('CreateProjectPage', () => { data: { project: ProjectDetailsFormInitialValues.project, objectives: ProjectObjectivesFormInitialValues.objectives, - location: ProjectLocationFormInitialValues.location, iucn: ProjectIUCNFormInitialValues.iucn } }); const { getByText, findAllByText, getByTestId, queryByText } = render( - - - - - + + + + + + + ); const deleteButton = await findAllByText('Delete Draft', { exact: false }); @@ -276,17 +281,18 @@ describe('CreateProjectPage', () => { data: { project: ProjectDetailsFormInitialValues.project, objectives: ProjectObjectivesFormInitialValues.objectives, - location: ProjectLocationFormInitialValues.location, iucn: ProjectIUCNFormInitialValues.iucn } }); const { getByText, findAllByText, getByTestId } = render( - - - - - + + + + + + + ); const deleteButton = await findAllByText('Delete Draft', { exact: false }); @@ -318,17 +324,18 @@ describe('CreateProjectPage', () => { project_name: 'Test name' }, objectives: ProjectObjectivesFormInitialValues.objectives, - location: ProjectLocationFormInitialValues.location, iucn: ProjectIUCNFormInitialValues.iucn } }); const { getByDisplayValue } = render( - - - - - + + + + + + + ); await waitFor(() => { @@ -406,7 +413,6 @@ describe('CreateProjectPage', () => { data: { project: ProjectDetailsFormInitialValues.project, objectives: ProjectObjectivesFormInitialValues.objectives, - location: ProjectLocationFormInitialValues.location, iucn: ProjectIUCNFormInitialValues.iucn, participants: AddProjectParticipantsFormInitialValues.participants } @@ -485,11 +491,15 @@ describe('CreateProjectPage', () => { end_date: '' }, objectives: { objectives: 'Draft objectives' }, - location: { location_description: '', geometry: [] }, iucn: { classificationDetails: [] }, participants: [ { - project_role_names: ['Coordinator'] + agency: 'agency', + display_name: 'admin-displayname', + email: 'admin@email.com', + identity_source: 'IDIR', + project_role_names: ['Coordinator'], + system_user_id: 1 } ] }); @@ -507,7 +517,6 @@ describe('CreateProjectPage', () => { data: { project: ProjectDetailsFormInitialValues.project, objectives: ProjectObjectivesFormInitialValues.objectives, - location: ProjectLocationFormInitialValues.location, iucn: ProjectIUCNFormInitialValues.iucn, participants: AddProjectParticipantsFormInitialValues.participants } @@ -550,7 +559,6 @@ describe('CreateProjectPage', () => { end_date: '' }, objectives: { objectives: 'my new Draft objectives' }, - location: { location_description: '', geometry: [] }, iucn: { classificationDetails: [] }, participants: [ { diff --git a/app/src/features/projects/edit/EditProjectForm.tsx b/app/src/features/projects/edit/EditProjectForm.tsx index 2a3dbc1700..f4d154516d 100644 --- a/app/src/features/projects/edit/EditProjectForm.tsx +++ b/app/src/features/projects/edit/EditProjectForm.tsx @@ -118,14 +118,6 @@ const EditProjectForm: React.FC = (props) => { component={} /> - {/* TODO: (https://apps.nrs.gov.bc.ca/int/jira/browse/SIMSBIOHUB-161) Commenting out location form temporarily, while its decided where exactly project/survey locations should be defined */} - {/* - - }> */} - diff --git a/app/src/features/projects/edit/EditProjectPage.tsx b/app/src/features/projects/edit/EditProjectPage.tsx index 9e3c10054b..97265029ae 100644 --- a/app/src/features/projects/edit/EditProjectPage.tsx +++ b/app/src/features/projects/edit/EditProjectPage.tsx @@ -73,7 +73,6 @@ const EditProjectPage: React.FC = (props) => { biohubApi.project.getProjectForUpdate(projectId, [ UPDATE_GET_ENTITIES.project, UPDATE_GET_ENTITIES.objectives, - UPDATE_GET_ENTITIES.location, UPDATE_GET_ENTITIES.iucn, UPDATE_GET_ENTITIES.participants ]) diff --git a/app/src/features/projects/list/ProjectsListPage.test.tsx b/app/src/features/projects/list/ProjectsListPage.test.tsx index 27e91bba00..6f99531e3d 100644 --- a/app/src/features/projects/list/ProjectsListPage.test.tsx +++ b/app/src/features/projects/list/ProjectsListPage.test.tsx @@ -72,6 +72,8 @@ describe('ProjectsListPage', () => { mockUseApi.project.getProjectsList.mockResolvedValue([]); mockUseApi.draft.getDraftsList.mockResolvedValue([]); + const authState = getMockAuthState({ base: SystemAdminAuthState }); + const mockCodesContext: ICodesContext = { codesDataLoader: { data: [], @@ -83,11 +85,13 @@ describe('ProjectsListPage', () => { } as unknown as ICodesContext; const { getByText } = render( - - - - - + + + + + + + ); await waitFor(() => { @@ -104,6 +108,8 @@ describe('ProjectsListPage', () => { ]); mockUseApi.project.getProjectsList.mockResolvedValue([]); + const authState = getMockAuthState({ base: SystemAdminAuthState }); + const mockCodesContext: ICodesContext = { codesDataLoader: { data: [], @@ -115,11 +121,13 @@ describe('ProjectsListPage', () => { } as unknown as ICodesContext; const { findByText } = render( - - - - - + + + + + + + ); expect(await findByText('Draft 1')).toBeInTheDocument(); @@ -144,6 +152,8 @@ describe('ProjectsListPage', () => { mockUseApi.draft.getDraftsList.mockResolvedValue([]); + const authState = getMockAuthState({ base: SystemAdminAuthState }); + const mockCodesContext: ICodesContext = { codesDataLoader: { data: codes, @@ -155,11 +165,13 @@ describe('ProjectsListPage', () => { } as unknown as ICodesContext; const { findByText } = render( - - - - - + + + + + + + ); fireEvent.click(await findByText('Project 1')); diff --git a/app/src/features/projects/participants/AddProjectParticipantsForm.tsx b/app/src/features/projects/participants/AddProjectParticipantsForm.tsx index 957afeb6e5..bbd80167e7 100644 --- a/app/src/features/projects/participants/AddProjectParticipantsForm.tsx +++ b/app/src/features/projects/participants/AddProjectParticipantsForm.tsx @@ -10,8 +10,8 @@ import InputLabel from '@mui/material/InputLabel'; import MenuItem from '@mui/material/MenuItem'; import Select from '@mui/material/Select'; import CustomTextField from 'components/fields/CustomTextField'; +import { SYSTEM_IDENTITY_SOURCE } from 'constants/auth'; import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; -import { SYSTEM_IDENTITY_SOURCE } from 'hooks/useKeycloakWrapper'; import React from 'react'; import yup from 'utils/YupSchema'; diff --git a/app/src/features/projects/participants/ProjectParticipantsPage.tsx b/app/src/features/projects/participants/ProjectParticipantsPage.tsx index 47162d755d..ee788932a4 100644 --- a/app/src/features/projects/participants/ProjectParticipantsPage.tsx +++ b/app/src/features/projects/participants/ProjectParticipantsPage.tsx @@ -15,6 +15,7 @@ import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import { makeStyles } from '@mui/styles'; import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; +import { SYSTEM_IDENTITY_SOURCE } from 'constants/auth'; import { ProjectParticipantsI18N } from 'constants/i18n'; import { CodesContext } from 'contexts/codesContext'; import { DialogContext } from 'contexts/dialogContext'; @@ -23,7 +24,6 @@ import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader from 'hooks/useDataLoader'; import useDataLoaderError from 'hooks/useDataLoaderError'; -import { SYSTEM_IDENTITY_SOURCE } from 'hooks/useKeycloakWrapper'; import { IGetProjectParticipant } from 'interfaces/useProjectApi.interface'; import React, { useCallback, useContext, useEffect } from 'react'; import { alphabetizeObjects, getFormattedIdentitySource } from 'utils/Utils'; diff --git a/app/src/features/projects/view/ProjectAttachments.test.tsx b/app/src/features/projects/view/ProjectAttachments.test.tsx index 825cc58b0c..b42564af22 100644 --- a/app/src/features/projects/view/ProjectAttachments.test.tsx +++ b/app/src/features/projects/view/ProjectAttachments.test.tsx @@ -55,6 +55,7 @@ describe('ProjectAttachments', () => { } as unknown as DataLoader } as unknown as IProjectContext; + const authState = getMockAuthState({ base: SystemAdminAuthState }); const mockProjectAuthStateContext: IProjectAuthStateContext = { getProjectParticipant: () => null, hasProjectRole: () => true, @@ -65,13 +66,15 @@ describe('ProjectAttachments', () => { }; const { getByText, queryByText } = render( - - - - - - - + + + + + + + + + ); await waitFor(() => { @@ -103,6 +106,7 @@ describe('ProjectAttachments', () => { } as unknown as DataLoader } as unknown as IProjectContext; + const authState = getMockAuthState({ base: SystemAdminAuthState }); const mockProjectAuthStateContext: IProjectAuthStateContext = { getProjectParticipant: () => null, hasProjectRole: () => true, @@ -113,13 +117,15 @@ describe('ProjectAttachments', () => { }; const { getByText } = render( - - - - - - - + + + + + + + + + ); await waitFor(() => { expect(getByText('No Documents')).toBeInTheDocument(); @@ -148,6 +154,7 @@ describe('ProjectAttachments', () => { } as unknown as DataLoader } as unknown as IProjectContext; + const authState = getMockAuthState({ base: SystemAdminAuthState }); const mockProjectAuthStateContext: IProjectAuthStateContext = { getProjectParticipant: () => null, hasProjectRole: () => true, @@ -158,13 +165,15 @@ describe('ProjectAttachments', () => { }; const { getByText } = render( - - - - - - - + + + + + + + + + ); await waitFor(() => { @@ -204,6 +213,7 @@ describe('ProjectAttachments', () => { } as unknown as DataLoader } as unknown as IProjectContext; + const authState = getMockAuthState({ base: SystemAdminAuthState }); const mockProjectAuthStateContext: IProjectAuthStateContext = { getProjectParticipant: () => null, hasProjectRole: () => true, @@ -213,11 +223,9 @@ describe('ProjectAttachments', () => { hasLoadedParticipantInfo: true }; - const authState = getMockAuthState({ base: SystemAdminAuthState }); - const { baseElement, queryByText, getByTestId, getAllByTestId, queryByTestId } = render( - - + + @@ -225,8 +233,8 @@ describe('ProjectAttachments', () => { - - + + ); await waitFor(() => { @@ -277,6 +285,7 @@ describe('ProjectAttachments', () => { } as unknown as DataLoader } as unknown as IProjectContext; + const authState = getMockAuthState({ base: SystemAdminAuthState }); const mockProjectAuthStateContext: IProjectAuthStateContext = { getProjectParticipant: () => null, hasProjectRole: () => true, @@ -286,11 +295,9 @@ describe('ProjectAttachments', () => { hasLoadedParticipantInfo: true }; - const authState = getMockAuthState({ base: SystemAdminAuthState }); - const { baseElement, queryByText, getByTestId, queryByTestId, getAllByTestId } = render( - - + + @@ -298,8 +305,8 @@ describe('ProjectAttachments', () => { - - + + ); await waitFor(() => { @@ -352,6 +359,7 @@ describe('ProjectAttachments', () => { } as unknown as DataLoader } as unknown as IProjectContext; + const authState = getMockAuthState({ base: SystemAdminAuthState }); const mockProjectAuthStateContext: IProjectAuthStateContext = { getProjectParticipant: () => null, hasProjectRole: () => true, @@ -361,11 +369,9 @@ describe('ProjectAttachments', () => { hasLoadedParticipantInfo: true }; - const authState = getMockAuthState({ base: SystemAdminAuthState }); - const { baseElement, queryByText, getAllByRole, queryByTestId, getAllByTestId } = render( - - + + @@ -373,8 +379,8 @@ describe('ProjectAttachments', () => { - - + + ); await waitFor(() => { diff --git a/app/src/features/projects/view/ProjectPage.test.tsx b/app/src/features/projects/view/ProjectPage.test.tsx index 3ecfa5bb91..1df77361b7 100644 --- a/app/src/features/projects/view/ProjectPage.test.tsx +++ b/app/src/features/projects/view/ProjectPage.test.tsx @@ -23,7 +23,7 @@ const mockUseApi = { publishProject: jest.fn() }, survey: { - getSurveysList: jest.fn().mockResolvedValue([]) + getSurveysBasicFieldsByProjectId: jest.fn().mockResolvedValue([]) }, codes: { getAllCodeSets: jest.fn, []>() @@ -38,7 +38,7 @@ describe.skip('ProjectPage', () => { mockBiohubApi.mockImplementation(() => mockUseApi); mockUseApi.project.deleteProject.mockClear(); mockUseApi.project.getProjectForView.mockClear(); - mockUseApi.survey.getSurveysList.mockClear(); + mockUseApi.survey.getSurveysBasicFieldsByProjectId.mockClear(); mockUseApi.codes.getAllCodeSets.mockClear(); mockUseApi.project.publishProject.mockClear(); mockUseApi.spatial.getRegions.mockClear(); diff --git a/app/src/features/projects/view/ProjectPage.tsx b/app/src/features/projects/view/ProjectPage.tsx index 8b02617068..3c00f795c5 100644 --- a/app/src/features/projects/view/ProjectPage.tsx +++ b/app/src/features/projects/view/ProjectPage.tsx @@ -66,12 +66,6 @@ const ProjectPage = () => { - {/* TODO: (https://apps.nrs.gov.bc.ca/int/jira/browse/SIMSBIOHUB-161) Commenting out location form (map) temporarily, while its decided where exactly project/survey locations should be defined */} - {/* - - - - */} diff --git a/app/src/features/projects/view/components/GeneralInformation.tsx b/app/src/features/projects/view/components/GeneralInformation.tsx index 3bf525033a..beb7482634 100644 --- a/app/src/features/projects/view/components/GeneralInformation.tsx +++ b/app/src/features/projects/view/components/GeneralInformation.tsx @@ -33,7 +33,7 @@ const GeneralInformation = () => { return ( - + Program @@ -55,7 +55,7 @@ const GeneralInformation = () => { ) : ( <> - Start Date:{' '} + Start Date: {getFormattedDateRangeString(DATE_FORMAT.ShortMediumDateFormat, projectData.project.start_date)} )} diff --git a/app/src/features/projects/view/components/LocationBoundary.test.tsx b/app/src/features/projects/view/components/LocationBoundary.test.tsx deleted file mode 100644 index 3f5be76358..0000000000 --- a/app/src/features/projects/view/components/LocationBoundary.test.tsx +++ /dev/null @@ -1,391 +0,0 @@ -import { CodesContext, ICodesContext } from 'contexts/codesContext'; -import { DialogContextProvider } from 'contexts/dialogContext'; -import { IProjectContext, ProjectContext } from 'contexts/projectContext'; -import { GetRegionsResponse } from 'hooks/api/useSpatialApi'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { DataLoader } from 'hooks/useDataLoader'; -import { UPDATE_GET_ENTITIES } from 'interfaces/useProjectApi.interface'; -import { codes } from 'test-helpers/code-helpers'; -import { getProjectForViewResponse } from 'test-helpers/project-helpers'; -import { geoJsonFeature } from 'test-helpers/spatial-helpers'; -import { getSurveyForListResponse } from 'test-helpers/survey-helpers'; -import { cleanup, fireEvent, render, waitFor } from 'test-helpers/test-utils'; -import LocationBoundary from './LocationBoundary'; - -jest.mock('../../../../hooks/useBioHubApi'); -const mockBiohubApi = useBiohubApi as jest.Mock; - -const mockUseApi = { - project: { - getProjectForUpdate: jest.fn, []>(), - updateProject: jest.fn() - }, - external: { - get: jest.fn() - }, - spatial: { - getRegions: jest.fn, []>() - } -}; - -const mockRefresh = jest.fn(); - -describe.skip('LocationBoundary', () => { - beforeEach(() => { - mockBiohubApi.mockImplementation(() => mockUseApi); - mockUseApi.project.getProjectForUpdate.mockClear(); - mockUseApi.project.updateProject.mockClear(); - mockUseApi.external.get.mockClear(); - mockUseApi.spatial.getRegions.mockClear(); - - mockUseApi.external.get.mockResolvedValue({ - features: [] - }); - mockUseApi.spatial.getRegions.mockResolvedValue({ - regions: [] - }); - - jest.spyOn(console, 'debug').mockImplementation(() => {}); - }); - - afterEach(() => { - cleanup(); - }); - - it.skip('matches the snapshot when there is no location description', async () => { - const mockCodesContext: ICodesContext = { - codesDataLoader: { - data: codes - } as DataLoader - }; - const mockProjectContext: IProjectContext = { - projectDataLoader: { - data: { - ...getProjectForViewResponse.projectData, - location: { - ...getProjectForViewResponse.projectData.location, - location_description: null as unknown as string - } - } - } as DataLoader, - artifactDataLoader: { data: null } as DataLoader, - surveysListDataLoader: { data: getSurveyForListResponse } as DataLoader, - projectId: 1 - }; - - const { asFragment } = render( - - - - - - ); - - await waitFor(() => { - expect(asFragment()).toMatchSnapshot(); - }); - }); - - it.skip('matches the snapshot when there is no geometry', async () => { - const mockCodesContext: ICodesContext = { - codesDataLoader: { - data: codes - } as DataLoader - }; - const mockProjectContext: IProjectContext = { - projectDataLoader: { - data: { - ...getProjectForViewResponse, - projectData: { - ...getProjectForViewResponse.projectData, - location: { ...getProjectForViewResponse.projectData.location, geometry: [] } - } - } - } as DataLoader, - surveysListDataLoader: { data: getSurveyForListResponse } as DataLoader, - artifactDataLoader: { data: null } as DataLoader, - projectId: 1 - }; - - const { asFragment } = render( - - - - - - ); - - await waitFor(() => { - expect(asFragment()).toMatchSnapshot(); - }); - }); - - it.skip('matches the snapshot when the geometry is a single polygon in valid GeoJSON format', async () => { - const mockCodesContext: ICodesContext = { - codesDataLoader: { - data: codes - } as DataLoader - }; - const mockProjectContext: IProjectContext = { - projectDataLoader: { - data: { - ...getProjectForViewResponse, - projectData: { - ...getProjectForViewResponse.projectData, - location: { ...getProjectForViewResponse.projectData.location, geometry: geoJsonFeature } - } - } - } as DataLoader, - artifactDataLoader: { data: null } as DataLoader, - surveysListDataLoader: { data: getSurveyForListResponse } as DataLoader, - projectId: 1 - }; - - const { asFragment } = render( - - - - - - ); - - await waitFor(() => { - expect(asFragment()).toMatchSnapshot(); - }); - }); - - it('editing the location boundary works in the dialog', async () => { - const mockCodesContext: ICodesContext = { - codesDataLoader: { - data: codes - } as DataLoader - }; - const mockProjectContext: IProjectContext = { - projectDataLoader: { - data: getProjectForViewResponse - } as DataLoader, - artifactDataLoader: { data: null } as DataLoader, - surveysListDataLoader: { data: getSurveyForListResponse } as DataLoader, - projectId: 1 - }; - - mockUseApi.project.getProjectForUpdate.mockResolvedValue({ - location: { - location_description: 'description', - geometry: geoJsonFeature, - revision_count: 1 - } - }); - - const { getByText, queryByText } = render( - - - - - - ); - - await waitFor(() => { - expect(getByText('Project Location')).toBeVisible(); - }); - - fireEvent.click(getByText('Edit')); - - await waitFor(() => { - expect(mockUseApi.project.getProjectForUpdate).toBeCalledWith( - getProjectForViewResponse.projectData.project.project_id, - [UPDATE_GET_ENTITIES.location] - ); - }); - - await waitFor(() => { - expect(getByText('Edit Project Location')).toBeVisible(); - }); - - fireEvent.click(getByText('Cancel')); - - await waitFor(() => { - expect(queryByText('Edit Project Location')).not.toBeInTheDocument(); - }); - - fireEvent.click(getByText('Edit')); - - await waitFor(() => { - expect(getByText('Edit Project Location')).toBeVisible(); - }); - - fireEvent.click(getByText('Save Changes')); - - await waitFor(() => { - expect(mockUseApi.project.updateProject).toHaveBeenCalledTimes(1); - expect(mockUseApi.project.updateProject).toBeCalledWith( - getProjectForViewResponse.projectData.project.project_id, - { - location: { - location_description: 'description', - geometry: geoJsonFeature, - revision_count: 1 - } - } - ); - - expect(mockRefresh).toBeCalledTimes(1); - }); - }); - - it('displays an error dialog when fetching the update data fails', async () => { - const mockCodesContext: ICodesContext = { - codesDataLoader: { - data: codes - } as DataLoader - }; - const mockProjectContext: IProjectContext = { - projectDataLoader: { - data: getProjectForViewResponse - } as DataLoader, - artifactDataLoader: { data: null } as DataLoader, - surveysListDataLoader: { data: getSurveyForListResponse } as DataLoader, - projectId: 1 - }; - - mockUseApi.project.getProjectForUpdate.mockResolvedValue({ - location: null - }); - - const { getByText, queryByText } = render( - - - - - - - - ); - - await waitFor(() => { - expect(getByText('Project Location')).toBeVisible(); - }); - - fireEvent.click(getByText('Edit')); - - await waitFor(() => { - expect(getByText('Error Editing Project Location')).toBeVisible(); - }); - - fireEvent.click(getByText('Ok')); - - await waitFor(() => { - expect(queryByText('Error Editing Project Location')).not.toBeInTheDocument(); - }); - }); - - it('shows error dialog with API error message when getting location data for update fails', async () => { - const mockCodesContext: ICodesContext = { - codesDataLoader: { - data: codes - } as DataLoader - }; - const mockProjectContext: IProjectContext = { - projectDataLoader: { - data: getProjectForViewResponse - } as DataLoader, - artifactDataLoader: { data: null } as DataLoader, - surveysListDataLoader: { data: getSurveyForListResponse } as DataLoader, - projectId: 1 - }; - - mockUseApi.project.getProjectForUpdate = jest.fn(() => Promise.reject(new Error('API Error is Here'))); - - const { getByText, queryByText } = render( - - - - - - - - ); - - await waitFor(() => { - expect(getByText('Project Location')).toBeVisible(); - }); - - fireEvent.click(getByText('Edit')); - - await waitFor(() => { - expect(queryByText('API Error is Here')).toBeInTheDocument(); - }); - - fireEvent.click(getByText('Ok')); - - await waitFor(() => { - expect(queryByText('API Error is Here')).toBeNull(); - }); - }); - - it('shows error dialog with API error message when updating location data fails', async () => { - const mockCodesContext: ICodesContext = { - codesDataLoader: { - data: codes - } as DataLoader - }; - const mockProjectContext: IProjectContext = { - projectDataLoader: { - data: getProjectForViewResponse - } as DataLoader, - artifactDataLoader: { data: null } as DataLoader, - surveysListDataLoader: { data: getSurveyForListResponse } as DataLoader, - projectId: 1 - }; - - mockUseApi.project.getProjectForUpdate.mockResolvedValue({ - location: { - location_description: 'description', - geometry: geoJsonFeature, - revision_count: 1 - } - }); - mockUseApi.project.updateProject = jest.fn(() => Promise.reject(new Error('API Error is Here'))); - - const { getByText, queryByText, getAllByRole } = render( - - - - - - - - ); - - await waitFor(() => { - expect(getByText('Project Location')).toBeVisible(); - }); - - fireEvent.click(getByText('Edit')); - - await waitFor(() => { - expect(mockUseApi.project.getProjectForUpdate).toBeCalledWith( - getProjectForViewResponse.projectData.project.project_id, - [UPDATE_GET_ENTITIES.location] - ); - }); - - await waitFor(() => { - expect(getByText('Edit Project Location')).toBeVisible(); - }); - - fireEvent.click(getByText('Save Changes')); - - await waitFor(() => { - expect(queryByText('API Error is Here')).toBeInTheDocument(); - }); - - // Get the backdrop, then get the firstChild because this is where the event listener is attached - //@ts-ignore - fireEvent.click(getAllByRole('presentation')[0].firstChild); - - await waitFor(() => { - expect(queryByText('API Error is Here')).toBeNull(); - }); - }); -}); diff --git a/app/src/features/projects/view/components/LocationBoundary.tsx b/app/src/features/projects/view/components/LocationBoundary.tsx deleted file mode 100644 index 363c1ca9c7..0000000000 --- a/app/src/features/projects/view/components/LocationBoundary.tsx +++ /dev/null @@ -1,291 +0,0 @@ -import { mdiChevronRight, mdiPencilOutline, mdiRefresh } from '@mdi/js'; -import Icon from '@mdi/react'; -import { Theme } from '@mui/material'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import { grey } from '@mui/material/colors'; -import IconButton from '@mui/material/IconButton'; -import { makeStyles } from '@mui/styles'; -import assert from 'assert'; -import FullScreenViewMapDialog from 'components/boundary/FullScreenViewMapDialog'; -import InferredLocationDetails, { IInferredLayers } from 'components/boundary/InferredLocationDetails'; -import EditDialog from 'components/dialog/EditDialog'; -import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; -import MapContainer from 'components/map/MapContainer'; -import { ProjectRoleGuard } from 'components/security/Guards'; -import { H2ButtonToolbar } from 'components/toolbar/ActionToolbars'; -import { EditLocationBoundaryI18N } from 'constants/i18n'; -import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; -import { DialogContext } from 'contexts/dialogContext'; -import { ProjectContext } from 'contexts/projectContext'; -import ProjectLocationForm, { - IProjectLocationForm, - ProjectLocationFormInitialValues, - ProjectLocationFormYupSchema -} from 'features/projects/components/ProjectLocationForm'; -import { Feature } from 'geojson'; -import { APIError } from 'hooks/api/useAxios'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { IGetProjectForUpdateResponseLocation, UPDATE_GET_ENTITIES } from 'interfaces/useProjectApi.interface'; -import { LatLngBoundsExpression } from 'leaflet'; -import React, { useCallback, useContext, useEffect, useState } from 'react'; -import { calculateUpdatedMapBounds } from 'utils/mapBoundaryUploadHelpers'; - -const useStyles = makeStyles((theme: Theme) => ({ - zoomToBoundaryExtentBtn: { - padding: '3px', - borderRadius: '4px', - background: '#ffffff', - color: '#000000', - border: '2px solid rgba(0,0,0,0.2)', - backgroundClip: 'padding-box', - '&:hover': { - backgroundColor: '#eeeeee' - } - }, - metaSectionHeader: { - color: grey[600], - fontWeight: 700, - textTransform: 'uppercase', - letterSpacing: '0.02rem', - '& + hr': { - marginTop: theme.spacing(0.75), - marginBottom: theme.spacing(0.75) - } - } -})); - -/** - * View project - Location section - * - * @return {*} - */ -const LocationBoundary = () => { - const classes = useStyles(); - - const biohubApi = useBiohubApi(); - - const projectContext = useContext(ProjectContext); - - // Project data must be loaded by a parent before this component is rendered - assert(projectContext.projectDataLoader.data); - - const projectData = projectContext.projectDataLoader.data.projectData; - - const dialogContext = useContext(DialogContext); - - const showErrorDialog = (textDialogProps?: Partial) => { - dialogContext.setErrorDialog({ - dialogTitle: EditLocationBoundaryI18N.editErrorTitle, - dialogText: EditLocationBoundaryI18N.editErrorText, - open: true, - onClose: () => { - dialogContext.setErrorDialog({ open: false }); - }, - onOk: () => { - dialogContext.setErrorDialog({ open: false }); - }, - ...textDialogProps - }); - }; - - const [openEditDialog, setOpenEditDialog] = useState(false); - const [locationDataForUpdate, setLocationDataForUpdate] = useState(null as any); - const [locationFormData, setLocationFormData] = useState(ProjectLocationFormInitialValues); - const [inferredLayersInfo, setInferredLayersInfo] = useState({ - parks: [], - nrm: [], - env: [], - wmu: [] - }); - const [bounds, setBounds] = useState(undefined); - const [nonEditableGeometries, setNonEditableGeometries] = useState([]); - const [showFullScreenViewMapDialog, setShowFullScreenViewMapDialog] = useState(false); - - const handleDialogEditOpen = async () => { - let locationResponseData; - - try { - const response = await biohubApi.project.getProjectForUpdate(projectContext.projectId, [ - UPDATE_GET_ENTITIES.location - ]); - - if (!response?.location) { - showErrorDialog(); - return; - } - - locationResponseData = response.location; - } catch (error) { - const apiError = error as APIError; - showErrorDialog({ dialogText: apiError.message }); - return; - } - - setLocationDataForUpdate(locationResponseData); - - setLocationFormData({ - location: { - location_description: locationResponseData.location_description, - geometry: locationResponseData.geometry - } - }); - - setOpenEditDialog(true); - }; - - const handleDialogEditSave = async (values: IProjectLocationForm) => { - const projectLocationData = { - ...values.location, - revision_count: locationDataForUpdate.revision_count - }; - - const projectData = { location: projectLocationData }; - - try { - await biohubApi.project.updateProject(projectContext.projectId, projectData); - } catch (error) { - const apiError = error as APIError; - showErrorDialog({ dialogText: apiError.message }); - return; - } finally { - setOpenEditDialog(false); - } - - projectContext.projectDataLoader.refresh(projectContext.projectId); - }; - - const zoomToBoundaryExtent = useCallback(() => { - setBounds(calculateUpdatedMapBounds(projectData.location.geometry)); - }, [projectData.location.geometry]); - - useEffect(() => { - const nonEditableGeometriesResult = projectData.location.geometry.map((geom: Feature) => { - return { feature: geom }; - }); - - zoomToBoundaryExtent(); - setNonEditableGeometries(nonEditableGeometriesResult); - }, [projectData.location.geometry, zoomToBoundaryExtent]); - - const handleOpenFullScreenMap = () => { - setShowFullScreenViewMapDialog(true); - }; - - const handleCloseFullScreenMap = () => { - setShowFullScreenViewMapDialog(false); - }; - - return ( - <> - , - initialValues: locationFormData, - validationSchema: ProjectLocationFormYupSchema - }} - onCancel={() => setOpenEditDialog(false)} - onSave={handleDialogEditSave} - /> - - } - description={projectData.location.location_description} - layers={} - backButtonTitle={'Back To Project'} - mapTitle={'Project Location'} - /> - - } - buttonOnClick={() => handleDialogEditOpen()} - // TODO: (https://apps.nrs.gov.bc.ca/int/jira/browse/SIMSBIOHUB-161) Commenting out location form (edit button) temporarily, while its decided where exactly project/survey locations should be defined - buttonProps={{ variant: 'text', disabled: true, sx: { display: 'none' } }} - renderButton={(buttonProps) => ( - - - - - ); -}; - -/** - * Memoized wrapper of `MapContainer` to ensure the map only re-renders if specificF props change. - * - * @return {*} - */ -const MemoizedMapContainer = React.memo(MapContainer, (prevProps, nextProps) => { - return prevProps.nonEditableGeometries === nextProps.nonEditableGeometries && prevProps.bounds === nextProps.bounds; -}); - -export default LocationBoundary; diff --git a/app/src/features/search/SearchPage.test.tsx b/app/src/features/search/SearchPage.test.tsx deleted file mode 100644 index 5204acc6a1..0000000000 --- a/app/src/features/search/SearchPage.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import MapContainer from 'components/map/MapContainer'; -import { createMemoryHistory } from 'history'; -import { GetRegionsResponse } from 'hooks/api/useSpatialApi'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { IGetSearchResultsResponse } from 'interfaces/useSearchApi.interface'; -import { Router } from 'react-router-dom'; -import { cleanup, render, waitFor } from 'test-helpers/test-utils'; -import SearchPage from './SearchPage'; - -const history = createMemoryHistory(); - -// Mock MapContainer component -jest.mock('../../components/map/MapContainer'); -const mockMapContainer = MapContainer as jest.Mock; - -// Mock useBioHubApi -jest.mock('../../hooks/useBioHubApi'); -const mockBiohubApi = useBiohubApi as jest.Mock; - -const mockUseApi = { - search: { - getSearchResults: jest.fn() - }, - spatial: { - getRegions: jest.fn, []>() - } -}; - -describe('SearchPage', () => { - beforeEach(() => { - mockBiohubApi.mockImplementation(() => mockUseApi); - mockMapContainer.mockImplementation(() =>
); - mockUseApi.spatial.getRegions.mockClear(); - - mockUseApi.spatial.getRegions.mockResolvedValue({ - regions: [] - }); - }); - - afterEach(() => { - cleanup(); - }); - - it('renders correctly', async () => { - const mockSearchResponse: IGetSearchResultsResponse[] = [ - { - id: '1', - name: 'name', - objectives: 'objectives', - associatedtaxa: 'CODE', - lifestage: 'lifestage', - geometry: [ - { - type: 'Feature', - geometry: { type: 'Point', coordinates: [0, 0] }, - properties: {} - } - ] - } - ]; - - mockUseApi.search.getSearchResults.mockResolvedValue(mockSearchResponse); - - const { getByText } = render( - - - - ); - - await waitFor(() => { - // Assert MapBoundary was rendered with the right propsF - expect(MapContainer).toHaveBeenCalledWith( - { - mapId: 'search_boundary_map', - scrollWheelZoom: true, - clusteredPointGeometries: [ - { - coordinates: [0, 0], - popupComponent: expect.anything() - } - ] - }, - expect.anything() - ); - // Assert section header - expect(getByText('Map')).toBeInTheDocument(); - }); - }); -}); diff --git a/app/src/features/search/SearchPage.tsx b/app/src/features/search/SearchPage.tsx deleted file mode 100644 index 6f9b177d4d..0000000000 --- a/app/src/features/search/SearchPage.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import Box from '@mui/material/Box'; -import Container from '@mui/material/Container'; -import Grid from '@mui/material/Grid'; -import Typography from '@mui/material/Typography'; -import centerOfMass from '@turf/center-of-mass'; -import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; -import MapContainer, { IClusteredPointGeometries } from 'components/map/MapContainer'; -import { SearchFeaturePopup } from 'components/map/SearchFeaturePopup'; -import { DialogContext } from 'contexts/dialogContext'; -import { APIError } from 'hooks/api/useAxios'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import React, { useCallback, useContext, useEffect, useState } from 'react'; -import { generateValidGeometryCollection } from 'utils/mapBoundaryUploadHelpers'; - -/** - * Page to search for and display a list of records spatially. - * - * @return {*} - */ -const SearchPage: React.FC = () => { - const biohubApi = useBiohubApi(); - - const [performSearch, setPerformSearch] = useState(true); - const [geometries, setGeometries] = useState([]); - - const dialogContext = useContext(DialogContext); - - const showFilterErrorDialog = useCallback( - (textDialogProps?: Partial) => { - dialogContext.setErrorDialog({ - onClose: () => { - dialogContext.setErrorDialog({ open: false }); - }, - onOk: () => { - dialogContext.setErrorDialog({ open: false }); - }, - ...textDialogProps, - open: true - }); - }, - [dialogContext] - ); - - const getSearchResults = useCallback(async () => { - try { - const response = await biohubApi.search.getSearchResults(); - - if (!response) { - setPerformSearch(false); - return; - } - - const clusteredPointGeometries: IClusteredPointGeometries[] = []; - - response.forEach((result: any) => { - const feature = generateValidGeometryCollection(result.geometry, result.id).geometryCollection[0]; - - clusteredPointGeometries.push({ - coordinates: centerOfMass(feature as any).geometry.coordinates, - popupComponent: - }); - }); - - setPerformSearch(false); - setGeometries(clusteredPointGeometries); - } catch (error) { - const apiError = error as APIError; - showFilterErrorDialog({ - dialogTitle: 'Error Searching For Results', - dialogError: apiError?.message, - dialogErrorDetails: apiError?.errors - }); - } - }, [biohubApi.search, showFilterErrorDialog]); - - useEffect(() => { - if (performSearch) { - getSearchResults(); - } - }, [performSearch, getSearchResults]); - - /** - * Displays search results visualized on a map spatially. - */ - return ( - - - - Map - - - - - - - - - - - - - ); -}; - -export default SearchPage; diff --git a/app/src/features/surveys/CreateSurveyPage.test.tsx b/app/src/features/surveys/CreateSurveyPage.test.tsx index 854e764ab1..5dac7e5099 100644 --- a/app/src/features/surveys/CreateSurveyPage.test.tsx +++ b/app/src/features/surveys/CreateSurveyPage.test.tsx @@ -95,7 +95,7 @@ describe.skip('CreateSurveyPage', () => { mockUseApi.project.getProjectForView.mockResolvedValue(getProjectForViewResponse); mockUseApi.codes.getAllCodeSets.mockResolvedValue(codes); mockUseApi.survey.getSurveyPermits.mockResolvedValue({ - permits: [{ id: 1, permit_number: 'abcd1', permit_type: 'Wildlife permit' }] + permits: [{ permit_id: 1, permit_number: 'abcd1', permit_type: 'Wildlife permit' }] }); const { getByText, getAllByText } = renderContainer(); @@ -134,8 +134,8 @@ describe.skip('CreateSurveyPage', () => { mockUseApi.survey.getSurveyPermits.mockResolvedValue({ permits: [ - { id: 1, permit_number: '123', permit_type: 'Scientific' }, - { id: 2, permit_number: '456', permit_type: 'Wildlife' } + { permit_id: 1, permit_number: '123', permit_type: 'Scientific' }, + { permit_id: 2, permit_number: '456', permit_type: 'Wildlife' } ] }); @@ -170,8 +170,8 @@ describe.skip('CreateSurveyPage', () => { mockUseApi.codes.getAllCodeSets.mockResolvedValue(codes); mockUseApi.survey.getSurveyPermits.mockResolvedValue({ permits: [ - { id: 1, permit_number: '123', permit_type: 'Scientific' }, - { id: 2, permit_number: '456', permit_type: 'Wildlife' } + { permit_id: 1, permit_number: '123', permit_type: 'Scientific' }, + { permit_id: 2, permit_number: '456', permit_type: 'Wildlife' } ] }); mockUseApi.taxonomy.getSpeciesFromIds.mockResolvedValue({ @@ -217,8 +217,8 @@ describe.skip('CreateSurveyPage', () => { mockUseApi.codes.getAllCodeSets.mockResolvedValue(codes); mockUseApi.survey.getSurveyPermits.mockResolvedValue({ permits: [ - { id: 1, permit_number: '123', permit_type: 'Scientific' }, - { id: 2, permit_number: '456', permit_type: 'Wildlife' } + { permit_id: 1, permit_number: '123', permit_type: 'Scientific' }, + { permit_id: 2, permit_number: '456', permit_type: 'Wildlife' } ] }); mockUseApi.taxonomy.getSpeciesFromIds.mockResolvedValue({ diff --git a/app/src/features/surveys/CreateSurveyPage.tsx b/app/src/features/surveys/CreateSurveyPage.tsx index 95387582da..41ad418662 100644 --- a/app/src/features/surveys/CreateSurveyPage.tsx +++ b/app/src/features/surveys/CreateSurveyPage.tsx @@ -1,12 +1,13 @@ -import { Theme } from '@mui/material'; import Box from '@mui/material/Box'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; import Button from '@mui/material/Button'; import CircularProgress from '@mui/material/CircularProgress'; import Container from '@mui/material/Container'; import Divider from '@mui/material/Divider'; +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 { makeStyles } from '@mui/styles'; import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; import { ScrollToFormikError } from 'components/formik/ScrollToFormikError'; @@ -27,6 +28,7 @@ import { ICreateSurveyRequest } from 'interfaces/useSurveyApi.interface'; import moment from 'moment'; import { useContext, useEffect, useRef, useState } from 'react'; import { Prompt, useHistory } from 'react-router'; +import { Link as RouterLink } from 'react-router-dom'; import { getFormattedDate } from 'utils/Utils'; import yup from 'utils/YupSchema'; import AgreementsForm, { AgreementsInitialValues, AgreementsYupSchema } from './components/AgreementsForm'; @@ -42,7 +44,7 @@ import PurposeAndMethodologyForm, { PurposeAndMethodologyInitialValues, PurposeAndMethodologyYupSchema } from './components/PurposeAndMethodologyForm'; -import SamplingMethodsForm from './components/SamplingMethodsForm'; +import SamplingStrategyForm from './components/SamplingStrategyForm'; import StudyAreaForm, { SurveyLocationInitialValues, SurveyLocationYupSchema } from './components/StudyAreaForm'; import { SurveyBlockInitialValues } from './components/SurveyBlockSection'; import SurveyFundingSourceForm, { @@ -51,40 +53,7 @@ import SurveyFundingSourceForm, { } from './components/SurveyFundingSourceForm'; import { SurveySiteSelectionInitialValues, SurveySiteSelectionYupSchema } from './components/SurveySiteSelectionForm'; import SurveyUserForm, { SurveyUserJobFormInitialValues, SurveyUserJobYupSchema } from './components/SurveyUserForm'; - -const useStyles = makeStyles((theme: Theme) => ({ - actionButton: { - minWidth: '6rem', - '& + button': { - marginLeft: '0.5rem' - } - }, - sectionDivider: { - height: '1px', - marginTop: theme.spacing(5), - marginBottom: theme.spacing(5) - }, - pageTitleContainer: { - maxWidth: '170ch', - overflow: 'hidden', - textOverflow: 'ellipsis' - }, - pageTitle: { - display: '-webkit-box', - '-webkit-line-clamp': 2, - '-webkit-box-orient': 'vertical', - paddingTop: theme.spacing(0.5), - paddingBottom: theme.spacing(0.5), - overflow: 'hidden' - }, - pageTitleActions: { - paddingTop: theme.spacing(0.75), - paddingBottom: theme.spacing(0.75), - '& button': { - marginLeft: theme.spacing(1) - } - } -})); +import SurveyBaseHeader from './view/components/SurveyBaseHeader'; /** * Page to create a survey. @@ -92,7 +61,6 @@ const useStyles = makeStyles((theme: Theme) => ({ * @return {*} */ const CreateSurveyPage = () => { - const classes = useStyles(); const biohubApi = useBiohubApi(); const history = useHistory(); @@ -269,30 +237,38 @@ const CreateSurveyPage = () => { if (!codes || !projectData) { return ; } + return ( <> - - - - - - - Create New Survey - - - - - - - - - - + + + {projectData.project.project_name} + + + Create New Survey + + + } + buttonJSX={ + + + + + } + /> @@ -306,142 +282,116 @@ const CreateSurveyPage = () => { onSubmit={handleSubmit}> <> - - { - return { value: item.id, label: item.name }; - }) || [] - } - projectStartDate={projectData.project.start_date} - projectEndDate={projectData.project.end_date} - /> - }> - - - - { - return { value: item.id, label: item.name, subText: item.description }; - }) || [] - } - field_methods={ - codes?.field_methods.map((item) => { - return { value: item.id, label: item.name, subText: item.description }; - }) || [] - } - ecological_seasons={ - codes?.ecological_seasons.map((item) => { - return { value: item.id, label: item.name, subText: item.description }; - }) || [] - } - vantage_codes={ - codes?.vantage_codes.map((item) => { - return { value: item.id, label: item.name }; - }) || [] - } - /> - }> - - - - } - /> - - - - - - Add Funding Sources - - + }> + { + return { value: item.id, label: item.name }; + }) || [] + } + projectStartDate={projectData.project.start_date} + projectEndDate={projectData.project.end_date} + /> + }> + + { + return { value: item.id, label: item.name, subText: item.description }; + }) || [] + } + vantage_codes={ + codes?.vantage_codes.map((item) => { + return { value: item.id, label: item.name }; + }) || [] + } + /> + }> + + } + /> + + + + Add Funding Sources + + + - - - Additional Partnerships - - + + Additional Partnerships + + + - - } - /> - - - - } - /> - - - - }> - - - - { - 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 }; - }) || [] - } - /> - }> - - - - }> - - - - - - - + } + /> + + } + /> + + }> + + { + 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/SurveyRouter.tsx b/app/src/features/surveys/SurveyRouter.tsx index 7268b22d41..a47a741419 100644 --- a/app/src/features/surveys/SurveyRouter.tsx +++ b/app/src/features/surveys/SurveyRouter.tsx @@ -5,10 +5,13 @@ import React from 'react'; import { Redirect, Switch } from 'react-router'; import RouteWithTitle from 'utils/RouteWithTitle'; import { getTitle } from 'utils/Utils'; +import { SurveyLocationPage } from './components/locations/SurveyLocationPage'; import EditSurveyPage from './edit/EditSurveyPage'; import SamplingSiteEditPage from './observations/sampling-sites/edit/SamplingSiteEditPage'; import SamplingSitePage from './observations/sampling-sites/SamplingSitePage'; import { SurveyObservationPage } from './observations/SurveyObservationPage'; +import ManualTelemetryPage from './telemetry/ManualTelemetryPage'; +import { SurveyAnimalsPage } from './view/survey-animals/SurveyAnimalsPage'; /** * Router for all `/admin/projects/:id/surveys/:survey_id/*` pages. @@ -32,6 +35,17 @@ const SurveyRouter: React.FC = () => { + + + + + + + + {/* Sample Site Routes */} @@ -43,6 +57,11 @@ const SurveyRouter: React.FC = () => { + {/* Survey Locations */} + + + + { + it('renders delete icon when delete handler provided', () => { + const { getByTestId, getByText, queryByTestId } = render( + + ); + expect(getByText('test-header')).toBeInTheDocument(); + expect(getByText('test-subheader')).toBeInTheDocument(); + expect(getByTestId('delete-icon')).toBeInTheDocument(); + expect(queryByTestId('edit-icon')).not.toBeInTheDocument(); + fireEvent.click(getByTestId('delete-icon')); + expect(handle).toHaveBeenCalled(); + }); + + it('renders edit icon when edit handler provided', () => { + const { getByTestId, getByText, queryByTestId } = render( + + ); + expect(getByText('test-header')).toBeInTheDocument(); + expect(getByText('test-subheader')).toBeInTheDocument(); + expect(queryByTestId('delete-icon')).not.toBeInTheDocument(); + expect(getByTestId('edit-icon')).toBeInTheDocument(); + fireEvent.click(getByTestId('edit-icon')); + expect(handle).toHaveBeenCalled(); + }); +}); diff --git a/app/src/features/surveys/components/EditDeleteStubCard.tsx b/app/src/features/surveys/components/EditDeleteStubCard.tsx new file mode 100644 index 0000000000..1eb8ecf642 --- /dev/null +++ b/app/src/features/surveys/components/EditDeleteStubCard.tsx @@ -0,0 +1,76 @@ +import { mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import { Card, CardHeader, IconButton } from '@mui/material'; + +interface IEditDeleteStubCardProps { + /* + * title of the card + */ + header: string; + + /* + * sub header text of the card + */ + subHeader: string; + + /* + * edit handler - undefined prevents edit action from rendering + */ + onClickEdit?: () => void; + + /* + * delete handler - undefined prevents delete action from rendering + */ + onClickDelete?: () => void; +} + +/** + * Renders a card with title and sub header text with additional edit / delete controls inline + * + * @param {EditDeleteStubCardProps} props + * + * @return {*} + * + **/ + +export const EditDeleteStubCard = (props: IEditDeleteStubCardProps) => { + const { header, subHeader, onClickEdit, onClickDelete } = props; + return ( + + + {onClickEdit && ( + + + + )} + {onClickDelete && ( + + + + )} + + } + title={header} + subheader={subHeader} + /> + + ); +}; diff --git a/app/src/features/surveys/components/EditSamplingMethod.tsx b/app/src/features/surveys/components/EditSamplingMethod.tsx index b00f6ae229..7cd4797498 100644 --- a/app/src/features/surveys/components/EditSamplingMethod.tsx +++ b/app/src/features/surveys/components/EditSamplingMethod.tsx @@ -1,8 +1,8 @@ import EditDialog from 'components/dialog/EditDialog'; -import yup from 'utils/YupSchema'; import MethodForm, { IEditSurveySampleMethodData, ISurveySampleMethodData, + SamplingSiteMethodYupSchema, SurveySampleMethodDataInitialValues } from './MethodForm'; @@ -13,24 +13,6 @@ interface IEditSamplingMethodProps { onClose: () => void; } -export const SamplingSiteMethodYupSchema = yup.object({ - method_lookup_id: yup.number().required(), - description: yup.string().required(), - periods: yup - .array( - yup.object({ - start_date: yup.string().isValidDateString().required('Start date is required'), - end_date: yup - .string() - .isValidDateString() - .isEndDateSameOrAfterStartDate('start_date') - .required('End date is required') - }) - ) - .hasUniqueDateRanges('Periods cannot overlap', 'start_date', 'end_state') - .min(1, 'At least one time period is required') -}); - const EditSamplingMethod: React.FC = (props) => { const { open, initialData, onSubmit, onClose } = props; return ( diff --git a/app/src/features/surveys/components/MethodForm.tsx b/app/src/features/surveys/components/MethodForm.tsx index cd8849e301..8df2aa0462 100644 --- a/app/src/features/surveys/components/MethodForm.tsx +++ b/app/src/features/surveys/components/MethodForm.tsx @@ -1,5 +1,6 @@ -import { mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import { mdiArrowRight, mdiCalendarMonthOutline, mdiClockOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; import Icon from '@mdi/react'; +import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import CircularProgress from '@mui/material/CircularProgress'; @@ -9,15 +10,16 @@ import IconButton from '@mui/material/IconButton'; import InputLabel from '@mui/material/InputLabel'; import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; -import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction'; import MenuItem from '@mui/material/MenuItem'; import Select from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import CustomTextField from 'components/fields/CustomTextField'; -import StartEndDateFields from 'components/fields/StartEndDateFields'; +import { DateTimeFields } from 'components/fields/DateTimeFields'; import { CodesContext } from 'contexts/codesContext'; import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; import get from 'lodash-es/get'; +import moment from 'moment'; import { useContext, useEffect } from 'react'; import yup from 'utils/YupSchema'; @@ -26,6 +28,8 @@ interface ISurveySampleMethodPeriodData { survey_sample_method_id: number | null; start_date: string; end_date: string; + start_time: string | null; + end_time: string | null; } export interface ISurveySampleMethodData { @@ -45,7 +49,9 @@ export const SurveySampleMethodPeriodArrayItemInitialValues = { survey_sample_period_id: '' as unknown as null, survey_sample_method_id: '' as unknown as null, start_date: '', - end_date: '' + end_date: '', + start_time: '', + end_time: '' }; export const SurveySampleMethodDataInitialValues = { @@ -61,19 +67,36 @@ export const SamplingSiteMethodYupSchema = yup.object({ description: yup.string().max(250, 'Maximum 250 characters'), periods: yup .array( - yup.object({ - start_date: yup - .string() - .typeError('Start Date is required') - .isValidDateString() - .required('Start Date is required'), - end_date: yup - .string() - .typeError('End Date is required') - .isValidDateString() - .required('End Date is required') - .isEndDateSameOrAfterStartDate('start_date') - }) + yup + .object({ + start_date: yup + .string() + .typeError('Start Date is required') + .isValidDateString() + .required('Start Date is required'), + end_date: yup + .string() + .typeError('End Date is required') + .isValidDateString() + .required('End Date is required') + .isEndDateSameOrAfterStartDate('start_date'), + start_time: yup.string().when('end_time', { + is: (val: string | null) => val && val !== null, + then: yup.string().typeError('Start Time is required').required('Start Time is required'), + otherwise: yup.string().nullable() + }), + end_time: yup.string().nullable() + }) + .test('checkDatesAreSameAndEndTimeIsAfterStart', 'Start and End dates must be different', function (value) { + const { start_date, end_date, start_time, end_time } = value; + + if (start_date === end_date && start_time && end_time) { + return moment(`${start_date} ${start_time}`, 'YYYY-MM-DD HH:mm:ss').isBefore( + moment(`${end_date} ${end_time}`, 'YYYY-MM-DD HH:mm:ss') + ); + } + return true; + }) ) .hasUniqueDateRanges('Periods cannot overlap', 'start_date', 'end_date') .min(1, 'At least one time period is required') @@ -94,117 +117,194 @@ const MethodForm = () => { return (
- - - Method Type - - {get(touched, 'method_lookup_id') && get(errors, 'method_lookup_id')} - - - - - - - - - Time Periods - - - ( - <> - - {values.periods.map((period, index) => { - return ( - - - - - + + Details + + Method Type + + {get(touched, 'method_lookup_id') && get(errors, 'method_lookup_id')} + + + + + + + Time Periods + + + ( + + {errors.periods && typeof errors.periods === 'string' && ( + + {String(errors.periods)} + + )} + + + {values.periods.map((period, index) => { + return ( + - arrayHelpers.remove(index)}> - - - - - ); - })} - - {errors.periods && !Array.isArray(errors.periods) && ( - - - {errors.periods} - - - )} - - - - )} - /> + + + + + {errors.periods && + typeof errors.periods !== 'string' && + errors.periods[index] && + typeof errors.periods[index] === 'string' && ( + + {String(errors.periods[index])} + + )} + + + + + + + + + {errors.periods && + typeof errors.periods !== 'string' && + errors.periods[index] && + typeof errors.periods[index] === 'string' && ( + + {String(errors.periods[index])} + + )} + + + arrayHelpers.remove(index)}> + + + + + ); + })} + + + + + )} + /> + - +
); }; diff --git a/app/src/features/surveys/components/PurposeAndMethodologyForm.tsx b/app/src/features/surveys/components/PurposeAndMethodologyForm.tsx index 0bd153a960..dc57b81888 100644 --- a/app/src/features/surveys/components/PurposeAndMethodologyForm.tsx +++ b/app/src/features/surveys/components/PurposeAndMethodologyForm.tsx @@ -2,47 +2,40 @@ 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, { IMultiAutocompleteFieldOption } from 'components/fields/MultiAutocompleteFieldVariableSize'; -import SelectWithSubtextField, { ISelectWithSubtextFieldOption } from 'components/fields/SelectWithSubtext'; +import { ISelectWithSubtextFieldOption } from 'components/fields/SelectWithSubtext'; import React from 'react'; import yup from 'utils/YupSchema'; export interface IPurposeAndMethodologyForm { purpose_and_methodology: { - intended_outcome_id: number; + intended_outcome_ids: number[]; additional_details: string; - field_method_id: number; - ecological_season_id: number; vantage_code_ids: number[]; }; } export const PurposeAndMethodologyInitialValues: IPurposeAndMethodologyForm = { purpose_and_methodology: { - intended_outcome_id: '' as unknown as number, + intended_outcome_ids: [], additional_details: '', - field_method_id: '' as unknown as number, - ecological_season_id: '' as unknown as number, vantage_code_ids: [] } }; export const PurposeAndMethodologyYupSchema = yup.object().shape({ purpose_and_methodology: yup.object().shape({ - field_method_id: yup.number().required('Field Method is Required'), additional_details: yup.string(), - intended_outcome_id: yup.number().required('Intended Outcome is Required'), - ecological_season_id: yup.number().required('Ecological Season is Required'), + 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') }) }); export interface IPurposeAndMethodologyFormProps { intended_outcomes: ISelectWithSubtextFieldOption[]; - field_methods: ISelectWithSubtextFieldOption[]; - ecological_seasons: ISelectWithSubtextFieldOption[]; vantage_codes: IMultiAutocompleteFieldOption[]; } @@ -60,10 +53,9 @@ const PurposeAndMethodologyForm: React.FC = (pr - @@ -82,24 +74,6 @@ const PurposeAndMethodologyForm: React.FC = (pr Survey Methodology - - - - - - { onSubmit={(data) => { setFieldValue(`methods[${values.methods.length}]`, data); validateField('methods'); + setAnchorEl(null); setIsCreateModalOpen(false); }} onClose={() => { + setAnchorEl(null); setIsCreateModalOpen(false); }} /> @@ -75,9 +79,11 @@ const SamplingMethodForm = () => { open={isEditModalOpen} onSubmit={(data, index) => { setFieldValue(`methods[${index}]`, data); + setAnchorEl(null); setIsEditModalOpen(false); }} onClose={() => { + setAnchorEl(null); setIsEditModalOpen(false); }} /> @@ -115,7 +121,7 @@ const SamplingMethodForm = () => { variant="body1" color="textSecondary" sx={{ - mb: 2, + mb: 3, maxWidth: '92ch' }}> Methods added here will be applied to ALL sampling locations. These can be modified later if required. @@ -130,26 +136,13 @@ const SamplingMethodForm = () => { {errors.methods} )} - + {values.methods.map((item, index) => ( - + { 'sample_methods', item.method_lookup_id || 0 )}`} - subheader={item.description} action={ ) => @@ -171,41 +163,69 @@ const SamplingMethodForm = () => { - - Time Periods - - - {item.periods.map((period) => ( - - - - - - - ))} - + + {item.description && ( + + {item.description} + + )} + + + Time Periods + + + + {item.periods.map((period) => ( + + + + + + + ))} + + + ))} - - + +
diff --git a/app/src/features/surveys/components/SamplingMethodsForm.tsx b/app/src/features/surveys/components/SamplingStrategyForm.tsx similarity index 95% rename from app/src/features/surveys/components/SamplingMethodsForm.tsx rename to app/src/features/surveys/components/SamplingStrategyForm.tsx index 31e4a6e69b..1d5653aa6d 100644 --- a/app/src/features/surveys/components/SamplingMethodsForm.tsx +++ b/app/src/features/surveys/components/SamplingStrategyForm.tsx @@ -6,7 +6,7 @@ import SurveyBlockSection from './SurveyBlockSection'; import SurveySiteSelectionForm from './SurveySiteSelectionForm'; import SurveyStratumForm from './SurveyStratumForm'; -const SamplingMethodsForm = () => { +const SamplingStrategyForm = () => { const [showStratumForm, setShowStratumForm] = useState(false); return ( @@ -53,4 +53,4 @@ const SamplingMethodsForm = () => { ); }; -export default SamplingMethodsForm; +export default SamplingStrategyForm; diff --git a/app/src/features/surveys/components/StudyAreaForm.test.tsx b/app/src/features/surveys/components/StudyAreaForm.test.tsx index ad0079d7e2..1200606371 100644 --- a/app/src/features/surveys/components/StudyAreaForm.test.tsx +++ b/app/src/features/surveys/components/StudyAreaForm.test.tsx @@ -1,5 +1,4 @@ import { cleanup } from '@testing-library/react-hooks'; -import MapBoundary from 'components/boundary/MapBoundary'; import StudyAreaForm, { ISurveyLocationForm, SurveyLocationInitialValues, @@ -7,14 +6,15 @@ import StudyAreaForm, { } from 'features/surveys/components/StudyAreaForm'; import { Formik } from 'formik'; import { render, waitFor } from 'test-helpers/test-utils'; +import { SurveyAreaMapControl } from './locations/SurveyAreaMapControl'; -// Mock MapBoundary component -jest.mock('../../../components/boundary/MapBoundary'); -const mockMapBoundary = MapBoundary as jest.Mock; +// Mock Map Controller component +jest.mock('./locations/SurveyAreaMapControl'); +const mockMap = SurveyAreaMapControl as jest.Mock; describe('Study Area Form', () => { beforeEach(() => { - mockMapBoundary.mockImplementation(() =>
); + mockMap.mockImplementation(() =>
); }); afterEach(() => { @@ -22,7 +22,7 @@ describe('Study Area Form', () => { }); it('renders correctly with default values', async () => { - const { getByLabelText, getByTestId } = render( + const { getByTestId } = render( { ); await waitFor(() => { - // Assert MapBoundary was rendered with the right propsF - expect(MapBoundary).toHaveBeenCalledWith( - { - name: 'locations[0].geojson', - title: 'Study Area Boundary', - mapId: 'study_area_form_map', - bounds: undefined, - formikProps: expect.objectContaining({ values: SurveyLocationInitialValues }) - }, - expect.anything() - ); - // Assert survey area name field is visible and populated correctly - expect(getByLabelText('Survey Area Name', { exact: false })).toBeVisible(); - expect(getByTestId('locations[0].name')).toHaveValue(''); + expect(getByTestId('study-area-list')).toBeVisible(); }); }); @@ -55,6 +42,7 @@ describe('Study Area Form', () => { const existingFormValues: ISurveyLocationForm = { locations: [ { + survey_location_id: 1, name: 'a study area name', description: 'a study area description', geojson: [ @@ -68,12 +56,13 @@ describe('Study Area Form', () => { name: 'Dinagat Islands' } } - ] + ], + uuid: '1234-5678-9101-1121' } ] }; - const { getByLabelText, getByTestId } = render( + const { getByTestId, findByText } = render( { ); - await waitFor(() => { - // Assert MapBoundary was rendered with the right propsF - expect(MapBoundary).toHaveBeenCalledWith( - { - name: 'locations[0].geojson', - title: 'Study Area Boundary', - mapId: 'study_area_form_map', - bounds: undefined, - formikProps: expect.objectContaining({ values: existingFormValues }) - }, - expect.anything() - ); - // Assert survey area name field is visible and populated correctly - expect(getByLabelText('Survey Area Name', { exact: false })).toBeVisible(); - expect(getByTestId('locations[0].name')).toHaveValue('a study area name'); + await waitFor(async () => { + expect(getByTestId('study-area-list')).toBeVisible(); + expect(await findByText('a study area description')).toBeVisible(); }); }); }); diff --git a/app/src/features/surveys/components/StudyAreaForm.tsx b/app/src/features/surveys/components/StudyAreaForm.tsx index 53bab39238..0f3a4234e7 100644 --- a/app/src/features/surveys/components/StudyAreaForm.tsx +++ b/app/src/features/surveys/components/StudyAreaForm.tsx @@ -1,42 +1,55 @@ +import Alert from '@mui/material/Alert'; +import AlertTitle from '@mui/material/AlertTitle'; import Box from '@mui/material/Box'; -import MapBoundary from 'components/boundary/MapBoundary'; -import CustomTextField from 'components/fields/CustomTextField'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import EditDialog from 'components/dialog/EditDialog'; +import YesNoDialog from 'components/dialog/YesNoDialog'; +import { IDrawControlsRef } from 'components/map/components/DrawControls'; import { useFormikContext } from 'formik'; import { Feature } from 'geojson'; +import { createRef, useMemo, useState } from 'react'; import yup from 'utils/YupSchema'; +import { SurveyAreaList } from './locations/SurveyAreaList'; +import SurveyAreaLocationForm from './locations/SurveyAreaLocationForm'; +import { SurveyAreaMapControl } from './locations/SurveyAreaMapControl'; +export interface ISurveyLocation { + survey_location_id?: number; + name: string; + description: string; + geojson: Feature[]; + revision_count?: number; + // This is an id meant for the front end only. This is is set if the geojson was drawn by the user (on the leaflet map) vs imported (file upload or region selector) + // Locations drawn by the user should be editable in the leaflet map using the draw tools available + // Any uploaded or selected regions should not be editable and be placed in the 'static' layer on the map + leaflet_id?: number; + // This is used to give each location a unique ID so the list/ collapse components have a key + uuid: string; +} export interface ISurveyLocationForm { - locations: { - survey_location_id?: number; - name: string; - description: string; - geojson: Feature[]; - revision_count?: number; - }[]; + locations: ISurveyLocation[]; } export const SurveyLocationInitialValues: ISurveyLocationForm = { - locations: [ - { - survey_location_id: null as unknown as number, - name: '', - // TODO description is temporarily hardcoded until the new UI to populate this field is implemented in - // https://apps.nrs.gov.bc.ca/int/jira/browse/SIMSBIOHUB-219 - description: 'Insert description here', - geojson: [], - revision_count: 0 - } - ] + locations: [] }; +export const SurveyLocationDetailsYupSchema = yup.object({ + name: yup.string().max(100, 'Name cannot exceed 100 characters').required('Name is Required'), + description: yup.string().max(250, 'Description cannot exceed 250 characters').default('') +}); + export const SurveyLocationYupSchema = yup.object({ - locations: yup.array( - yup.object({ - name: yup.string().max(100, 'Name cannot exceed 100 characters').required('Name is Required'), - description: yup.string().max(250, 'Description cannot exceed 250 characters'), - geojson: yup.array().min(1, 'A geometry is required').required('A geometry is required') - }) - ) + locations: yup + .array( + yup.object({ + name: yup.string().max(100, 'Name cannot exceed 100 characters').required('Name is Required'), + description: yup.string().max(250, 'Description cannot exceed 250 characters').default(''), + geojson: yup.array().min(1, 'A geometry is required').required('A geometry is required') + }) + ) + .min(1, 'At least one feature or boundary is required for a survey study area.') }); /** @@ -46,26 +59,133 @@ export const SurveyLocationYupSchema = yup.object({ */ const StudyAreaForm = () => { const formikProps = useFormikContext(); + const { handleSubmit, values, setFieldValue, errors } = formikProps; + const [isOpen, setIsOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [currentIndex, setCurrentIndex] = useState(undefined); + const drawRef = createRef(); + const locationDialogFormData = useMemo(() => { + // Initial Dialog Data + const dialogData = { + name: '', + description: '' + }; + + if (currentIndex !== undefined) { + dialogData.name = values.locations[currentIndex]?.name; + dialogData.description = values.locations[currentIndex]?.description; + } + return dialogData; + }, [currentIndex, values.locations]); + + const onOpen = () => { + setIsOpen(true); + }; + const onClose = () => { + setIsOpen(false); + setCurrentIndex(undefined); + }; + const onSave = (data: { name: string; description: string }) => { + setFieldValue(`locations[${currentIndex}].name`, data.name); + setFieldValue(`locations[${currentIndex}].description`, data.description); + }; + + const onDeleteAll = () => { + // Use Draw Ref to remove editable layers from the map + values.locations.forEach((item) => { + if (item.leaflet_id) { + drawRef.current?.deleteLayer(item.leaflet_id); + } + }); + + // set field to an empty array + setFieldValue('locations', []); + }; + + const onDelete = (index: number) => { + // remove the item at index + const data = values.locations; + const locationData = data.splice(index, 1); + + // Use Draw Ref to remove editable layer from the map + locationData.forEach((item) => { + if (item.leaflet_id) { + drawRef.current?.deleteLayer(item.leaflet_id); + } + }); + + // set values + setFieldValue('locations', data); + }; - const { handleSubmit } = formikProps; return (
- - - - { + setIsDeleteOpen(false); + onDeleteAll(); + }} + onClose={() => setIsDeleteOpen(false)} + onNo={() => setIsDeleteOpen(false)} + /> + , + initialValues: locationDialogFormData, + validationSchema: SurveyLocationDetailsYupSchema + }} + dialogSaveButtonLabel="Save" + onCancel={() => { + onClose(); + }} + onSave={(formValues) => { + onSave(formValues); + onClose(); + }} /> + + + {errors.locations && !Array.isArray(errors?.locations) && ( + + Study Area Missing + {errors.locations} + + )} + + + + + { + setCurrentIndex(index); + onOpen(); + }} + openDelete={onDelete} + data={values.locations} + /> + + + ); }; diff --git a/app/src/features/surveys/components/SurveySectionFullPageLayout.test.tsx b/app/src/features/surveys/components/SurveySectionFullPageLayout.test.tsx new file mode 100644 index 0000000000..62b791aff6 --- /dev/null +++ b/app/src/features/surveys/components/SurveySectionFullPageLayout.test.tsx @@ -0,0 +1,69 @@ +import { AuthStateContext } from 'contexts/authStateContext'; +import { IProjectContext, ProjectContext } from 'contexts/projectContext'; +import { ISurveyContext, SurveyContext } from 'contexts/surveyContext'; +import { createMemoryHistory } from 'history'; +import { DataLoader } from 'hooks/useDataLoader'; +import { Router } from 'react-router'; +import { getMockAuthState, SystemAdminAuthState } from 'test-helpers/auth-helpers'; +import { render } from 'test-helpers/test-utils'; +import { SurveySectionFullPageLayout } from './SurveySectionFullPageLayout'; + +const sidebar =
SIDEBAR
; +const main =
MAIN_CONTENT
; + +const mockSurveyContext: ISurveyContext = { + projectId: 1, + surveyDataLoader: { + data: { surveyData: { survey_details: { survey_name: 'survey-name-1' } } }, + load: jest.fn() + } as unknown as DataLoader +} as unknown as ISurveyContext; + +const mockProjectContext: IProjectContext = { + 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 history = createMemoryHistory(); + +const _render = (_mockSurveyContext: any) => + render( + + + + + + + + + + ); + +it('should render the page naviagtion header', async () => { + const { queryAllByText, queryByText } = _render(mockSurveyContext); + expect(queryAllByText('fullpage-title')).not.toBeNull(); + expect(queryByText('survey-name-1')).toBeInTheDocument(); +}); + +it('should render sidebar and main content', () => { + const { getByText } = _render(mockSurveyContext); + expect(getByText('SIDEBAR')).toBeInTheDocument(); + expect(getByText('MAIN_CONTENT')).toBeInTheDocument(); +}); + +it('should render loading spinner when no survey data', () => { + const { getByTestId, queryByText } = _render({ + projectId: 1, + surveyDataLoader: { + data: undefined, + load: jest.fn() + } as unknown as DataLoader + }); + expect(getByTestId('fullpage-spinner')).toBeInTheDocument(); + expect(queryByText('SIDEBAR')).not.toBeInTheDocument(); + expect(queryByText('MAIN_CONTENT')).not.toBeInTheDocument(); +}); diff --git a/app/src/features/surveys/components/SurveySectionFullPageLayout.tsx b/app/src/features/surveys/components/SurveySectionFullPageLayout.tsx new file mode 100644 index 0000000000..7e98d60362 --- /dev/null +++ b/app/src/features/surveys/components/SurveySectionFullPageLayout.tsx @@ -0,0 +1,64 @@ +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import Divider from '@mui/material/Divider'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/system/Stack'; +import { SurveyContext } from 'contexts/surveyContext'; +import { useContext } from 'react'; +import SurveySectionHeader from '../components/SurveySectionHeader'; + +interface SurveySectionFullPageLayoutProps { + sideBarComponent: JSX.Element; + mainComponent: JSX.Element; + pageTitle: string; +} + +export const SurveySectionFullPageLayout = (props: SurveySectionFullPageLayoutProps) => { + const { sideBarComponent, mainComponent, pageTitle } = props; + const surveyContext = useContext(SurveyContext); + + if (!surveyContext.surveyDataLoader.data) { + return ; + } + + return ( + + + + + + } + sx={{ + flex: '1 1 auto', + m: 1, + overflow: 'hidden' + }}> + + {sideBarComponent} + + + + {mainComponent} + + + + ); +}; diff --git a/app/src/features/surveys/components/SurveySectionHeader.tsx b/app/src/features/surveys/components/SurveySectionHeader.tsx new file mode 100644 index 0000000000..aafd406821 --- /dev/null +++ b/app/src/features/surveys/components/SurveySectionHeader.tsx @@ -0,0 +1,53 @@ +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Link from '@mui/material/Link'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; +import { Link as RouterLink } from 'react-router-dom'; + +export interface SurveySectionHeaderProps { + project_id: number; + survey_id: number; + survey_name: string; + title: string; +} + +const SurveySectionHeader = (props: SurveySectionHeaderProps) => { + const { project_id, survey_id, survey_name, title } = props; + return ( + + + + {survey_name} + + + {title} + + + + {title} + + + ); +}; + +export default SurveySectionHeader; diff --git a/app/src/features/surveys/components/SurveySiteSelectionForm.tsx b/app/src/features/surveys/components/SurveySiteSelectionForm.tsx index a66fe5161d..7bc02300d9 100644 --- a/app/src/features/surveys/components/SurveySiteSelectionForm.tsx +++ b/app/src/features/surveys/components/SurveySiteSelectionForm.tsx @@ -31,7 +31,9 @@ export const SurveySiteSelectionYupSchema = yup.object().shape({ site_selection: yup.object().shape({ strategies: yup .array() - .of(yup.string() /* .required('Must select a valid site selection strategy') */) + .required('Site Selection Strategy is required') + .min(1, 'Site Selection Strategy is required') + .of(yup.string()) .when('stratums', (stratums: string[], schema: any) => { return stratums.length > 0 ? schema.test( @@ -130,8 +132,8 @@ const SurveySiteSelectionForm = (props: ISurveySiteSelectionFormProps) => { label="Site Selection Strategies" options={siteStrategies} selectedOptions={selectedSiteStrategies} - required={false} - onChange={(event, selectedOptions, reason) => { + required={true} + onChange={(_, selectedOptions, reason) => { // If the user clicks to remove the 'Stratified' option and there are Stratums already defined, then show // a warning dialogue asking the user if they are sure they want to remove the option and delete the Stratums if ( diff --git a/app/src/features/surveys/components/locations/SurveyAreaList.tsx b/app/src/features/surveys/components/locations/SurveyAreaList.tsx new file mode 100644 index 0000000000..18b50cfccc --- /dev/null +++ b/app/src/features/surveys/components/locations/SurveyAreaList.tsx @@ -0,0 +1,125 @@ +import { mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import Box from '@mui/material/Box'; +import Collapse from '@mui/material/Collapse'; +import { grey } from '@mui/material/colors'; +import IconButton from '@mui/material/IconButton'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +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 { useState } from 'react'; +import TransitionGroup from 'react-transition-group/TransitionGroup'; +import { ISurveyLocation } from '../StudyAreaForm'; + +export interface ISurveyAreaListProps { + data: ISurveyLocation[]; + openEdit: (index: number) => void; + openDelete: (index: number) => void; +} + +export const SurveyAreaList = (props: ISurveyAreaListProps) => { + const { data, openEdit, openDelete } = props; + const [anchorEl, setAnchorEl] = useState(null); + const [currentItemIndex, setCurrentItemIndex] = useState(-1); + + const handleMenuClick = (event: React.MouseEvent, index: number) => { + setAnchorEl(event.currentTarget); + setCurrentItemIndex(index); + }; + + return ( + <> + {/* CONTEXT MENU */} + setAnchorEl(null)} + anchorEl={anchorEl} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right' + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right' + }}> + { + if (currentItemIndex != null) { + openEdit(currentItemIndex); + } + setAnchorEl(null); + }}> + + + + Edit Details + + { + if (currentItemIndex != null) { + openDelete(currentItemIndex); + } + setAnchorEl(null); + }}> + + + + Remove + + + + + {data.map((item: ISurveyLocation, index: number) => { + return ( + + ) => + handleMenuClick(event, index) + } + aria-label="settings"> + + + } + sx={{ + minHeight: '55px' + }}> + + + + ); + })} + + + + ); +}; diff --git a/app/src/features/surveys/components/locations/SurveyAreaLocationForm.tsx b/app/src/features/surveys/components/locations/SurveyAreaLocationForm.tsx new file mode 100644 index 0000000000..f86c0d1fcd --- /dev/null +++ b/app/src/features/surveys/components/locations/SurveyAreaLocationForm.tsx @@ -0,0 +1,30 @@ +import { Box } from '@mui/system'; +import CustomTextField from 'components/fields/CustomTextField'; + +export interface ISurveyAreaLocationForm { + name: string; + description: string; +} + +const SurveyAreaLocationForm = () => { + return ( +
+ + + + + + ); +}; + +export default SurveyAreaLocationForm; diff --git a/app/src/features/surveys/components/locations/SurveyAreaMapControl.tsx b/app/src/features/surveys/components/locations/SurveyAreaMapControl.tsx new file mode 100644 index 0000000000..98879b32d5 --- /dev/null +++ b/app/src/features/surveys/components/locations/SurveyAreaMapControl.tsx @@ -0,0 +1,217 @@ +import { mdiTrashCanOutline, mdiTrayArrowUp } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import BaseLayerControls from 'components/map/components/BaseLayerControls'; +import { SetMapBounds } from 'components/map/components/Bounds'; +import DrawControls, { IDrawControlsRef } from 'components/map/components/DrawControls'; +import FullScreenScrollingEventHandler from 'components/map/components/FullScreenScrollingEventHandler'; +import ImportBoundaryDialog from 'components/map/components/ImportBoundaryDialog'; +import { IRegionOption, RegionSelector } from 'components/map/components/RegionSelector'; +import StaticLayers from 'components/map/components/StaticLayers'; +import { MapBaseCss } from 'components/map/styles/MapBaseCss'; +import { layerContentHandlers, layerNameHandler } from 'components/map/wfs-utils'; +import WFSFeatureGroup from 'components/map/WFSFeatureGroup'; +import { MAP_DEFAULT_CENTER, MAP_DEFAULT_ZOOM } from 'constants/spatial'; +import { FormikContextType } from 'formik'; +import { Feature, FeatureCollection } from 'geojson'; +import L, { DrawEvents, LatLngBoundsExpression } from 'leaflet'; +import { useEffect, useState } from 'react'; +import { FeatureGroup, LayersControl, MapContainer as LeafletMapContainer } from 'react-leaflet'; +import { calculateUpdatedMapBounds } from 'utils/mapBoundaryUploadHelpers'; +import { shapeFileFeatureDesc, shapeFileFeatureName } from 'utils/Utils'; +import { v4 } from 'uuid'; +import { ISurveyLocation, ISurveyLocationForm } from '../StudyAreaForm'; + +export interface ISurveyAreMapControlProps { + map_id: string; + formik_key: string; + formik_props: FormikContextType; + draw_controls_ref: React.RefObject; + toggle_delete_dialog: (isOpen: boolean) => void; +} + +export const SurveyAreaMapControl = (props: ISurveyAreMapControlProps) => { + const { map_id, formik_key, formik_props, draw_controls_ref, toggle_delete_dialog } = props; + const { setFieldValue, setFieldError, values } = formik_props; + const [updatedBounds, setUpdatedBounds] = useState(undefined); + const [isOpen, setIsOpen] = useState(false); + const [selectedRegion, setSelectedRegion] = useState(null); + + useEffect(() => { + setUpdatedBounds(calculateUpdatedMapBounds(formik_props.values.locations.map((item) => item.geojson[0]))); + }, [formik_props.values.locations]); + + return ( + <> + setIsOpen(false)} + onSuccess={(features) => { + // Map features into form data + const formData = features.map((item: Feature, index) => { + return { + name: shapeFileFeatureName(item) ?? `Study Area ${index + 1}`, + description: shapeFileFeatureDesc(item) ?? '', + geojson: [item], + revision_count: 0 + }; + }); + setUpdatedBounds(calculateUpdatedMapBounds(features)); + setFieldValue(formik_key, [...values.locations, ...formData]); + }} + onFailure={(message) => { + setFieldError(formik_key, message); + }} + /> + + + Study Areas + + ({values.locations.length}) + + + + + + { + setSelectedRegion(data); + }} + /> + + + + + + + + + + + {/* Allow scroll wheel zoom when in full screen mode */} + + + {/* Programmatically set map bounds */} + + + + { + const feature: Feature = event.layer.toGeoJSON(); + if (feature.properties) { + feature.properties.layer_id = id; + } + const location: ISurveyLocation = { + name: `Drawn Location ${id}`, + description: '', + geojson: [feature], + revision_count: 0, + leaflet_id: id, + uuid: v4() + }; + setFieldValue(formik_key, [...values.locations, location]); + }} + onLayerEdit={(event: DrawEvents.Edited) => { + event.layers.getLayers().forEach((item) => { + const layer_id = L.stamp(item); + const featureCollection = L.layerGroup([item]).toGeoJSON() as FeatureCollection; + const updatedLocations = values.locations.map((location) => { + if (location.leaflet_id === layer_id) { + location.geojson = [...featureCollection.features]; + } + return location; + }); + setFieldValue(formik_key, [...updatedLocations]); + }); + }} + onLayerDelete={(event: DrawEvents.Deleted) => { + let locationsToFilter = values.locations; + event.layers.getLayers().forEach((item) => { + const layer_id = L.stamp(item); + locationsToFilter = locationsToFilter.filter((location) => location.leaflet_id !== layer_id); + }); + setFieldValue(formik_key, [...locationsToFilter]); + }} + /> + + + {selectedRegion && ( + { + const layerName = layerNameHandler[selectedRegion.key](geo); + const region: ISurveyLocation = { + name: layerName, + description: '', + geojson: [geo], + revision_count: 0, + uuid: v4() + }; + setFieldValue(formik_key, [...values.locations, region]); + }} + /> + )} + + !item?.leaflet_id) // filter out user drawn locations + .map((item) => { + // Map geojson features into layer objects for leaflet + return { layerName: item.name, features: item.geojson.map((geo) => ({ geoJSON: geo })) }; + })} + /> + + + + + ); +}; diff --git a/app/src/features/surveys/components/locations/SurveyLocationPage.tsx b/app/src/features/surveys/components/locations/SurveyLocationPage.tsx new file mode 100644 index 0000000000..58914c8c36 --- /dev/null +++ b/app/src/features/surveys/components/locations/SurveyLocationPage.tsx @@ -0,0 +1,62 @@ +import { Button, Paper, Typography } from '@mui/material'; +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import { grey } from '@mui/material/colors'; +import { SurveyContext } from 'contexts/surveyContext'; +import { useContext } from 'react'; + +export const SurveyLocationPage = () => { + const surveyContext = useContext(SurveyContext); + + if (!surveyContext.surveyDataLoader.data) { + return ; + } + + return ( + + + + + Survey Area Boundaries + + + + + + + + + + + ); +}; diff --git a/app/src/features/surveys/edit/EditSurveyForm.tsx b/app/src/features/surveys/edit/EditSurveyForm.tsx index 16b2049cce..87a202aca0 100644 --- a/app/src/features/surveys/edit/EditSurveyForm.tsx +++ b/app/src/features/surveys/edit/EditSurveyForm.tsx @@ -1,4 +1,4 @@ -import { Theme, Typography } from '@mui/material'; +import { Stack, Theme, Typography } from '@mui/material'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Divider from '@mui/material/Divider'; @@ -7,6 +7,7 @@ import { makeStyles } from '@mui/styles'; import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; import { ScrollToFormikError } from 'components/formik/ScrollToFormikError'; import { DATE_FORMAT, DATE_LIMIT } from 'constants/dateTimeFormats'; +import SamplingStrategyForm from 'features/surveys/components/SamplingStrategyForm'; import SurveyPartnershipsForm, { SurveyPartnershipsFormInitialValues, SurveyPartnershipsFormYupSchema @@ -27,7 +28,6 @@ import GeneralInformationForm, { } from '../components/GeneralInformationForm'; import ProprietaryDataForm, { ProprietaryDataYupSchema } from '../components/ProprietaryDataForm'; import PurposeAndMethodologyForm, { PurposeAndMethodologyYupSchema } from '../components/PurposeAndMethodologyForm'; -import SamplingMethodsForm from '../components/SamplingMethodsForm'; import StudyAreaForm, { SurveyLocationInitialValues, SurveyLocationYupSchema } from '../components/StudyAreaForm'; import { SurveyBlockInitialValues } from '../components/SurveyBlockSection'; import SurveyFundingSourceForm, { @@ -73,10 +73,8 @@ const EditSurveyForm: React.FC = (props) => { ...GeneralInformationInitialValues, ...{ purpose_and_methodology: { - intended_outcome_id: '' as unknown as number, + intended_outcome_ids: [], additional_details: '', - field_method_id: '' as unknown as number, - ecological_season_id: '' as unknown as number, vantage_code_ids: [] } }, @@ -188,16 +186,6 @@ const EditSurveyForm: React.FC = (props) => { return { value: item.id, label: item.name, subText: item.description }; }) || [] } - field_methods={ - props.codes.field_methods.map((item) => { - return { value: item.id, label: item.name, subText: item.description }; - }) || [] - } - ecological_seasons={ - props.codes.ecological_seasons.map((item) => { - return { value: item.id, label: item.name, subText: item.description }; - }) || [] - } vantage_codes={ props.codes.vantage_codes.map((item) => { return { value: item.id, label: item.name }; @@ -240,9 +228,9 @@ const EditSurveyForm: React.FC = (props) => { } + component={} /> @@ -250,7 +238,18 @@ const EditSurveyForm: React.FC = (props) => { }> + component={ + + Define Survey Study Area + + + Import, draw or select a feature from an existing layer to define the study areas for this survey. + + + + + } + /> diff --git a/app/src/features/surveys/edit/EditSurveyPage.tsx b/app/src/features/surveys/edit/EditSurveyPage.tsx index 61deee4861..06c02a46f1 100644 --- a/app/src/features/surveys/edit/EditSurveyPage.tsx +++ b/app/src/features/surveys/edit/EditSurveyPage.tsx @@ -1,11 +1,11 @@ -import { Theme } from '@mui/material'; import Box from '@mui/material/Box'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; import Button from '@mui/material/Button'; import CircularProgress from '@mui/material/CircularProgress'; import Container from '@mui/material/Container'; -import Paper from '@mui/material/Paper'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; -import { makeStyles } from '@mui/styles'; import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; import { EditSurveyI18N } from 'constants/i18n'; import { CodesContext } from 'contexts/codesContext'; @@ -20,49 +20,16 @@ import useDataLoader from 'hooks/useDataLoader'; import { IEditSurveyRequest, SurveyUpdateObject } from 'interfaces/useSurveyApi.interface'; import { useContext, useEffect, useRef, useState } from 'react'; import { Prompt, useHistory, useParams } from 'react-router'; +import { Link as RouterLink } from 'react-router-dom'; +import SurveyBaseHeader from '../view/components/SurveyBaseHeader'; import EditSurveyForm from './EditSurveyForm'; -const useStyles = makeStyles((theme: Theme) => ({ - actionButton: { - minWidth: '6rem', - '& + button': { - marginLeft: '0.5rem' - } - }, - sectionDivider: { - height: '1px', - marginTop: theme.spacing(5), - marginBottom: theme.spacing(5) - }, - pageTitleContainer: { - maxWidth: '170ch', - overflow: 'hidden', - textOverflow: 'ellipsis' - }, - pageTitle: { - display: '-webkit-box', - '-webkit-line-clamp': 2, - '-webkit-box-orient': 'vertical', - paddingTop: theme.spacing(0.5), - paddingBottom: theme.spacing(0.5), - overflow: 'hidden' - }, - pageTitleActions: { - paddingTop: theme.spacing(0.75), - paddingBottom: theme.spacing(0.75), - '& button': { - marginLeft: theme.spacing(1) - } - } -})); - /** * Page to create a survey. * * @return {*} */ const EditSurveyPage = () => { - const classes = useStyles(); const biohubApi = useBiohubApi(); const history = useHistory(); const urlParams: Record = useParams(); @@ -214,27 +181,43 @@ const EditSurveyPage = () => { return ( <> - - - - - - - Edit Survey Details - - - - - - - - - - + + + {projectData.project.project_name} + + + {surveyData && surveyData.survey_details && surveyData.survey_details.survey_name} + + + Edit Survey Details + + + } + buttonJSX={ + + + + + } + /> + { beforeEach(() => { mockBiohubApi.mockImplementation(() => mockUseApi); - mockUseApi.survey.getSurveysList.mockClear(); + mockUseApi.survey.getSurveysBasicFieldsByProjectId.mockClear(); }); afterEach(() => { @@ -56,18 +58,22 @@ describe('SurveysListPage', () => { hasLoadedParticipantInfo: true }; - mockUseApi.survey.getSurveysList.mockResolvedValue([]); + mockUseApi.survey.getSurveysBasicFieldsByProjectId.mockResolvedValue([]); + + const authState = getMockAuthState({ base: SystemAdminAuthState }); const { getByText } = render( - - - - - - - - - + + + + + + + + + + + ); await waitFor(() => { @@ -102,16 +108,20 @@ describe('SurveysListPage', () => { projectId: 1 }; + const authState = getMockAuthState({ base: SystemAdminAuthState }); + const { getByText } = render( - - - - - - - - - + + + + + + + + + + + ); await waitFor(() => { diff --git a/app/src/features/surveys/observations/ObservationComponent.tsx b/app/src/features/surveys/observations/ObservationComponent.tsx deleted file mode 100644 index 4d428cf09e..0000000000 --- a/app/src/features/surveys/observations/ObservationComponent.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { mdiCogOutline, mdiPlus } 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 Toolbar from '@mui/material/Toolbar'; -import Typography from '@mui/material/Typography'; -import YesNoDialog from 'components/dialog/YesNoDialog'; -import { ObservationsTableI18N } from 'constants/i18n'; -import { CodesContext } from 'contexts/codesContext'; -import { ObservationsContext } from 'contexts/observationsContext'; -import { SurveyContext } from 'contexts/surveyContext'; -import ObservationsTable, { - ISampleMethodSelectProps, - ISamplePeriodSelectProps, - ISampleSiteSelectProps -} from 'features/surveys/observations/ObservationsTable'; -import { useContext, useState } from 'react'; -import { getCodesName } from 'utils/Utils'; - -const ObservationComponent = () => { - const sampleSites: ISampleSiteSelectProps[] = []; - const sampleMethods: ISampleMethodSelectProps[] = []; - const samplePeriods: ISamplePeriodSelectProps[] = []; - const observationsContext = useContext(ObservationsContext); - const surveyContext = useContext(SurveyContext); - const codesContext = useContext(CodesContext); - - const [isSaving, setIsSaving] = useState(false); - const [showConfirmRemoveAllDialog, setShowConfirmRemoveAllDialog] = useState(false); - - const handleSaveChanges = async () => { - setIsSaving(true); - - return observationsContext.saveRecords().finally(() => { - setIsSaving(false); - }); - }; - - const showSaveButton = observationsContext.hasUnsavedChanges(); - - if (surveyContext.sampleSiteDataLoader.data && codesContext.codesDataLoader.data) { - // loop through and collect all sites - surveyContext.sampleSiteDataLoader.data.sampleSites.forEach((site) => { - sampleSites.push({ - survey_sample_site_id: site.survey_sample_site_id, - sample_site_name: site.name - }); - - // loop through and collect all methods for all sites - site.sample_methods?.forEach((method) => { - sampleMethods.push({ - survey_sample_method_id: method.survey_sample_method_id, - survey_sample_site_id: site.survey_sample_site_id, - sample_method_name: - getCodesName(codesContext.codesDataLoader.data, 'sample_methods', method.method_lookup_id) ?? '' - }); - - // loop through and collect all periods for all methods for all sites - method.sample_periods?.forEach((period) => { - samplePeriods.push({ - survey_sample_period_id: period.survey_sample_period_id, - survey_sample_method_id: period.survey_sample_method_id, - sample_period_name: `${period.start_date} - ${period.end_date}` - }); - }); - }); - }); - } - - return ( - <> - { - setShowConfirmRemoveAllDialog(false); - observationsContext.revertRecords(); - }} - onClose={() => setShowConfirmRemoveAllDialog(false)} - onNo={() => setShowConfirmRemoveAllDialog(false)} - /> - - - - Observations - - {showSaveButton && ( - <> - handleSaveChanges()}> - Save - - - - )} - - - - - - - - - - - ); -}; - -export default ObservationComponent; diff --git a/app/src/features/surveys/observations/ObservationMapView.tsx b/app/src/features/surveys/observations/ObservationMapView.tsx deleted file mode 100644 index 244ac7c6eb..0000000000 --- a/app/src/features/surveys/observations/ObservationMapView.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; - -export const ObservationMapView = () => { - return ( - - Map View - - ); -}; diff --git a/app/src/features/surveys/observations/ObservationsMap.tsx b/app/src/features/surveys/observations/ObservationsMap.tsx new file mode 100644 index 0000000000..09c95a83fc --- /dev/null +++ b/app/src/features/surveys/observations/ObservationsMap.tsx @@ -0,0 +1,177 @@ +import { mdiRefresh } from '@mdi/js'; +import Icon from '@mdi/react'; +import { IconButton } from '@mui/material'; +import Box from '@mui/material/Box'; +import BaseLayerControls from 'components/map/components/BaseLayerControls'; +import { SetMapBounds } from 'components/map/components/Bounds'; +import FullScreenScrollingEventHandler from 'components/map/components/FullScreenScrollingEventHandler'; +import StaticLayers from 'components/map/components/StaticLayers'; +import { MapBaseCss } from 'components/map/styles/MapBaseCss'; +import { ALL_OF_BC_BOUNDARY, MAP_DEFAULT_CENTER } from 'constants/spatial'; +import { ObservationsContext } from 'contexts/observationsContext'; +import { SurveyContext } from 'contexts/surveyContext'; +import { Feature, Position } from 'geojson'; +import { LatLngBoundsExpression } from 'leaflet'; +import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { GeoJSON, LayersControl, MapContainer as LeafletMapContainer } from 'react-leaflet'; +import { calculateUpdatedMapBounds } from 'utils/mapBoundaryUploadHelpers'; +import { coloredPoint, INonEditableGeometries } from 'utils/mapUtils'; +import { v4 as uuidv4 } from 'uuid'; + +const ObservationsMap = () => { + const observationsContext = useContext(ObservationsContext); + const surveyContext = useContext(SurveyContext); + + const surveyObservations: INonEditableGeometries[] = useMemo(() => { + const observations = observationsContext.observationsDataLoader.data?.surveyObservations; + + if (!observations) { + return []; + } + + return observations + .filter((observation) => observation.latitude !== undefined && observation.longitude !== undefined) + .map((observation) => { + /* + const link = observation.survey_observation_id + ? `observations/#view-${observation.survey_observation_id}` + : 'observations' + */ + + return { + feature: { + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: [observation.longitude, observation.latitude] as Position + } + }, + popupComponent: undefined + /*( + +
{JSON.stringify(observation)}
+ +
+ )*/ + }; + }); + }, [observationsContext.observationsDataLoader.data]); + + const studyAreaFeatures: Feature[] = useMemo(() => { + const locations = surveyContext.surveyDataLoader.data?.surveyData.locations; + if (!locations) { + return []; + } + + return locations.flatMap((item) => item.geojson); + }, [surveyContext.surveyDataLoader.data]); + + const sampleSiteFeatures: Feature[] = useMemo(() => { + const sites = surveyContext.sampleSiteDataLoader.data?.sampleSites; + if (!sites) { + return []; + } + + return sites.map((item) => item.geojson); + }, [surveyContext.sampleSiteDataLoader.data]); + + const getDefaultMapBounds = useCallback((): LatLngBoundsExpression | undefined => { + const features = surveyObservations.map((observation) => observation.feature); + return calculateUpdatedMapBounds([...features, ...studyAreaFeatures, ...sampleSiteFeatures]); + }, [surveyObservations, studyAreaFeatures, sampleSiteFeatures]); + + // set default bounds to encompass all of BC + const [bounds, setBounds] = useState( + calculateUpdatedMapBounds([ALL_OF_BC_BOUNDARY]) + ); + + const zoomToBoundaryExtent = useCallback(() => { + setBounds(getDefaultMapBounds()); + }, [getDefaultMapBounds]); + + useEffect(() => { + // Once all data loaders have finished loading it will zoom the map to include all features + if ( + !surveyContext.surveyDataLoader.isLoading && + !surveyContext.sampleSiteDataLoader.isLoading && + !observationsContext.observationsDataLoader.isLoading + ) { + zoomToBoundaryExtent(); + } + }, [ + observationsContext.observationsDataLoader.isLoading, + surveyContext.sampleSiteDataLoader.isLoading, + surveyContext.surveyDataLoader.isLoading, + zoomToBoundaryExtent + ]); + + return ( + <> + + + + + + {surveyObservations?.map((nonEditableGeo: INonEditableGeometries) => ( + coloredPoint({ latlng, fillColor: '#1f7dff', borderColor: '#ffffff' })}> + {nonEditableGeo.popupComponent} + + ))} + + + ({ geoJSON: feature, tooltip: Study Area })) + }, + { + layerName: 'Sample Sites', + layerColors: { color: '#1f7dff', fillColor: '#1f7dff' }, + features: sampleSiteFeatures.map((feature) => ({ + geoJSON: feature, + tooltip: Sample Site + })) + } + ]} + /> + + + {(surveyObservations.length > 0 || studyAreaFeatures.length > 0 || sampleSiteFeatures.length > 0) && ( + + zoomToBoundaryExtent()}> + + + + )} + + ); +}; + +export default ObservationsMap; diff --git a/app/src/features/surveys/observations/ObservationsTable.tsx b/app/src/features/surveys/observations/ObservationsTable.tsx deleted file mode 100644 index 0543261643..0000000000 --- a/app/src/features/surveys/observations/ObservationsTable.tsx +++ /dev/null @@ -1,405 +0,0 @@ -import { mdiTrashCanOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import IconButton from '@mui/material/IconButton'; -import { DataGrid, GridColDef, GridEditInputCell, GridEventListener, GridRowModelUpdate } from '@mui/x-data-grid'; -import { LocalizationProvider, TimePicker } from '@mui/x-date-pickers'; -import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'; -import AutocompleteDataGridEditCell from 'components/data-grid/autocomplete/AutocompleteDataGridEditCell'; -import AutocompleteDataGridViewCell from 'components/data-grid/autocomplete/AutocompleteDataGridViewCell'; -import ConditionalAutocompleteDataGridEditCell from 'components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell'; -import ConditionalAutocompleteDataGridViewCell from 'components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell'; -import TaxonomyDataGridEditCell from 'components/data-grid/taxonomy/TaxonomyDataGridEditCell'; -import TaxonomyDataGridViewCell from 'components/data-grid/taxonomy/TaxonomyDataGridViewCell'; -import YesNoDialog from 'components/dialog/YesNoDialog'; -import { ObservationsTableI18N } from 'constants/i18n'; -import { IObservationTableRow, ObservationsContext } from 'contexts/observationsContext'; -import moment from 'moment'; -import { useContext, useEffect, useState } from 'react'; - -export interface ISampleSiteSelectProps { - survey_sample_site_id: number; - sample_site_name: string; -} - -export interface ISampleMethodSelectProps { - survey_sample_method_id: number; - survey_sample_site_id: number; - sample_method_name: string; -} - -export interface ISamplePeriodSelectProps { - survey_sample_period_id: number; - survey_sample_method_id: number; - sample_period_name: string; -} -export interface ISpeciesObservationTableProps { - sample_sites: { - survey_sample_site_id: number; - sample_site_name: string; - }[]; - sample_methods: { - survey_sample_method_id: number; - survey_sample_site_id: number; - sample_method_name: string; - }[]; - sample_periods: { - survey_sample_period_id: number; - survey_sample_method_id: number; - sample_period_name: string; - }[]; -} - -const ObservationsTable = (props: ISpeciesObservationTableProps) => { - const { sample_sites, sample_methods, sample_periods } = props; - const observationsContext = useContext(ObservationsContext); - const { observationsDataLoader } = observationsContext; - - const apiRef = observationsContext._muiDataGridApiRef; - - const observationColumns: GridColDef[] = [ - { - field: 'wldtaxonomic_units_id', - headerName: 'Species', - editable: true, - flex: 1, - minWidth: 250, - disableColumnMenu: true, - headerAlign: 'left', - align: 'left', - renderCell: (params) => { - return ; - }, - renderEditCell: (params) => { - return ; - } - }, - { - field: 'survey_sample_site_id', - headerName: 'Sampling Site', - editable: true, - flex: 1, - minWidth: 200, - disableColumnMenu: true, - headerAlign: 'left', - align: 'left', - renderCell: (params) => { - return ( - ({ - label: item.sample_site_name, - value: item.survey_sample_site_id - }))} - /> - ); - }, - renderEditCell: (params) => { - return ( - ({ - label: item.sample_site_name, - value: item.survey_sample_site_id - }))} - /> - ); - } - }, - { - field: 'survey_sample_method_id', - headerName: 'Sampling Method', - editable: true, - flex: 1, - minWidth: 200, - disableColumnMenu: true, - headerAlign: 'left', - align: 'left', - renderCell: (params) => { - return ( - { - return allOptions - .filter((item) => item.survey_sample_site_id === row.survey_sample_site_id) - .map((item) => ({ label: item.sample_method_name, value: item.survey_sample_method_id })); - }} - allOptions={sample_methods} - /> - ); - }, - renderEditCell: (params) => { - return ( - { - return allOptions - .filter((item) => item.survey_sample_site_id === row.survey_sample_site_id) - .map((item) => ({ label: item.sample_method_name, value: item.survey_sample_method_id })); - }} - allOptions={sample_methods} - /> - ); - } - }, - { - field: 'survey_sample_period_id', - headerName: 'Sampling Period', - editable: true, - flex: 1, - minWidth: 200, - disableColumnMenu: true, - headerAlign: 'left', - align: 'left', - renderCell: (params) => { - return ( - { - return allOptions - .filter((item) => item.survey_sample_method_id === row.survey_sample_method_id) - .map((item) => ({ label: item.sample_period_name, value: item.survey_sample_period_id })); - }} - allOptions={sample_periods} - /> - ); - }, - renderEditCell: (params) => { - return ( - { - return allOptions - .filter((item) => item.survey_sample_method_id === row.survey_sample_method_id) - .map((item) => ({ label: item.sample_period_name, value: item.survey_sample_period_id })); - }} - allOptions={sample_periods} - /> - ); - } - }, - { - field: 'count', - headerName: 'Count', - editable: true, - type: 'number', - minWidth: 100, - disableColumnMenu: true, - headerAlign: 'left', - align: 'left', - renderEditCell: (params) => ( - - ) - }, - { - field: 'observation_date', - headerName: 'Date', - editable: true, - type: 'date', - minWidth: 150, - valueGetter: (params) => (params.row.observation_date ? moment(params.row.observation_date).toDate() : null), - disableColumnMenu: true, - headerAlign: 'left', - align: 'left' - }, - { - field: 'observation_time', - headerName: 'Time', - editable: true, - type: 'string', - width: 150, - disableColumnMenu: true, - headerAlign: 'left', - align: 'left', - renderCell: (params) => { - if (!params.value) { - return null; - } - - if (moment.isMoment(params.value)) { - return <>{params.value.format('HH:mm')}; - } - - return <>{moment(params.value, 'HH:mm:ss').format('HH:mm')}; - }, - renderEditCell: (params) => { - return ( - - { - apiRef?.current.setEditCellValue({ id: params.id, field: params.field, value: value }); - }} - onAccept={(value) => { - apiRef?.current.setEditCellValue({ - id: params.id, - field: params.field, - value: value?.format('HH:mm:ss') - }); - }} - ampm={false} - /> - - ); - } - }, - { - field: 'latitude', - headerName: 'Lat', - type: 'number', - editable: true, - width: 150, - disableColumnMenu: true, - headerAlign: 'left', - align: 'left', - renderCell: (params) => String(params.row.latitude) - }, - { - field: 'longitude', - headerName: 'Long', - type: 'number', - editable: true, - width: 150, - disableColumnMenu: true, - headerAlign: 'left', - align: 'left', - renderCell: (params) => String(params.row.longitude) - }, - { - field: 'actions', - headerName: '', - type: 'actions', - width: 96, - disableColumnMenu: true, - resizable: false, - getActions: (params) => [ - { - event.preventDefault(); // Prevent row from going into edit mode - handleConfirmDeleteRow(params.id); - }} - key={`actions[${params.id}].handleDeleteRow`}> - - - ] - } - ]; - - const [deletingObservation, setDeletingObservation] = useState(null); - const showConfirmDeleteDialog = Boolean(deletingObservation); - - useEffect(() => { - if (observationsDataLoader.data?.surveyObservations) { - const rows: IObservationTableRow[] = observationsDataLoader.data.surveyObservations.map( - (row: IObservationTableRow) => ({ - ...row, - id: String(row.survey_observation_id) - }) - ); - - observationsContext.setInitialRows(rows); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [observationsDataLoader.data]); - - const handleCancelDeleteRow = () => { - setDeletingObservation(null); - }; - - const handleConfirmDeleteRow = (id: string | number) => { - setDeletingObservation(id); - }; - - const handleDeleteRow = (id: string | number) => { - observationsContext.markRecordWithUnsavedChanges(id); - apiRef?.current.updateRows([{ id, _action: 'delete' } as GridRowModelUpdate]); - }; - - const handleRowEditStop: GridEventListener<'rowEditStop'> = (_params, event) => { - event.defaultMuiPrevented = true; - }; - - const handleCellClick: GridEventListener<'cellClick'> = (params, event) => { - const { id } = params.row; - - if (apiRef?.current.state.editRows[id]) { - return; - } - - apiRef?.current.startRowEditMode({ id, fieldToFocus: params.field }); - observationsContext.markRecordWithUnsavedChanges(id); - }; - - const handleProcessRowUpdate = (newRow: IObservationTableRow) => { - const updatedRow = { ...newRow, wldtaxonomic_units_id: Number(newRow.wldtaxonomic_units_id) }; - return updatedRow; - }; - - return ( - <> - { - if (deletingObservation) { - handleDeleteRow(deletingObservation); - } - setDeletingObservation(null); - }} - onClose={() => handleCancelDeleteRow()} - onNo={() => handleCancelDeleteRow()} - /> - - - ); -}; - -export default ObservationsTable; diff --git a/app/src/features/surveys/observations/SurveyObservationHeader.tsx b/app/src/features/surveys/observations/SurveyObservationHeader.tsx index 25df800057..e174a3aff0 100644 --- a/app/src/features/surveys/observations/SurveyObservationHeader.tsx +++ b/app/src/features/surveys/observations/SurveyObservationHeader.tsx @@ -1,7 +1,9 @@ import Breadcrumbs from '@mui/material/Breadcrumbs'; +import { grey } from '@mui/material/colors'; +import Link from '@mui/material/Link'; import Paper from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; -import { Link } from 'react-router-dom'; +import { Link as RouterLink } from 'react-router-dom'; export interface SurveyObservationHeaderProps { project_id: number; @@ -12,39 +14,36 @@ export interface SurveyObservationHeaderProps { const SurveyObservationHeader: React.FC = (props) => { const { project_id, survey_id, survey_name } = props; return ( - <> - + + + {survey_name} + + + Manage Observations + + + - - - - {survey_name} - - - - Manage Survey Observations - - - - Manage Survey Observations - - - + Manage Observations + + ); }; diff --git a/app/src/features/surveys/observations/SurveyObservationPage.tsx b/app/src/features/surveys/observations/SurveyObservationPage.tsx index 81cebed2d2..910e69c392 100644 --- a/app/src/features/surveys/observations/SurveyObservationPage.tsx +++ b/app/src/features/surveys/observations/SurveyObservationPage.tsx @@ -1,9 +1,12 @@ import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; import { grey } from '@mui/material/colors'; +import Paper from '@mui/material/Paper'; +import { ObservationsTableContext, ObservationsTableContextProvider } from 'contexts/observationsTableContext'; import { SurveyContext } from 'contexts/surveyContext'; +import { TaxonomyContextProvider } from 'contexts/taxonomyContext'; import { useContext } from 'react'; -import ObservationComponent from './ObservationComponent'; +import ObservationComponent from './observations-table/ObservationComponent'; import SamplingSiteList from './sampling-sites/SamplingSiteList'; import SurveyObservationHeader from './SurveyObservationHeader'; @@ -15,47 +18,48 @@ export const SurveyObservationPage = () => { } return ( - - + + + - - - - {/* Sampling Site List */} {/* Observations Component */} - + + + + {(context) => { + if (!context._muiDataGridApiRef.current) { + return ; + } + + return ; + }} + + + - + ); }; diff --git a/app/src/features/surveys/observations/observations-table/ObservationComponent.tsx b/app/src/features/surveys/observations/observations-table/ObservationComponent.tsx new file mode 100644 index 0000000000..fc371c8320 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/ObservationComponent.tsx @@ -0,0 +1,210 @@ +import { mdiDotsVertical, mdiImport, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import { LoadingButton } from '@mui/lab'; +import { ListItemIcon } from '@mui/material'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Collapse from '@mui/material/Collapse'; +import IconButton from '@mui/material/IconButton'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Stack from '@mui/material/Stack'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import DataGridValidationAlert from 'components/data-grid/DataGridValidationAlert'; +import FileUploadDialog from 'components/dialog/FileUploadDialog'; +import YesNoDialog from 'components/dialog/YesNoDialog'; +import { UploadFileStatus } from 'components/file-upload/FileUploadItem'; +import { ObservationsTableI18N } from 'constants/i18n'; +import { DialogContext, ISnackbarProps } from 'contexts/dialogContext'; +import { ObservationsTableContext } from 'contexts/observationsTableContext'; +import { SurveyContext } from 'contexts/surveyContext'; +import ObservationsTable from 'features/surveys/observations/observations-table/ObservationsTable'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useContext, useState } from 'react'; +import { pluralize as p } from 'utils/Utils'; + +const ObservationComponent = () => { + const [showImportDialog, setShowImportDialog] = useState(false); + const [processingRecords, setProcessingRecords] = useState(false); + const [menuAnchorEl, setMenuAnchorEl] = useState(null); + const [showConfirmRemoveAllDialog, setShowConfirmRemoveAllDialog] = useState(false); + const observationsTableContext = useContext(ObservationsTableContext); + const surveyContext = useContext(SurveyContext); + const biohubApi = useBiohubApi(); + const dialogContext = useContext(DialogContext); + + const showSnackBar = (textDialogProps?: Partial) => { + dialogContext.setSnackbar({ ...textDialogProps, open: true }); + }; + + const handleCloseMenu = () => { + setMenuAnchorEl(null); + }; + + const { projectId, surveyId } = surveyContext; + + const handleImportObservations = async (file: File) => { + return biohubApi.observation.uploadCsvForImport(projectId, surveyId, file).then((response) => { + setShowImportDialog(false); + setProcessingRecords(true); + biohubApi.observation + .processCsvSubmission(projectId, surveyId, response.submissionId) + .then(() => { + showSnackBar({ + snackbarMessage: ( + + Observations imported successfully. + + ) + }); + observationsTableContext.refreshObservationRecords().then(() => { + setProcessingRecords(false); + }); + }) + .catch(() => { + setProcessingRecords(false); + }); + }); + }; + + const { hasUnsavedChanges, validationModel, _muiDataGridApiRef } = observationsTableContext; + const numSelectedRows = observationsTableContext.rowSelectionModel.length; + + return ( + <> + setShowImportDialog(false)} + onUpload={handleImportObservations} + FileUploadProps={{ + dropZoneProps: { maxNumFiles: 1, acceptedFileExtensions: '.csv' }, + status: UploadFileStatus.STAGED + }}> + { + setShowConfirmRemoveAllDialog(false); + observationsTableContext.revertObservationRecords(); + }} + onClose={() => setShowConfirmRemoveAllDialog(false)} + onNo={() => setShowConfirmRemoveAllDialog(false)} + /> + + + + Observations ‌ + + ({observationsTableContext.observationCount}) + + + + + + + + + observationsTableContext.saveObservationRecords()} + disabled={observationsTableContext.isSaving}> + Save + + + + + + ) => { + setMenuAnchorEl(event.currentTarget); + }} + edge="end" + disabled={numSelectedRows === 0} + aria-label="observation options"> + + + + { + observationsTableContext.deleteSelectedObservationRecords(); + handleCloseMenu(); + }} + disabled={observationsTableContext.isSaving}> + + + + Delete {p(numSelectedRows, 'Observation')} + + + + + + + + + + + + + + + + ); +}; + +export default ObservationComponent; diff --git a/app/src/features/surveys/observations/observations-table/ObservationsTable.tsx b/app/src/features/surveys/observations/observations-table/ObservationsTable.tsx new file mode 100644 index 0000000000..ef14ec4cdb --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/ObservationsTable.tsx @@ -0,0 +1,649 @@ +import { mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import { cyan, grey } from '@mui/material/colors'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import { DataGrid, GridCellParams, GridColDef } from '@mui/x-data-grid'; +import AutocompleteDataGridEditCell from 'components/data-grid/autocomplete/AutocompleteDataGridEditCell'; +import AutocompleteDataGridViewCell from 'components/data-grid/autocomplete/AutocompleteDataGridViewCell'; +import ConditionalAutocompleteDataGridEditCell from 'components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell'; +import ConditionalAutocompleteDataGridViewCell from 'components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell'; +import TaxonomyDataGridEditCell from 'components/data-grid/taxonomy/TaxonomyDataGridEditCell'; +import TaxonomyDataGridViewCell from 'components/data-grid/taxonomy/TaxonomyDataGridViewCell'; +import TextFieldDataGrid from 'components/data-grid/TextFieldDataGrid'; +import TimePickerDataGrid from 'components/data-grid/TimePickerDataGrid'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import { CodesContext } from 'contexts/codesContext'; +import { ObservationsContext } from 'contexts/observationsContext'; +import { IObservationTableRow, ObservationsTableContext } from 'contexts/observationsTableContext'; +import { SurveyContext } from 'contexts/surveyContext'; +import { + IGetSampleLocationRecord, + IGetSampleMethodRecord, + IGetSamplePeriodRecord +} from 'interfaces/useSurveyApi.interface'; +import { has } from 'lodash-es'; +import moment from 'moment'; +import { useCallback, useContext, useEffect, useMemo } from 'react'; +import { useLocation } from 'react-router'; +import { getCodesName, getFormattedDate } from 'utils/Utils'; + +type ISampleSiteOption = { + survey_sample_site_id: number; + sample_site_name: string; +}; + +type ISampleMethodOption = { + survey_sample_method_id: number; + survey_sample_site_id: number; + sample_method_name: string; +}; + +type ISamplePeriodOption = { + survey_sample_period_id: number; + survey_sample_method_id: number; + sample_period_name: string; +}; +export interface ISpeciesObservationTableProps { + isLoading?: boolean; +} + +const ObservationsTable = (props: ISpeciesObservationTableProps) => { + const location = useLocation(); + const observationsTableContext = useContext(ObservationsTableContext); + const observationsContext = useContext(ObservationsContext); + const surveyContext = useContext(SurveyContext); + const codesContext = useContext(CodesContext); + const hasLoadedCodes = Boolean(codesContext.codesDataLoader.data); + + const apiRef = observationsTableContext._muiDataGridApiRef; + + const hasError = useCallback( + (params: GridCellParams): boolean => { + return Boolean( + observationsTableContext.validationModel[params.row.id]?.some((error) => { + return error.field === params.field; + }) + ); + }, + [observationsTableContext.validationModel] + ); + + const isLoading = useMemo(() => { + return [ + observationsContext.observationsDataLoader.isLoading && !observationsContext.observationsDataLoader.hasLoaded, + props.isLoading, + surveyContext.sampleSiteDataLoader.isLoading, + observationsTableContext.isLoading, + observationsTableContext.isSaving + ].some(Boolean); + }, [ + observationsContext.observationsDataLoader.isLoading, + observationsContext.observationsDataLoader.hasLoaded, + props.isLoading, + surveyContext.sampleSiteDataLoader.isLoading, + observationsTableContext.isLoading, + observationsTableContext.isSaving + ]); + + // Collect sample sites + const surveySampleSites: IGetSampleLocationRecord[] = surveyContext.sampleSiteDataLoader.data?.sampleSites ?? []; + const sampleSiteOptions: ISampleSiteOption[] = + surveySampleSites.map((site) => ({ + survey_sample_site_id: site.survey_sample_site_id, + sample_site_name: site.name + })) ?? []; + + // Collect sample methods + const surveySampleMethods: IGetSampleMethodRecord[] = surveySampleSites + .filter((sampleSite) => Boolean(sampleSite.sample_methods)) + .map((sampleSite) => sampleSite.sample_methods as IGetSampleMethodRecord[]) + .flat(2); + const sampleMethodOptions: ISampleMethodOption[] = hasLoadedCodes + ? surveySampleMethods.map((method) => ({ + survey_sample_method_id: method.survey_sample_method_id, + survey_sample_site_id: method.survey_sample_site_id, + sample_method_name: + getCodesName(codesContext.codesDataLoader.data, 'sample_methods', method.method_lookup_id) ?? '' + })) + : []; + + // Collect sample periods + const samplePeriodOptions: ISamplePeriodOption[] = surveySampleMethods + .filter((sampleMethod) => Boolean(sampleMethod.sample_periods)) + .map((sampleMethod) => sampleMethod.sample_periods as IGetSamplePeriodRecord[]) + .flat(2) + .map((samplePeriod: IGetSamplePeriodRecord) => ({ + survey_sample_period_id: samplePeriod.survey_sample_period_id, + survey_sample_method_id: samplePeriod.survey_sample_method_id, + sample_period_name: `${samplePeriod.start_date} ${samplePeriod.start_time || ''} - ${samplePeriod.end_date} ${ + samplePeriod.end_time || '' + }` + })); + + const observationColumns: GridColDef[] = [ + { + field: 'wldtaxonomic_units_id', + headerName: 'Species', + editable: true, + flex: 1, + minWidth: 250, + disableColumnMenu: true, + headerAlign: 'left', + align: 'left', + valueSetter: (params) => { + return { ...params.row, wldtaxonomic_units_id: Number(params.value) }; + }, + renderCell: (params) => { + return ; + }, + renderEditCell: (params) => { + return ; + } + }, + { + field: 'survey_sample_site_id', + headerName: 'Sampling Site', + editable: true, + flex: 1, + minWidth: 250, + disableColumnMenu: true, + headerAlign: 'left', + align: 'left', + renderCell: (params) => { + return ( + + dataGridProps={params} + options={sampleSiteOptions.map((item) => ({ + label: item.sample_site_name, + value: item.survey_sample_site_id + }))} + error={hasError(params)} + /> + ); + }, + renderEditCell: (params) => { + return ( + + dataGridProps={params} + options={sampleSiteOptions.map((item) => ({ + label: item.sample_site_name, + value: item.survey_sample_site_id + }))} + error={hasError(params)} + /> + ); + } + }, + { + field: 'survey_sample_method_id', + headerName: 'Sampling Method', + editable: true, + flex: 1, + minWidth: 250, + disableColumnMenu: true, + headerAlign: 'left', + align: 'left', + renderCell: (params) => { + return ( + + dataGridProps={params} + optionsGetter={(row, allOptions) => { + return allOptions + .filter((item) => item.survey_sample_site_id === row.survey_sample_site_id) + .map((item) => ({ label: item.sample_method_name, value: item.survey_sample_method_id })); + }} + allOptions={sampleMethodOptions} + error={hasError(params)} + /> + ); + }, + renderEditCell: (params) => { + return ( + + dataGridProps={params} + optionsGetter={(row, allOptions) => { + return allOptions + .filter((item) => item.survey_sample_site_id === row.survey_sample_site_id) + .map((item) => ({ label: item.sample_method_name, value: item.survey_sample_method_id })); + }} + allOptions={sampleMethodOptions} + error={hasError(params)} + /> + ); + } + }, + { + field: 'survey_sample_period_id', + headerName: 'Sampling Period', + editable: true, + flex: 0, + width: 250, + disableColumnMenu: true, + headerAlign: 'left', + align: 'left', + renderCell: (params) => { + return ( + + dataGridProps={params} + optionsGetter={(row, allOptions) => { + return allOptions + .filter((item) => item.survey_sample_method_id === row.survey_sample_method_id) + .map((item) => ({ + label: item.sample_period_name, + value: item.survey_sample_period_id + })); + }} + allOptions={samplePeriodOptions} + error={hasError(params)} + /> + ); + }, + renderEditCell: (params) => { + return ( + + dataGridProps={params} + optionsGetter={(row, allOptions) => { + return allOptions + .filter((item) => item.survey_sample_method_id === row.survey_sample_method_id) + .map((item) => ({ + label: item.sample_period_name, + value: item.survey_sample_period_id + })); + }} + allOptions={samplePeriodOptions} + error={hasError(params)} + /> + ); + } + }, + { + field: 'count', + headerName: 'Count', + editable: true, + type: 'number', + minWidth: 110, + disableColumnMenu: true, + headerAlign: 'right', + align: 'right', + renderCell: (params) => ( + + {params.value} + + ), + renderEditCell: (params) => { + const error: boolean = hasError(params); + + return ( + { + if (!/^\d{0,7}$/.test(event.target.value)) { + // If the value is not a number, return + return; + } + + apiRef?.current.setEditCellValue({ + id: params.id, + field: params.field, + value: event.target.value + }); + }, + error + }} + /> + ); + } + }, + { + field: 'observation_date', + headerName: 'Date', + editable: true, + type: 'date', + minWidth: 150, + valueGetter: (params) => (params.row.observation_date ? moment(params.row.observation_date).toDate() : null), + disableColumnMenu: true, + headerAlign: 'left', + align: 'left', + renderCell: (params) => ( + + {getFormattedDate(DATE_FORMAT.ShortDateFormatMonthFirst, params.value)} + + ), + renderEditCell: (params) => { + const error = hasError(params); + + return ( + { + const value = moment(event.target.value).toDate(); + apiRef?.current.setEditCellValue({ + id: params.id, + field: params.field, + value + }); + }, + + error + }} + /> + ); + } + }, + { + field: 'observation_time', + headerName: 'Time', + editable: true, + type: 'string', + width: 150, + disableColumnMenu: true, + headerAlign: 'right', + align: 'right', + valueSetter: (params) => { + return { ...params.row, observation_time: params.value }; + }, + valueParser: (value) => { + if (!value) { + return null; + } + + if (moment.isMoment(value)) { + return value.format('HH:mm:ss'); + } + + return moment(value, 'HH:mm:ss').format('HH:mm:ss'); + }, + renderCell: (params) => { + if (!params.value) { + return null; + } + + return ( + + {params.value} + + ); + }, + renderEditCell: (params) => { + const error = hasError(params); + + return ( + + ); + } + }, + { + field: 'latitude', + headerName: 'Lat', + editable: true, + width: 120, + disableColumnMenu: true, + headerAlign: 'right', + align: 'right', + valueSetter: (params) => { + if (/^-?\d{1,3}(?:\.\d{0,12})?$/.test(params.value)) { + // If the value is a legal latitude value + // Valid entries: `-1`, `-1.1`, `-123.456789` `1`, `1.1, `123.456789` + return { ...params.row, latitude: Number(params.value) }; + } + + const value = parseFloat(params.value); + return { ...params.row, latitude: isNaN(value) ? null : value }; + }, + renderCell: (params) => ( + + {params.value} + + ), + renderEditCell: (params) => { + const error: boolean = hasError(params); + + return ( + { + if (!/^-?\d{0,3}(?:\.\d{0,12})?$/.test(event.target.value)) { + // If the value is not a subset of a legal latitude value, prevent the value from being applied + return; + } + + apiRef?.current.setEditCellValue({ + id: params.id, + field: params.field, + value: event.target.value + }); + }, + error + }} + /> + ); + } + }, + { + field: 'longitude', + headerName: 'Long', + editable: true, + width: 120, + disableColumnMenu: true, + headerAlign: 'right', + align: 'right', + valueSetter: (params) => { + if (/^-?\d{1,3}(?:\.\d{0,12})?$/.test(params.value)) { + // If the value is a legal longitude value + // Valid entries: `-1`, `-1.1`, `-123.456789` `1`, `1.1, `123.456789` + return { ...params.row, longitude: Number(params.value) }; + } + + const value = parseFloat(params.value); + return { ...params.row, longitude: isNaN(value) ? null : value }; + }, + renderCell: (params) => ( + + {params.value} + + ), + renderEditCell: (params) => { + const error: boolean = hasError(params); + + return ( + { + if (!/^-?\d{0,3}(?:\.\d{0,12})?$/.test(event.target.value)) { + // If the value is not a subset of a legal longitude value, prevent the value from being applied + return; + } + + apiRef?.current.setEditCellValue({ + id: params.id, + field: params.field, + value: event.target.value + }); + }, + error + }} + /> + ); + } + }, + { + field: 'actions', + headerName: '', + type: 'actions', + width: 70, + disableColumnMenu: true, + resizable: false, + cellClassName: 'pinnedColumn', + getActions: (params) => [ + observationsTableContext.deleteObservationRecords([params.row])} + disabled={observationsTableContext.isSaving} + key={`actions[${params.id}].handleDeleteRow`}> + + + ] + } + ]; + + /** + * On first render, pre-selected the observation row based on the URL + */ + useEffect(() => { + if (location.hash.startsWith('#view-')) { + const selectedId = location.hash.split('-')[1]; + observationsTableContext.onRowSelectionModelChange([selectedId]); + } + }, [location.hash, observationsTableContext]); + + return ( + <> + {isLoading && } + + observationsTableContext.onRowEditStart(params.id)} + onRowEditStop={(_params, event) => { + event.defaultMuiPrevented = true; + }} + localeText={{ + noRowsLabel: 'No Records' + }} + onRowSelectionModelChange={observationsTableContext.onRowSelectionModelChange} + rowSelectionModel={observationsTableContext.rowSelectionModel} + getRowHeight={() => 'auto'} + getRowClassName={(params) => (has(observationsTableContext.validationModel, params.row.id) ? 'error' : '')} + sx={{ + background: '#fff', + border: 'none', + borderTop: '1px solid ' + grey[300], + borderRadius: 0, + '&:after': { + content: '" "', + position: 'absolute', + top: 0, + right: 0, + width: 100, + height: 55, + background: 'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,1) 50%)' + }, + '& .pinnedColumn': { + position: 'sticky', + right: 0, + top: 0, + borderLeft: '1px solid' + grey[300] + }, + '& .MuiDataGrid-columnHeaders': { + position: 'relative' + }, + '& .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': { + 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' + } + }, + '& .MuiOutlinedInput-notchedOutline': { + border: '1px solid ' + grey[300], + '&.Mui-focused': { + borderColor: 'primary.main' + } + }, + '& .MuiDataGrid-virtualScrollerContent': { + background: '#fff' + }, + '& .MuiDataGrid-footerContainer': { + background: '#fff' + } + }} + /> + + ); +}; + +export default ObservationsTable; diff --git a/app/src/features/surveys/observations/sampling-sites/SamplingSiteHeader.tsx b/app/src/features/surveys/observations/sampling-sites/SamplingSiteHeader.tsx index 27fc44df1a..9bf3730a9e 100644 --- a/app/src/features/surveys/observations/sampling-sites/SamplingSiteHeader.tsx +++ b/app/src/features/surveys/observations/sampling-sites/SamplingSiteHeader.tsx @@ -2,25 +2,16 @@ import { LoadingButton } from '@mui/lab'; import Box from '@mui/material/Box'; 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 { Theme } from '@mui/material/styles'; import Typography from '@mui/material/Typography'; -import { makeStyles } from '@mui/styles'; import { useFormikContext } from 'formik'; import { useHistory } from 'react-router'; import { Link as RouterLink } from 'react-router-dom'; import { ICreateSamplingSiteRequest } from './SamplingSitePage'; -const useStyles = makeStyles((theme: Theme) => ({ - actionButton: { - minWidth: '6rem', - '& + button': { - marginLeft: '0.5rem' - } - } -})); - export interface ISamplingSiteHeaderProps { project_id: number; survey_id: number; @@ -30,7 +21,6 @@ export interface ISamplingSiteHeaderProps { breadcrumb: string; } export const SamplingSiteHeader: React.FC = (props) => { - const classes = useStyles(); const history = useHistory(); const formikProps = useFormikContext(); @@ -41,63 +31,70 @@ export const SamplingSiteHeader: React.FC = (props) => square elevation={0} sx={{ + position: 'sticky', + top: 0, + zIndex: 1002, pt: 3, - pb: 3, - px: 3 + pb: 3.75, + borderBottomStyle: 'solid', + borderBottomWidth: '1px', + borderBottomColor: grey[300] }}> - - - - {survey_name} - - - - - Manage Survey Observations + + + + {survey_name} + + + Manage Survey Observations + + {breadcrumb} + + + + {title} - - - {breadcrumb} - - - - - {title} - - - { - formikProps.submitForm(); - }} - className={classes.actionButton}> - Save and Exit - - + + { + formikProps.submitForm(); + }}> + Save and Exit + + + - +
); diff --git a/app/src/features/surveys/observations/sampling-sites/SamplingSiteList.tsx b/app/src/features/surveys/observations/sampling-sites/SamplingSiteList.tsx index ee74e4a52e..05717eb007 100644 --- a/app/src/features/surveys/observations/sampling-sites/SamplingSiteList.tsx +++ b/app/src/features/surveys/observations/sampling-sites/SamplingSiteList.tsx @@ -1,11 +1,18 @@ -import { mdiDotsVertical, mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import { + mdiCalendarRange, + mdiChevronDown, + mdiDotsVertical, + mdiPencilOutline, + mdiPlus, + mdiTrashCanOutline +} from '@mdi/js'; import Icon from '@mdi/react'; +import { Paper } from '@mui/material'; 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 CircularProgress from '@mui/material/CircularProgress'; import { grey } from '@mui/material/colors'; import IconButton from '@mui/material/IconButton'; import List from '@mui/material/List'; @@ -16,8 +23,11 @@ import Menu, { MenuProps } from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; +import { SkeletonList } from 'components/loading/SkeletonLoaders'; import { CodesContext } from 'contexts/codesContext'; +import { DialogContext } from 'contexts/dialogContext'; import { SurveyContext } from 'contexts/surveyContext'; +import { useBiohubApi } from 'hooks/useBioHubApi'; import { useContext, useEffect, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { getCodesName } from 'utils/Utils'; @@ -25,12 +35,17 @@ import { getCodesName } from 'utils/Utils'; const SamplingSiteList = () => { const surveyContext = useContext(SurveyContext); const codesContext = useContext(CodesContext); + const dialogContext = useContext(DialogContext); + const biohubApi = useBiohubApi(); useEffect(() => { codesContext.codesDataLoader.load(); }, [codesContext.codesDataLoader]); - surveyContext.sampleSiteDataLoader.load(surveyContext.projectId, surveyContext.surveyId); + useEffect(() => { + surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const [anchorEl, setAnchorEl] = useState(null); const [selectedSampleSiteId, setSelectedSampleSiteId] = useState(); @@ -40,14 +55,65 @@ const SamplingSiteList = () => { setSelectedSampleSiteId(sample_site_id); }; - if ( - !surveyContext.sampleSiteDataLoader.data || - (surveyContext.sampleSiteDataLoader.isLoading && !codesContext.codesDataLoader.data) || - codesContext.codesDataLoader.isLoading - ) { - // TODO Fix styling: spinner loads in the corner of the component - return ; - } + /** + * 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 }); + setAnchorEl(null); + surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + }) + .catch((error: any) => { + dialogContext.setYesNoDialog({ open: false }); + setAnchorEl(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 samplingSiteCount = surveyContext.sampleSiteDataLoader.data?.sampleSites.length ?? 0; return ( <> @@ -63,30 +129,50 @@ const SamplingSiteList = () => { vertical: 'top', horizontal: 'right' }}> - - - - - Edit Details + + + + + + Edit Details + - console.log('DELETE THIS SAMPLING SITE')}> + - Remove + Delete - Sampling Sites + Sampling Sites ‌ + + ({samplingSiteCount}) + - - - {!surveyContext.sampleSiteDataLoader.data.sampleSites.length && ( - - No Sampling Sites - - )} - - {surveyContext.sampleSiteDataLoader.data.sampleSites.map((sampleSite, index) => { - return ( - - - - - {sampleSite.name} - - - ) => - handleMenuClick(event, sampleSite.survey_sample_site_id) - } - aria-label="settings"> - - + + {surveyContext.sampleSiteDataLoader.isLoading || codesContext.codesDataLoader.isLoading ? ( + + + + + + ) : ( + + {/* Display text if the sample site data loader has no items in it */} + {!surveyContext.sampleSiteDataLoader.data?.sampleSites.length && + !surveyContext.sampleSiteDataLoader.isLoading && ( + + No Sampling Sites - { + return ( + - - {sampleSite.sample_methods?.map((sampleMethod) => { - return ( - - - + } + aria-controls="panel1bh-content" + sx={{ + flex: '1 1 auto', + overflow: 'hidden', + py: 0.25, + pr: 1.5, + pl: 2, + gap: '24px', + '& .MuiAccordionSummary-content': { + flex: '1 1 auto', + overflow: 'hidden', + whiteSpace: 'nowrap' + } + }}> + + {sampleSite.name} + + + ) => + handleMenuClick(event, sampleSite.survey_sample_site_id) + } + aria-label="settings"> + + + + + + {sampleSite.sample_methods?.map((sampleMethod) => { + return ( + + - {getCodesName( + title="Sampling Method" + primary={getCodesName( codesContext.codesDataLoader.data, 'sample_methods', sampleMethod.method_lookup_id - )} - + )}> {sampleMethod.sample_periods?.map((samplePeriod) => { return ( + + + - - {samplePeriod.start_date} to {samplePeriod.end_date} + + {`${samplePeriod.start_date} ${samplePeriod.start_time ?? ''} - ${ + samplePeriod.end_date + } ${samplePeriod.end_time ?? ''}`} ); })} - - - ); - })} - - - - ); - })} - + + ); + })} + + + + ); + })} + + )}
diff --git a/app/src/features/surveys/observations/sampling-sites/SamplingSitePage.tsx b/app/src/features/surveys/observations/sampling-sites/SamplingSitePage.tsx index 2ec5123d5a..47b51aff8b 100644 --- a/app/src/features/surveys/observations/sampling-sites/SamplingSitePage.tsx +++ b/app/src/features/surveys/observations/sampling-sites/SamplingSitePage.tsx @@ -40,11 +40,15 @@ const useStyles = makeStyles((theme: Theme) => ({ } })); -export interface ICreateSamplingSiteRequest { +export interface ISurveySampleSite { name: string; description: string; + feature: Feature; +} + +export interface ICreateSamplingSiteRequest { survey_id: number; - survey_sample_sites: Feature[]; // extracted list from shape files + survey_sample_sites: ISurveySampleSite[]; // extracted list from shape files methods: ISurveySampleMethodData[]; } @@ -66,9 +70,11 @@ const SamplingSitePage = () => { } const samplingSiteYupSchema = yup.object({ - name: yup.string().default(''), - description: yup.string().default(''), - survey_sample_sites: yup.array(yup.object()).min(1, 'At least one sampling site location is required'), + survey_sample_sites: yup + .array( + yup.object({ name: yup.string().default(''), description: yup.string().default(''), feature: yup.object({}) }) + ) + .min(1, 'At least one sampling site location is required'), methods: yup .array(yup.object().concat(SamplingSiteMethodYupSchema)) .min(1, 'At least one sampling method is required') @@ -179,7 +185,7 @@ const SamplingSitePage = () => { survey_id={surveyContext.surveyId} survey_name={surveyContext.surveyDataLoader.data.surveyData.survey_details.survey_name} is_submitting={isSubmitting} - title="New Sampling Site" + title="Add Sampling Site" breadcrumb="Add Sampling Sites" /> diff --git a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteMapControl.tsx b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteMapControl.tsx index 3770a8af7c..9e760228e9 100644 --- a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteMapControl.tsx +++ b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteMapControl.tsx @@ -9,23 +9,29 @@ import Paper from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; import { makeStyles } from '@mui/styles'; import FileUpload from 'components/file-upload/FileUpload'; -import FileUploadItem, { IUploadHandler } from 'components/file-upload/FileUploadItem'; -import MapContainer from 'components/map/MapContainer'; +import FileUploadItem from 'components/file-upload/FileUploadItem'; +import BaseLayerControls from 'components/map/components/BaseLayerControls'; +import { SetMapBounds } from 'components/map/components/Bounds'; +import FullScreenScrollingEventHandler from 'components/map/components/FullScreenScrollingEventHandler'; +import StaticLayers from 'components/map/components/StaticLayers'; +import { MapBaseCss } from 'components/map/styles/MapBaseCss'; +import { MAP_DEFAULT_CENTER, MAP_DEFAULT_ZOOM } from 'constants/spatial'; +import { SurveyContext } from 'contexts/surveyContext'; import SampleSiteFileUploadItemActionButton from 'features/surveys/observations/sampling-sites/components/SampleSiteFileUploadItemActionButton'; import SampleSiteFileUploadItemProgressBar from 'features/surveys/observations/sampling-sites/components/SampleSiteFileUploadItemProgressBar'; import SampleSiteFileUploadItemSubtext from 'features/surveys/observations/sampling-sites/components/SampleSiteFileUploadItemSubtext'; import { FormikContextType } from 'formik'; import { Feature } from 'geojson'; import { LatLngBoundsExpression } from 'leaflet'; +import 'leaflet-fullscreen/dist/leaflet.fullscreen.css'; +import 'leaflet-fullscreen/dist/Leaflet.fullscreen.js'; +import 'leaflet/dist/leaflet.css'; import get from 'lodash-es/get'; -import { useEffect, useState } from 'react'; -import { - calculateUpdatedMapBounds, - handleGPXUpload, - handleKMLUpload, - handleShapeFileUpload -} from 'utils/mapBoundaryUploadHelpers'; -import { pluralize } from 'utils/Utils'; +import { useContext, useEffect, useMemo, useState } from 'react'; +import { LayersControl, MapContainer as LeafletMapContainer } from 'react-leaflet'; +import { boundaryUploadHelper, calculateUpdatedMapBounds } from 'utils/mapBoundaryUploadHelpers'; +import { pluralize, shapeFileFeatureDesc, shapeFileFeatureName } from 'utils/Utils'; +import { ISurveySampleSite } from '../SamplingSitePage'; const useStyles = makeStyles(() => ({ zoomToBoundaryExtentBtn: { @@ -57,30 +63,23 @@ export interface ISamplingSiteMapControlProps { const SamplingSiteMapControl = (props: ISamplingSiteMapControlProps) => { const classes = useStyles(); + const surveyContext = useContext(SurveyContext); + const { name, mapId, formikProps } = props; - const { values, errors, setFieldValue } = formikProps; + const { values, errors, setFieldValue, setFieldError } = formikProps; const [updatedBounds, setUpdatedBounds] = useState(undefined); - const boundaryUploadHandler = (): IUploadHandler => { - return async (file) => { - if (file?.type.includes('zip') || file?.name.includes('.zip')) { - await handleShapeFileUpload(file, name, formikProps); - } else if (file?.type.includes('gpx') || file?.name.includes('.gpx')) { - await handleGPXUpload(file, name, formikProps); - } else if (file?.type.includes('kml') || file?.name.includes('.kml')) { - await handleKMLUpload(file, name, formikProps); - } - }; - }; - const removeFile = () => { setFieldValue(name, []); }; // Array of sampling site features - const samplingSiteGeoJsonFeatures: Feature[] = get(values, name); + const samplingSiteGeoJsonFeatures: Feature[] = useMemo( + () => get(values, name).map((site: ISurveySampleSite) => site.feature), + [name, values] + ); useEffect(() => { setUpdatedBounds(calculateUpdatedMapBounds(samplingSiteGeoJsonFeatures)); @@ -101,7 +100,22 @@ const SamplingSiteMapControl = (props: ISamplingSiteMapControlProps) => { )} { + let numSites = surveyContext.sampleSiteDataLoader.data?.sampleSites.length ?? 0; + setFieldValue( + name, + features.map((feature) => ({ + name: shapeFileFeatureName(feature) ?? `Sample Site ${++numSites}`, + description: shapeFileFeatureDesc(feature) ?? '', + feature: feature + })) + ); + }, + onFailure: (message: string) => { + setFieldError(name, message); + } + })} onRemove={removeFile} dropZoneProps={{ maxNumFiles: 1, @@ -131,17 +145,34 @@ const SamplingSiteMapControl = (props: ISamplingSiteMapControlProps) => { - ({ geoJSON: feature })) - } - ]} - onDrawChange={(newGeo: Feature[]) => setFieldValue(name, newGeo)} - bounds={updatedBounds} - /> + + + {/* Allow scroll wheel zoom when in full screen mode */} + + + {/* Programmatically set map bounds */} + + + + ({ geoJSON: feature })) + } + ]} + /> + + + {samplingSiteGeoJsonFeatures.length > 0 && ( { useEffect(() => { if (surveyContext.sampleSiteDataLoader.data) { const data = surveyContext.sampleSiteDataLoader.data.sampleSites.find( - (x) => x.survey_sample_site_id === surveySampleSiteId + (sampleSite) => sampleSite.survey_sample_site_id === surveySampleSiteId ); if (data !== undefined) { @@ -90,9 +90,9 @@ const SamplingSiteEditPage = () => { }; const handleSubmit = async (values: IEditSamplingSiteRequest) => { - setIsSubmitting(true); - try { + setIsSubmitting(true); + // create edit request const editSampleSite: IEditSamplingSiteRequest = { sampleSite: { @@ -106,17 +106,39 @@ const SamplingSiteEditPage = () => { }; // send edit request - await biohubApi.samplingSite.editSampleSite( - surveyContext.projectId, - surveyContext.surveyId, - surveySampleSiteId, - editSampleSite - ); - - // Disable cancel prompt so we can navigate away from the page after saving - setEnableCancelCheck(false); - // create complete, navigate back to observations page - history.push(`/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/observations`); + await biohubApi.samplingSite + .editSampleSite(surveyContext.projectId, surveyContext.surveyId, surveySampleSiteId, editSampleSite) + .then(() => { + // Disable cancel prompt so we can navigate away from the page after saving + setEnableCancelCheck(false); + setIsSubmitting(false); + + // Refresh the context, so the next page loads with the latest data + surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + + // create complete, navigate back to observations page + history.push(`/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/observations`); + surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + }) + .catch((error: any) => { + dialogContext.setYesNoDialog({ open: false }); + dialogContext.setSnackbar({ + snackbarMessage: ( + <> + + Error Submitting Sampling Site + + + {String(error)} + + + ), + open: true + }); + setIsSubmitting(false); + + return; + }); } catch (error) { showCreateErrorDialog({ dialogTitle: CreateSamplingSiteI18N.createErrorTitle, @@ -124,8 +146,6 @@ const SamplingSiteEditPage = () => { dialogError: (error as APIError).message, dialogErrorDetails: (error as APIError)?.errors }); - } finally { - setIsSubmitting(false); } }; @@ -179,27 +199,16 @@ const SamplingSiteEditPage = () => { enableReinitialize onSubmit={handleSubmit}> - - ${initialFormData.sampleSite.name}`} - breadcrumb="Edit Sampling Sites" - /> - - - - + + +
diff --git a/app/src/features/surveys/observations/sampling-sites/edit/components/SampleMethodEditForm.tsx b/app/src/features/surveys/observations/sampling-sites/edit/components/SampleMethodEditForm.tsx index d271d3c677..1c720d1fb1 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/components/SampleMethodEditForm.tsx +++ b/app/src/features/surveys/observations/sampling-sites/edit/components/SampleMethodEditForm.tsx @@ -10,6 +10,7 @@ import CardContent from '@mui/material/CardContent'; import CardHeader from '@mui/material/CardHeader'; import Collapse from '@mui/material/Collapse'; import { grey } from '@mui/material/colors'; +import Divider from '@mui/material/Divider'; import IconButton from '@mui/material/IconButton'; import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; @@ -17,6 +18,7 @@ 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 Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import { CodesContext } from 'contexts/codesContext'; import CreateSamplingMethod from 'features/surveys/components/CreateSamplingMethod'; @@ -68,9 +70,11 @@ const SampleMethodEditForm = (props: SampleMethodEditFormProps) => { onSubmit={(data) => { setFieldValue(`${name}[${values.sampleSite.methods.length}]`, data); validateField(`${name}`); + setAnchorEl(null); setIsCreateModalOpen(false); }} onClose={() => { + setAnchorEl(null); setIsCreateModalOpen(false); }} /> @@ -81,9 +85,11 @@ const SampleMethodEditForm = (props: SampleMethodEditFormProps) => { open={isEditModalOpen} onSubmit={(data, index) => { setFieldValue(`${name}[${index}]`, data); + setAnchorEl(null); setIsEditModalOpen(false); }} onClose={() => { + setAnchorEl(null); setIsEditModalOpen(false); }} /> @@ -100,7 +106,11 @@ const SampleMethodEditForm = (props: SampleMethodEditFormProps) => { vertical: 'top', horizontal: 'right' }}> - setIsEditModalOpen(true)}> + { + setIsEditModalOpen(true); + }}> @@ -121,7 +131,7 @@ const SampleMethodEditForm = (props: SampleMethodEditFormProps) => { variant="body1" color="textSecondary" sx={{ - mb: 2, + mb: 3, maxWidth: '92ch' }}> Methods added here will be applied to ALL sampling locations. These can be modified later if required. @@ -136,82 +146,99 @@ const SampleMethodEditForm = (props: SampleMethodEditFormProps) => { {errors.sampleSite.methods} )} - - {values.sampleSite.methods.map((item, index) => ( - - - ) => - handleMenuClick(event, index) - } - aria-label="settings"> - - - } - /> - + + {values.sampleSite.methods.map((item, index) => ( + + - - Time Periods - - - {item.periods.map((period) => ( - - - - - - - ))} - - - - - ))} - - + ) => + handleMenuClick(event, index) + } + aria-label="settings"> + + + } + /> + + + {item.description && ( + + {item.description} + + )} + + + + Time Periods + + + + {item.periods.map((period) => ( + + + + + + + ))} + + + + + + + ))} + + + diff --git a/app/src/features/surveys/observations/sampling-sites/edit/components/SampleSiteEditForm.tsx b/app/src/features/surveys/observations/sampling-sites/edit/components/SampleSiteEditForm.tsx index faaebfb6c2..f181db2af5 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/components/SampleSiteEditForm.tsx +++ b/app/src/features/surveys/observations/sampling-sites/edit/components/SampleSiteEditForm.tsx @@ -3,13 +3,11 @@ 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 { Theme } from '@mui/material/styles'; -import { makeStyles } from '@mui/styles'; import { Container } from '@mui/system'; import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; import { SurveyContext } from 'contexts/surveyContext'; import { ISurveySampleMethodData, SamplingSiteMethodYupSchema } from 'features/surveys/components/MethodForm'; -import { FormikProps } from 'formik'; +import { useFormikContext } from 'formik'; import { Feature } from 'geojson'; import { useContext } from 'react'; import { Link as RouterLink } from 'react-router-dom'; @@ -18,20 +16,6 @@ import SampleMethodEditForm from './SampleMethodEditForm'; import SampleSiteGeneralInformationForm from './SampleSiteGeneralInformationForm'; import SurveySamplingSiteEditForm from './SurveySampleSiteEditForm'; -const useStyles = makeStyles((theme: Theme) => ({ - actionButton: { - minWidth: '6rem', - '& + button': { - marginLeft: '0.5rem' - } - }, - sectionDivider: { - height: '1px', - marginTop: theme.spacing(5), - marginBottom: theme.spacing(5) - } -})); - export interface IEditSamplingSiteRequest { sampleSite: { name: string; @@ -43,15 +27,13 @@ export interface IEditSamplingSiteRequest { }; } -export interface ISampleSiteEditForm { - handleSubmit: (formikData: IEditSamplingSiteRequest) => void; - formikRef: React.RefObject>; +export interface ISampleSiteEditFormProps { isSubmitting: boolean; } export const samplingSiteYupSchema = yup.object({ sampleSite: yup.object({ - name: yup.string().default(''), + name: yup.string().default('').max(50, 'Maximum 50 characters.'), description: yup.string().default('').nullable(), survey_sample_sites: yup .array(yup.object()) @@ -63,10 +45,9 @@ export const samplingSiteYupSchema = yup.object({ }) }); -const SampleSiteEditForm: React.FC = (props) => { - const classes = useStyles(); - +const SampleSiteEditForm = (props: ISampleSiteEditFormProps) => { const surveyContext = useContext(SurveyContext); + const { submitForm } = useFormikContext(); return ( <> @@ -75,49 +56,60 @@ const SampleSiteEditForm: React.FC = (props) => { }> - + }> - + }> - + - { - props.formikRef.current?.submitForm(); - }} - className={classes.actionButton}> - Save and Exit - - + + submitForm()}> + Save and Exit + + + diff --git a/app/src/features/surveys/observations/sampling-sites/edit/components/SampleSiteGeneralInformationForm.tsx b/app/src/features/surveys/observations/sampling-sites/edit/components/SampleSiteGeneralInformationForm.tsx index c700fbe603..91db456d78 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/components/SampleSiteGeneralInformationForm.tsx +++ b/app/src/features/surveys/observations/sampling-sites/edit/components/SampleSiteGeneralInformationForm.tsx @@ -15,9 +15,7 @@ const SampleSiteGeneralInformationForm: React.FC = (props) => { diff --git a/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingSiteEditMapControl.tsx b/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingSiteEditMapControl.tsx index f0ca80adbc..2ac92ce92b 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingSiteEditMapControl.tsx +++ b/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingSiteEditMapControl.tsx @@ -9,9 +9,13 @@ import Paper from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; import { makeStyles } from '@mui/styles'; import FileUpload from 'components/file-upload/FileUpload'; -import FileUploadItem, { IUploadHandler } from 'components/file-upload/FileUploadItem'; -import { IStaticLayer } from 'components/map/components/StaticLayers'; -import MapContainer from 'components/map/MapContainer'; +import FileUploadItem from 'components/file-upload/FileUploadItem'; +import BaseLayerControls from 'components/map/components/BaseLayerControls'; +import { SetMapBounds } from 'components/map/components/Bounds'; +import FullScreenScrollingEventHandler from 'components/map/components/FullScreenScrollingEventHandler'; +import StaticLayers, { IStaticLayer } from 'components/map/components/StaticLayers'; +import { MapBaseCss } from 'components/map/styles/MapBaseCss'; +import { MAP_DEFAULT_CENTER, MAP_DEFAULT_ZOOM } from 'constants/spatial'; import { SurveyContext } from 'contexts/surveyContext'; import SampleSiteFileUploadItemActionButton from 'features/surveys/observations/sampling-sites/components/SampleSiteFileUploadItemActionButton'; import SampleSiteFileUploadItemProgressBar from 'features/surveys/observations/sampling-sites/components/SampleSiteFileUploadItemProgressBar'; @@ -21,13 +25,9 @@ import { Feature } from 'geojson'; import { LatLngBoundsExpression } from 'leaflet'; import get from 'lodash-es/get'; import { useContext, useEffect, useState } from 'react'; +import { LayersControl, MapContainer as LeafletMapContainer } from 'react-leaflet'; import { useParams } from 'react-router'; -import { - calculateUpdatedMapBounds, - handleGPXUpload, - handleKMLUpload, - handleShapeFileUpload -} from 'utils/mapBoundaryUploadHelpers'; +import { boundaryUploadHelper, calculateUpdatedMapBounds } from 'utils/mapBoundaryUploadHelpers'; import { pluralize } from 'utils/Utils'; const useStyles = makeStyles(() => ({ @@ -73,18 +73,6 @@ const SamplingSiteEditMapControl = (props: ISamplingSiteEditMapControlProps) => const [updatedBounds, setUpdatedBounds] = useState(undefined); const [staticLayers, setStaticLayers] = useState([]); - const boundaryUploadHandler = (): IUploadHandler => { - return async (file) => { - if (file?.type.includes('zip') || file?.name.includes('.zip')) { - await handleShapeFileUpload(file, name, formikProps); - } else if (file?.type.includes('gpx') || file?.name.includes('.gpx')) { - await handleGPXUpload(file, name, formikProps); - } else if (file?.type.includes('kml') || file?.name.includes('.kml')) { - await handleKMLUpload(file, name, formikProps); - } - }; - }; - const removeFile = () => { setFieldValue(name, sampleSiteData?.geojson ? [sampleSiteData?.geojson] : []); setFieldError(name, undefined); @@ -110,7 +98,14 @@ const SamplingSiteEditMapControl = (props: ISamplingSiteEditMapControlProps) => { + setFieldValue(name, [...features]); + }, + onFailure: (message: string) => { + setFieldError(name, message); + } + })} onRemove={removeFile} dropZoneProps={{ maxNumFiles: 1, @@ -150,12 +145,23 @@ const SamplingSiteEditMapControl = (props: ISamplingSiteEditMapControlProps) => )} - setFieldValue(name, newGeo)} - bounds={updatedBounds} - /> + + + + + + + + + {samplingSiteGeoJsonFeatures.length > 0 && ( , id: number) => void; +} + +const ManualTelemetryCard = (props: ManualTelemetryCardProps) => { + return ( + + + } + sx={{ + flex: '1 1 auto', + overflow: 'hidden', + py: 0.25, + pr: 1.5, + pl: 2, + gap: '24px', + '& .MuiAccordionSummary-content': { + flex: '1 1 auto', + overflow: 'hidden', + whiteSpace: 'nowrap' + } + }}> + + + {props.name} + + + Device ID: {props.device_id} + + + + ) => props.onMenu(event, props.device_id)} + aria-label="settings"> + + + + + + + + {moment(props.start_date).format('YYYY-MM-DD')}{' '} + {props.end_date ? '- ' + moment(props.end_date).format('YYYY-MM-DD') : ''} + + + + + ); +}; + +export default ManualTelemetryCard; diff --git a/app/src/features/surveys/telemetry/ManualTelemetryComponent.tsx b/app/src/features/surveys/telemetry/ManualTelemetryComponent.tsx new file mode 100644 index 0000000000..32ab1c08fe --- /dev/null +++ b/app/src/features/surveys/telemetry/ManualTelemetryComponent.tsx @@ -0,0 +1,220 @@ +import { mdiDotsVertical, mdiImport, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import { LoadingButton } from '@mui/lab'; +import { ListItemIcon } from '@mui/material'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Collapse from '@mui/material/Collapse'; +import { grey } from '@mui/material/colors'; +import IconButton from '@mui/material/IconButton'; +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 DataGridValidationAlert from 'components/data-grid/DataGridValidationAlert'; +import FileUploadDialog from 'components/dialog/FileUploadDialog'; +import YesNoDialog from 'components/dialog/YesNoDialog'; +import { UploadFileStatus } from 'components/file-upload/FileUploadItem'; +import { TelemetryTableI18N } from 'constants/i18n'; +import { DialogContext, ISnackbarProps } from 'contexts/dialogContext'; +import { SurveyContext } from 'contexts/surveyContext'; +import { TelemetryTableContext } from 'contexts/telemetryTableContext'; +import { useTelemetryApi } from 'hooks/useTelemetryApi'; +import { useContext, useState } from 'react'; +import { pluralize as p } from 'utils/Utils'; +import ManualTelemetryTable from './ManualTelemetryTable'; + +const ManualTelemetryComponent = () => { + const [showImportDialog, setShowImportDialog] = useState(false); + const [processingRecords, setProcessingRecords] = useState(false); + const [showConfirmRemoveAllDialog, setShowConfirmRemoveAllDialog] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + const dialogContext = useContext(DialogContext); + const telemetryTableContext = useContext(TelemetryTableContext); + const surveyContext = useContext(SurveyContext); + const telemetryApi = useTelemetryApi(); + const { hasUnsavedChanges, validationModel, _muiDataGridApiRef } = telemetryTableContext; + + const showSnackBar = (textDialogProps?: Partial) => { + dialogContext.setSnackbar({ ...textDialogProps, open: true }); + }; + + const handleCloseMenu = () => { + setAnchorEl(null); + }; + + const { projectId, surveyId } = surveyContext; + const handleFileImport = async (file: File) => { + telemetryApi.uploadCsvForImport(projectId, surveyId, file).then((response) => { + setShowImportDialog(false); + setProcessingRecords(true); + telemetryApi + .processTelemetryCsvSubmission(response.submission_id) + .then(() => { + showSnackBar({ + snackbarMessage: ( + + Telemetry imported successfully. + + ) + }); + telemetryTableContext.refreshRecords().then(() => { + setProcessingRecords(false); + }); + }) + .catch(() => { + setProcessingRecords(false); + }); + }); + }; + + const numSelectedRows = telemetryTableContext.rowSelectionModel.length; + return ( + <> + setShowImportDialog(false)} + onUpload={handleFileImport} + FileUploadProps={{ + dropZoneProps: { maxNumFiles: 1, acceptedFileExtensions: '.csv' }, + status: UploadFileStatus.STAGED + }} + /> + { + setShowConfirmRemoveAllDialog(false); + telemetryTableContext.revertRecords(); + }} + onClose={() => setShowConfirmRemoveAllDialog(false)} + onNo={() => setShowConfirmRemoveAllDialog(false)} + /> + + + + + Telemetry ‌ + + ({telemetryTableContext.recordCount}) + + + + + {false && ( + + )} + + + + telemetryTableContext.saveRecords()} + disabled={telemetryTableContext.isSaving}> + Save + + + + + + ) => { + setAnchorEl(event.currentTarget); + }} + size="small" + disabled={numSelectedRows === 0} + aria-label="telemetry options"> + + + + { + telemetryTableContext.deleteSelectedRecords(); + handleCloseMenu(); + }} + disabled={telemetryTableContext.isSaving}> + + + + Delete {p(numSelectedRows, 'Telemetr', 'y', 'ies')} + + + + + + + + + + + + + + + ); +}; + +export default ManualTelemetryComponent; diff --git a/app/src/features/surveys/telemetry/ManualTelemetryHeader.tsx b/app/src/features/surveys/telemetry/ManualTelemetryHeader.tsx new file mode 100644 index 0000000000..ee801d5ff1 --- /dev/null +++ b/app/src/features/surveys/telemetry/ManualTelemetryHeader.tsx @@ -0,0 +1,50 @@ +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import { grey } from '@mui/material/colors'; +import Link from '@mui/material/Link'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; +import { Link as RouterLink } from 'react-router-dom'; + +export interface ManualTelemetryHeaderProps { + project_id: number; + survey_id: number; + survey_name: string; +} + +const ManualTelemetryHeader: React.FC = (props) => { + const { project_id, survey_id, survey_name } = props; + return ( + + + + {survey_name} + + + Manage Telemetry + + + + Manage Telemetry + + + ); +}; + +export default ManualTelemetryHeader; diff --git a/app/src/features/surveys/telemetry/ManualTelemetryList.tsx b/app/src/features/surveys/telemetry/ManualTelemetryList.tsx new file mode 100644 index 0000000000..34b445694d --- /dev/null +++ b/app/src/features/surveys/telemetry/ManualTelemetryList.tsx @@ -0,0 +1,564 @@ +import { mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import { LoadingButton } from '@mui/lab'; +import { ListItemIcon, Menu, MenuItem, Select, useMediaQuery, useTheme } from '@mui/material'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import { grey } from '@mui/material/colors'; +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 FormControl from '@mui/material/FormControl'; +import FormHelperText from '@mui/material/FormHelperText'; +import InputLabel from '@mui/material/InputLabel'; +import { MenuProps } from '@mui/material/Menu'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import { SkeletonList } 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 { Formik } from 'formik'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useTelemetryApi } from 'hooks/useTelemetryApi'; +import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; +import { isEqual as _deepEquals } from 'lodash'; +import { get } from 'lodash-es'; +import moment from 'moment'; +import { useContext, useEffect, 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, + IAnimalTelemetryDevice +} 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: IDetailedCritterWithInternalId; + 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); + + useEffect(() => { + surveyContext.deploymentDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + surveyContext.critterDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + }, []); + + const deployments = useMemo(() => surveyContext.deploymentDataLoader.data, [surveyContext.deploymentDataLoader.data]); + const critters = useMemo(() => surveyContext.critterDataLoader.data, [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); + const deviceDetails = await telemetryApi.devices.getDeviceDetails(device_id); + + // need to map deployment back into object for initial values + if (critterDeployment) { + const editData: AnimalDeployment = { + survey_critter_id: Number(critterDeployment.critter?.survey_critter_id), + deployments: [ + { + deployment_id: critterDeployment.deployment.deployment_id, + attachment_start: moment(critterDeployment.deployment.attachment_start).format('YYYY-MM-DD'), + attachment_end: critterDeployment.deployment.attachment_end + ? moment(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) => { + const payload = data as IAnimalTelemetryDevice & { critter_id: string }; + try { + const critter = critters?.find((a) => a.survey_critter_id === data.survey_critter_id); + + if (!critter) { + throw new Error('Invalid critter data'); + } + data.critter_id = critter?.critter_id; + await biohubApi.survey.addDeployment( + surveyContext.projectId, + surveyContext.surveyId, + Number(data.survey_critter_id), + payload + ); + 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 ‌ + + ({critterDeployments?.length ?? 0}) + + + + + + {/* Display list of skeleton components while waiting for a response */} + {surveyContext.deploymentDataLoader.isLoading && } + + {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 new file mode 100644 index 0000000000..c366f79f1b --- /dev/null +++ b/app/src/features/surveys/telemetry/ManualTelemetryPage.tsx @@ -0,0 +1,58 @@ +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import { grey } from '@mui/material/colors'; +import Paper from '@mui/material/Paper'; +import { SurveyContext } from 'contexts/surveyContext'; +import { TelemetryDataContextProvider } from 'contexts/telemetryDataContext'; +import { TelemetryTableContextProvider } from 'contexts/telemetryTableContext'; +import { useContext, useMemo } from 'react'; +import ManualTelemetryComponent from './ManualTelemetryComponent'; +import ManualTelemetryHeader from './ManualTelemetryHeader'; +import ManualTelemetryList from './ManualTelemetryList'; + +const ManualTelemetryPage = () => { + const surveyContext = useContext(SurveyContext); + const deploymentIds = useMemo(() => { + return surveyContext.deploymentDataLoader.data?.map((item) => item.deployment_id); + }, [surveyContext.deploymentDataLoader.data]); + + if (!surveyContext.surveyDataLoader.data) { + return ; + } + + return ( + + + + + + + + + + + + + + + + ); +}; + +export default ManualTelemetryPage; diff --git a/app/src/features/surveys/telemetry/ManualTelemetrySection.tsx b/app/src/features/surveys/telemetry/ManualTelemetrySection.tsx new file mode 100644 index 0000000000..f0711d2602 --- /dev/null +++ b/app/src/features/surveys/telemetry/ManualTelemetrySection.tsx @@ -0,0 +1,33 @@ +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 ( + + + + Manual Telemetry + + + + + + + + + ); +}; + +export default ManualTelemetrySection; diff --git a/app/src/features/surveys/telemetry/ManualTelemetryTable.tsx b/app/src/features/surveys/telemetry/ManualTelemetryTable.tsx new file mode 100644 index 0000000000..565a9a4f3d --- /dev/null +++ b/app/src/features/surveys/telemetry/ManualTelemetryTable.tsx @@ -0,0 +1,433 @@ +import { mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import { cyan, grey } from '@mui/material/colors'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import { DataGrid, GridCellParams, GridColDef } from '@mui/x-data-grid'; +import AutocompleteDataGridEditCell from 'components/data-grid/autocomplete/AutocompleteDataGridEditCell'; +import AutocompleteDataGridViewCell from 'components/data-grid/autocomplete/AutocompleteDataGridViewCell'; +import TextFieldDataGrid from 'components/data-grid/TextFieldDataGrid'; +import TimePickerDataGrid from 'components/data-grid/TimePickerDataGrid'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import { SurveyContext } from 'contexts/surveyContext'; +import { IManualTelemetryTableRow, TelemetryTableContext } from 'contexts/telemetryTableContext'; +import moment from 'moment'; +import { useCallback, useContext, useEffect, useMemo } from 'react'; +import { getFormattedDate } from 'utils/Utils'; +import { ICritterDeployment } from './ManualTelemetryList'; +interface IManualTelemetryTableProps { + isLoading: boolean; +} +const ManualTelemetryTable = (props: IManualTelemetryTableProps) => { + const telemetryTableContext = useContext(TelemetryTableContext); + const surveyContext = useContext(SurveyContext); + + useEffect(() => { + surveyContext.deploymentDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + surveyContext.critterDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + }, []); + + 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 { _muiDataGridApiRef } = telemetryTableContext; + const hasError = useCallback( + (params: GridCellParams): boolean => { + return Boolean( + telemetryTableContext.validationModel[params.row.id]?.some((error) => { + return error.field === params.field; + }) + ); + }, + [telemetryTableContext.validationModel] + ); + + const tableColumns: GridColDef[] = [ + { + field: 'deployment_id', + headerName: 'Deployment', + editable: true, + flex: 1, + minWidth: 250, + disableColumnMenu: true, + headerAlign: 'left', + align: 'left', + type: 'string', + renderCell: (params) => { + return ( + + dataGridProps={params} + options={critterDeployments.map((item) => ({ + label: `${item.critter.animal_id}: ${item.deployment.device_id}`, + value: item.deployment.deployment_id + }))} + error={hasError(params)} + /> + ); + }, + renderEditCell: (params) => { + return ( + + dataGridProps={params} + options={critterDeployments.map((item) => ({ + label: `${item.critter.animal_id}: ${item.deployment.device_id}`, + value: item.deployment.deployment_id + }))} + error={hasError(params)} + /> + ); + } + }, + { + field: 'latitude', + headerName: 'Latitude', + editable: true, + flex: 1, + minWidth: 120, + disableColumnMenu: true, + headerAlign: 'left', + align: 'left', + valueSetter: (params) => { + if (/^-?\d{1,3}(?:\.\d{0,12})?$/.test(params.value)) { + // If the value is a legal latitude value + // Valid entries: `-1`, `-1.1`, `-123.456789` `1`, `1.1, `123.456789` + return { ...params.row, latitude: Number(params.value) }; + } + + const value = parseFloat(params.value); + return { ...params.row, latitude: value }; + }, + renderCell: (params) => ( + + {params.value} + + ), + renderEditCell: (params) => { + const error: boolean = hasError(params); + + return ( + { + if (!/^-?\d{0,3}(?:\.\d{0,12})?$/.test(event.target.value)) { + // If the value is not a subset of a legal latitude value, prevent the value from being applied + return; + } + + _muiDataGridApiRef?.current.setEditCellValue({ + id: params.id, + field: params.field, + value: event.target.value + }); + }, + error + }} + /> + ); + } + }, + { + field: 'longitude', + headerName: 'Longitude', + editable: true, + flex: 1, + minWidth: 120, + disableColumnMenu: true, + headerAlign: 'left', + align: 'left', + valueSetter: (params) => { + if (/^-?\d{1,3}(?:\.\d{0,12})?$/.test(params.value)) { + // If the value is a legal longitude value + // Valid entries: `-1`, `-1.1`, `-123.456789` `1`, `1.1, `123.456789` + return { ...params.row, longitude: Number(params.value) }; + } + + const value = parseFloat(params.value); + return { ...params.row, longitude: value }; + }, + renderCell: (params) => ( + + {params.value} + + ), + renderEditCell: (params) => { + const error: boolean = hasError(params); + + return ( + { + if (!/^-?\d{0,3}(?:\.\d{0,12})?$/.test(event.target.value)) { + // If the value is not a subset of a legal longitude value, prevent the value from being applied + return; + } + + _muiDataGridApiRef?.current.setEditCellValue({ + id: params.id, + field: params.field, + value: event.target.value + }); + }, + error + }} + /> + ); + } + }, + { + field: 'date', + headerName: 'Date', + editable: true, + flex: 1, + minWidth: 150, + type: 'date', + disableColumnMenu: true, + headerAlign: 'left', + align: 'left', + valueGetter: (params) => (params.row.date ? moment(params.row.date).toDate() : null), + renderCell: (params) => ( + + {getFormattedDate(DATE_FORMAT.ShortDateFormatMonthFirst, params.value)} + + ), + renderEditCell: (params) => { + const error = hasError(params); + + return ( + { + const value = moment(event.target.value).toDate(); + _muiDataGridApiRef?.current.setEditCellValue({ + id: params.id, + field: params.field, + value + }); + }, + + error + }} + /> + ); + } + }, + { + field: 'time', + headerName: 'Time', + editable: true, + flex: 1, + minWidth: 150, + type: 'string', + disableColumnMenu: true, + headerAlign: 'left', + align: 'left', + valueSetter: (params) => { + return { ...params.row, time: params.value }; + }, + valueParser: (value) => { + if (!value) { + return null; + } + + if (moment.isMoment(value)) { + return value.format('HH:mm:ss'); + } + + return moment(value, 'HH:mm:ss').format('HH:mm:ss'); + }, + renderCell: (params) => { + if (!params.value) { + return null; + } + + return ( + + {params.value} + + ); + }, + renderEditCell: (params) => { + const error = hasError(params); + + return ( + + ); + } + }, + { + field: 'actions', + headerName: '', + type: 'actions', + width: 70, + disableColumnMenu: true, + resizable: false, + headerClassName: 'pinnedColumn', + cellClassName: 'pinnedColumn', + getActions: (params) => [ + telemetryTableContext.deleteRecords([params.row])} + disabled={telemetryTableContext.isSaving} + key={`actions[${params.id}].handleDeleteRow`}> + + + ] + } + ]; + + return ( + telemetryTableContext.onRowEditStart(params.id)} + onRowEditStop={(_params, event) => { + event.defaultMuiPrevented = true; + }} + localeText={{ + noRowsLabel: 'No Records' + }} + onRowSelectionModelChange={telemetryTableContext.onRowSelectionModelChange} + rowSelectionModel={telemetryTableContext.rowSelectionModel} + getRowHeight={() => 'auto'} + slots={{ + loadingOverlay: SkeletonTable + }} + sx={{ + background: grey[50], + border: 'none', + '& .pinnedColumn': { + position: 'sticky', + right: 0, + top: 0, + borderLeft: '1px solid' + grey[300] + }, + '& .MuiDataGrid-columnHeaders': { + background: '#fff', + position: 'relative', + '&:after': { + content: "''", + position: 'absolute', + top: '0', + right: 0, + width: '70px', + height: '60px', + background: '#fff', + borderLeft: '1px solid' + grey[300] + } + }, + '& .MuiDataGrid-columnHeader': { + // px: 3, + py: 1, + '&:focus': { + outline: 'none' + } + }, + '& .MuiDataGrid-columnHeaderTitle': { + fontWeight: 700, + textTransform: 'uppercase', + color: 'text.secondary' + }, + '& .MuiDataGrid-cell': { + // px: 3, + py: 1, + 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': { + backgroundColor: cyan[50] + } + }, + '& .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' + } + }, + '& .MuiOutlinedInput-notchedOutline': { + border: '1px solid ' + grey[300], + '&.Mui-focused': { + borderColor: 'primary.main' + } + }, + '& .MuiDataGrid-virtualScrollerContent': { + background: grey[100] + }, + '& .MuiDataGrid-footerContainer': { + background: '#fff' + } + }} + /> + ); +}; + +export default ManualTelemetryTable; diff --git a/app/src/features/surveys/view/Partnerships.test.tsx b/app/src/features/surveys/view/Partnerships.test.tsx index b5374553c1..d17ff83f34 100644 --- a/app/src/features/surveys/view/Partnerships.test.tsx +++ b/app/src/features/surveys/view/Partnerships.test.tsx @@ -6,7 +6,7 @@ import { codes } from 'test-helpers/code-helpers'; import { getProjectForViewResponse } from 'test-helpers/project-helpers'; import { getSurveyForListResponse } from 'test-helpers/survey-helpers'; import { cleanup, render } from 'test-helpers/test-utils'; -import Partnerships from './Partnerships'; +import Partnerships from './components/Partnerships'; jest.mock('../../../hooks/useBioHubApi'); const mockBiohubApi = useBiohubApi as jest.Mock; diff --git a/app/src/features/surveys/view/SurveyAnimals.test.tsx b/app/src/features/surveys/view/SurveyAnimals.test.tsx index d2193c1fe9..7caef6f7db 100644 --- a/app/src/features/surveys/view/SurveyAnimals.test.tsx +++ b/app/src/features/surveys/view/SurveyAnimals.test.tsx @@ -5,6 +5,7 @@ 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'; @@ -87,7 +88,9 @@ describe('SurveyAnimals', () => { - + + + @@ -104,7 +107,7 @@ describe('SurveyAnimals', () => { { critter_id: 'critter_uuid', survey_critter_id: 1, - animal_id: 'a', + animal_id: 'animal_alias', taxon: 'a', created_at: 'a', wlh_id: '123-45' @@ -119,7 +122,9 @@ describe('SurveyAnimals', () => { - + + + @@ -130,11 +135,11 @@ describe('SurveyAnimals', () => { expect(getByText('123-45')).toBeInTheDocument(); expect(getByTestId('survey-animal-table')).toBeInTheDocument(); fireEvent.click(getByTestId('animal actions')); - fireEvent.click(getByTestId('animal-table-row-edit-timespan')); - fireEvent.click(getByText('Save')); - fireEvent.click(getByTestId('animal actions')); - fireEvent.click(getByTestId('animal-table-row-add-device')); - fireEvent.click(getByText('Save')); + 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 index 49cf3909de..ecd6ec082e 100644 --- a/app/src/features/surveys/view/SurveyAnimals.tsx +++ b/app/src/features/surveys/view/SurveyAnimals.tsx @@ -1,60 +1,27 @@ -import { mdiPlus } from '@mdi/js'; +import { mdiCog } from '@mdi/js'; import Icon from '@mdi/react'; -import { Box, Divider, Typography } from '@mui/material'; -import HelpButtonTooltip from 'components/buttons/HelpButtonTooltip'; +import { Box, Button, Divider, Toolbar, Typography } from '@mui/material'; import ComponentDialog from 'components/dialog/ComponentDialog'; -import EditDialog from 'components/dialog/EditDialog'; import YesNoDialog from 'components/dialog/YesNoDialog'; -import { H2ButtonToolbar } from 'components/toolbar/ActionToolbars'; -import { AttachmentType } from 'constants/attachments'; -import { SurveyAnimalsI18N } from 'constants/i18n'; import { DialogContext } from 'contexts/dialogContext'; import { SurveyContext } from 'contexts/surveyContext'; import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader from 'hooks/useDataLoader'; -import { useTelemetryApi } from 'hooks/useTelemetryApi'; -import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; -import { isEqual as _deepEquals } from 'lodash-es'; import React, { useContext, useEffect, useMemo, useState } from 'react'; -import { dateRangesOverlap, datesSameNullable } from 'utils/Utils'; -import yup from 'utils/YupSchema'; +import { Link as RouterLink, useHistory } from 'react-router-dom'; import NoSurveySectionData from '../components/NoSurveySectionData'; -import { AnimalSchema, AnimalSex, Critter, IAnimal } from './survey-animals/animal'; -import { - createCritterUpdatePayload, - transformCritterbaseAPIResponseToForm -} from './survey-animals/animal-form-helpers'; -import { - AnimalDeploymentTimespanSchema, - AnimalTelemetryDeviceSchema, - Device, - IAnimalTelemetryDevice, - IDeploymentTimespan -} from './survey-animals/device'; -import IndividualAnimalForm, { ANIMAL_FORM_MODE } from './survey-animals/IndividualAnimalForm'; import { SurveyAnimalsTable } from './survey-animals/SurveyAnimalsTable'; -import TelemetryDeviceForm, { - IAnimalTelemetryDeviceFile, - TELEMETRY_DEVICE_FORM_MODE -} from './survey-animals/TelemetryDeviceForm'; -import TelemetryMap from './survey-animals/TelemetryMap'; +import TelemetryMap from './survey-animals/telemetry-device/TelemetryMap'; const SurveyAnimals: React.FC = () => { const bhApi = useBiohubApi(); - const telemetryApi = useTelemetryApi(); const dialogContext = useContext(DialogContext); const surveyContext = useContext(SurveyContext); + const history = useHistory(); const [openRemoveCritterDialog, setOpenRemoveCritterDialog] = useState(false); - const [openAddCritterDialog, setOpenAddCritterDialog] = useState(false); - const [openDeviceDialog, setOpenDeviceDialog] = useState(false); const [openViewTelemetryDialog, setOpenViewTelemetryDialog] = useState(false); - const [isSubmittingTelemetry, setIsSubmittingTelemetry] = useState(false); const [selectedCritterId, setSelectedCritterId] = useState(null); - const [telemetryFormMode, setTelemetryFormMode] = useState( - TELEMETRY_DEVICE_FORM_MODE.ADD - ); - const [animalFormMode, setAnimalFormMode] = useState(ANIMAL_FORM_MODE.ADD); const { projectId, surveyId } = surveyContext; const { @@ -63,11 +30,9 @@ const SurveyAnimals: React.FC = () => { data: critterData } = useDataLoader(() => bhApi.survey.getSurveyCritters(projectId, surveyId)); - const { - refresh: refreshDeployments, - load: loadDeployments, - data: deploymentData - } = useDataLoader(() => bhApi.survey.getDeploymentsInSurvey(projectId, surveyId)); + const { load: loadDeployments, data: deploymentData } = useDataLoader(() => + bhApi.survey.getDeploymentsInSurvey(projectId, surveyId) + ); if (!critterData) { loadCritters(); @@ -103,11 +68,6 @@ const SurveyAnimals: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentCritterbaseCritterId]); - const toggleDialog = () => { - setAnimalFormMode(ANIMAL_FORM_MODE.ADD); - setOpenAddCritterDialog((d) => !d); - }; - const setPopup = (message: string) => { dialogContext.setSnackbar({ open: true, @@ -119,353 +79,6 @@ const SurveyAnimals: React.FC = () => { }); }; - const AnimalFormValues: IAnimal = { - general: { wlh_id: '', taxon_id: '', taxon_name: '', animal_id: '', sex: AnimalSex.UNKNOWN, critter_id: '' }, - captures: [], - markings: [], - mortality: [], - collectionUnits: [], - measurements: [], - family: [], - images: [], - device: undefined - }; - - const DeviceFormValues: IAnimalTelemetryDevice = { - device_id: '' as unknown as number, - device_make: '', - frequency: '' as unknown as number, - frequency_unit: '', - device_model: '', - deployments: [ - { - deployment_id: '', - attachment_start: '', - attachment_end: undefined - } - ] - }; - - const deploymentOverlapTest = async ( - device_id: number, - deployment_id: string, - attachment_start: string | undefined, - attachment_end: string | null | undefined - ): Promise => { - const deviceDetails = await telemetryApi.devices.getDeviceDetails(device_id); - if (!attachment_start) { - return 'Attachment start is required.'; //It probably won't actually display this but just in case. - } - const existingDeployment = deviceDetails?.deployments?.find( - (a) => - a.deployment_id !== deployment_id && - dateRangesOverlap(a.attachment_start, a.attachment_end, attachment_start, attachment_end) - ); - if (existingDeployment) { - return `This will conflict with an existing deployment for the device running from ${ - existingDeployment.attachment_start - } until ${existingDeployment.attachment_end ?? 'indefinite.'}`; - } else { - return ''; - } - }; - - const AnimalDeploymentSchemaAsyncValidation = AnimalTelemetryDeviceSchema.shape({ - device_make: yup - .string() - .required('Required') - .test('checkDeviceMakeIsNotChanged', '', async (value, context) => { - // Bypass to avoid an api call when invalid device_id - if (!context.parent.device_id) { - return true; - } - const deviceDetails = await telemetryApi.devices.getDeviceDetails(Number(context.parent.device_id)); - if (deviceDetails.device?.device_make && deviceDetails.device?.device_make !== value) { - return context.createError({ - message: `The current make for this device is ${deviceDetails.device?.device_make}, this value should not be changed.` - }); - } - return true; - }), - deployments: yup.array( - AnimalDeploymentTimespanSchema.shape({ - attachment_start: yup - .string() - .required('Required.') - .isValidDateString() - .typeError('Required.') - .test('checkDeploymentRange', '', async (value, context) => { - const upperLevelIndex = Number(context.path.match(/\[(\d+)\]/)?.[1]); //Searches [0].deployments[0].attachment_start for the number contained in first index. - const deviceId = context.options.context?.[upperLevelIndex]?.device_id; - const errStr = deviceId - ? await deploymentOverlapTest( - deviceId, - context.parent.deployment_id, - value, - context.parent.attachment_end - ) - : ''; - if (errStr.length) { - return context.createError({ message: errStr }); - } else { - return true; - } - }), - attachment_end: yup - .string() - .isValidDateString() - .isEndDateSameOrAfterStartDate('attachment_start') - .nullable() - .test('checkDeploymentRangeEnd', '', async (value, context) => { - const upperLevelIndex = Number(context.path.match(/\[(\d+)\]/)?.[1]); //Searches [0].deployments[0].attachment_start for the number contained in first index. - const deviceId = context.options.context?.[upperLevelIndex]?.device_id; - const errStr = deviceId - ? await deploymentOverlapTest( - deviceId, - context.parent.deployment_id, - context.parent.attachment_start, - value - ) - : ''; - if (errStr.length) { - return context.createError({ message: errStr }); - } else { - return true; - } - }) - }) - ) - }); - - const obtainAnimalFormInitialvalues = (mode: ANIMAL_FORM_MODE): IAnimal | null => { - switch (mode) { - case ANIMAL_FORM_MODE.ADD: - return AnimalFormValues; - case ANIMAL_FORM_MODE.EDIT: { - const existingCritter = critterData?.find( - (critter: IDetailedCritterWithInternalId) => currentCritterbaseCritterId === critter.critter_id - ); - if (!existingCritter) { - return null; - } - return transformCritterbaseAPIResponseToForm(existingCritter); - } - } - }; - - const obtainDeviceFormInitialValues = (mode: TELEMETRY_DEVICE_FORM_MODE) => { - switch (mode) { - case TELEMETRY_DEVICE_FORM_MODE.ADD: - return [DeviceFormValues]; - case TELEMETRY_DEVICE_FORM_MODE.EDIT: { - const deployments = deploymentData?.filter((a) => a.critter_id === currentCritterbaseCritterId); - if (deployments) { - //Any suggestions on something better than this reduce is welcome. - //Idea is to transform flat rows of {device_id, ..., deployment_id, attachment_end, attachment_start} - //to {device_id, ..., deployments: [{deployment_id, attachment_start, attachment_end}]} - const red = deployments.reduce((acc: IAnimalTelemetryDevice[], curr) => { - const currObj = acc.find((a: any) => a.device_id === curr.device_id); - const { attachment_end, attachment_start, deployment_id, ...rest } = curr; - const deployment = { - deployment_id, - attachment_start: attachment_start?.split('T')?.[0] ?? '', - attachment_end: attachment_end?.split('T')?.[0] - }; - if (!currObj) { - acc.push({ ...rest, deployments: [deployment] }); - } else { - currObj.deployments?.push(deployment); - } - return acc; - }, []); - return red; - } else { - return [DeviceFormValues]; - } - } - } - }; - - const renderAnimalFormSafe = (): JSX.Element => { - const initialValues = obtainAnimalFormInitialvalues(animalFormMode); - if (!initialValues) { - return ( - - ); - } else { - return ( - - - - {animalFormMode === ANIMAL_FORM_MODE.EDIT ? 'Edit Animal' : 'Add Animal'} - - - {animalFormMode === ANIMAL_FORM_MODE.EDIT && ( - - ID: {currentCritterbaseCritterId} - - )} - - } - open={openAddCritterDialog} - onSave={(values) => { - handleCritterSave(values); - }} - onCancel={toggleDialog} - component={{ - element: , - initialValues: initialValues, - validationSchema: AnimalSchema - }} - dialogSaveButtonLabel="Save" - /> - ); - } - }; - - const handleCritterSave = async (currentFormValues: IAnimal) => { - const postCritterPayload = async () => { - const critter = new Critter(currentFormValues); - toggleDialog(); - await bhApi.survey.createCritterAndAddToSurvey(projectId, surveyId, critter); - refreshCritters(); - setPopup('Animal added to survey.'); - }; - const patchCritterPayload = async () => { - const initialFormValues = obtainAnimalFormInitialvalues(ANIMAL_FORM_MODE.EDIT); - if (!initialFormValues) { - throw Error('Could not obtain initial form values.'); - } - const { create: createCritter, update: updateCritter } = createCritterUpdatePayload( - initialFormValues, - currentFormValues - ); - toggleDialog(); - if (!selectedCritterId) { - throw Error('The internal critter id for this row was not set correctly.'); - } - await bhApi.survey.updateSurveyCritter(projectId, surveyId, selectedCritterId, updateCritter, createCritter); - refreshCritters(); - setPopup('Animal data updated.'); - }; - try { - if (animalFormMode === ANIMAL_FORM_MODE.ADD) { - await postCritterPayload(); - } else { - await patchCritterPayload(); - } - } catch (err) { - setPopup(`Submission failed. ${(err as Error).message}`); - toggleDialog(); - } - }; - - const uploadAttachment = async (file?: File, attachmentType?: AttachmentType) => { - try { - if (file && attachmentType === AttachmentType.KEYX) { - await bhApi.survey.uploadSurveyKeyx(projectId, surveyId, file); - } else if (file && attachmentType === AttachmentType.OTHER) { - await bhApi.survey.uploadSurveyAttachments(projectId, surveyId, file); - } - } catch (error) { - throw new Error(`Failed to upload attachment ${file?.name}`); - } - }; - - const handleAddTelemetry = async (survey_critter_id: number, data: IAnimalTelemetryDeviceFile[]) => { - const critter = critterData?.find((a) => a.survey_critter_id === survey_critter_id); - const { attachmentFile, attachmentType, ...critterTelemetryDevice } = { - ...data[0], - critter_id: critter?.critter_id ?? '' - }; - try { - // Upload attachment if there is one - await uploadAttachment(attachmentFile, attachmentType); - // create new deployment record - await bhApi.survey.addDeployment(projectId, surveyId, survey_critter_id, critterTelemetryDevice); - setPopup('Successfully added deployment.'); - surveyContext.artifactDataLoader.refresh(projectId, surveyId); - } catch (error: unknown) { - if (error instanceof Error) { - setPopup('Failed to add deployment' + (error?.message ? `: ${error.message}` : '.')); - } else { - setPopup('Failed to add deployment.'); - } - } - }; - - const updateDevice = async (formValues: IAnimalTelemetryDevice) => { - const existingDevice = deploymentData?.find((deployment) => deployment.device_id === formValues.device_id); - const formDevice = new Device({ collar_id: existingDevice?.collar_id, ...formValues }); - if (existingDevice && !_deepEquals(new Device(existingDevice), formDevice)) { - try { - await telemetryApi.devices.upsertCollar(formDevice); - } catch (error) { - throw new Error(`Failed to update collar ${formDevice.collar_id}`); - } - } - }; - - const updateDeployments = async (formDeployments: IDeploymentTimespan[], survey_critter_id: number) => { - for (const formDeployment of formDeployments ?? []) { - const existingDeployment = deploymentData?.find( - (animalDeployment) => animalDeployment.deployment_id === formDeployment.deployment_id - ); - if ( - !datesSameNullable(formDeployment?.attachment_start, existingDeployment?.attachment_start) || - !datesSameNullable(formDeployment?.attachment_end, existingDeployment?.attachment_end) - ) { - try { - await bhApi.survey.updateDeployment(projectId, surveyId, survey_critter_id, formDeployment); - } catch (error) { - throw new Error(`Failed to update deployment ${formDeployment.deployment_id}`); - } - } - } - }; - - const handleEditTelemetry = async (survey_critter_id: number, data: IAnimalTelemetryDeviceFile[]) => { - const errors: string[] = []; - for (const { attachmentFile, attachmentType, ...formValues } of data) { - try { - await uploadAttachment(attachmentFile, attachmentType); - await updateDevice(formValues); - await updateDeployments(formValues.deployments ?? [], survey_critter_id); - } catch (error) { - errors.push(`Device ${formValues.device_id} - ` + (error instanceof Error ? error.message : 'Unknown error')); - } - } - errors.length - ? setPopup('Failed to save some data: ' + errors.join(', ')) - : setPopup('Updated deployment and device data successfully.'); - }; - - const handleTelemetrySave = async (survey_critter_id: number, data: IAnimalTelemetryDeviceFile[]) => { - setIsSubmittingTelemetry(true); - if (telemetryFormMode === TELEMETRY_DEVICE_FORM_MODE.ADD) { - await handleAddTelemetry(survey_critter_id, data); - } else if (telemetryFormMode === TELEMETRY_DEVICE_FORM_MODE.EDIT) { - await handleEditTelemetry(survey_critter_id, data); - } - - setIsSubmittingTelemetry(false); - setOpenDeviceDialog(false); - refreshDeployments(); - surveyContext.artifactDataLoader.refresh(projectId, surveyId); - }; - const handleRemoveCritter = async () => { try { if (!selectedCritterId) { @@ -480,52 +93,11 @@ const SurveyAnimals: React.FC = () => { refreshCritters(); }; - const handleRemoveDeployment = async (deployment_id: string) => { - try { - if (!selectedCritterId) { - setPopup('Failed to delete deployment.'); - return; - } - await bhApi.survey.removeDeployment(projectId, surveyId, selectedCritterId, deployment_id); - } catch (e) { - setPopup('Failed to delete deployment.'); - return; - } - - const deployments = deploymentData?.filter((a) => a.critter_id === currentCritterbaseCritterId) ?? []; - if (deployments.length <= 1) { - setOpenDeviceDialog(false); - } - refreshDeployments(); - }; - return ( - {renderAnimalFormSafe()} - , - initialValues: obtainDeviceFormInitialValues(telemetryFormMode), - validationSchema: yup.array(AnimalDeploymentSchemaAsyncValidation), - validateOnBlur: false, - validateOnChange: true - }} - onCancel={() => setOpenDeviceDialog(false)} - onSave={(values) => { - if (selectedCritterId) { - handleTelemetrySave(selectedCritterId, values); - } - }} - /> { onNo={() => setOpenRemoveCritterDialog(false)} onYes={handleRemoveCritter} /> - } - buttonOnClick={toggleDialog} - /> + + + Marked and Known Animals + + + {critterData?.length ? ( @@ -553,17 +136,8 @@ const SurveyAnimals: React.FC = () => { onRemoveCritter={() => { setOpenRemoveCritterDialog(true); }} - onAddDevice={() => { - setTelemetryFormMode(TELEMETRY_DEVICE_FORM_MODE.ADD); - setOpenDeviceDialog(true); - }} - onEditDevice={() => { - setTelemetryFormMode(TELEMETRY_DEVICE_FORM_MODE.EDIT); - setOpenDeviceDialog(true); - }} onEditCritter={() => { - setAnimalFormMode(ANIMAL_FORM_MODE.EDIT); - setOpenAddCritterDialog(true); + history.push(`animals/?cid=${selectedCritterId}`); }} onMapOpen={() => { setOpenViewTelemetryDialog(true); diff --git a/app/src/features/surveys/view/SurveyAttachments.test.tsx b/app/src/features/surveys/view/SurveyAttachments.test.tsx index 12f3ddd4cf..2876b3c922 100644 --- a/app/src/features/surveys/view/SurveyAttachments.test.tsx +++ b/app/src/features/surveys/view/SurveyAttachments.test.tsx @@ -61,6 +61,7 @@ describe('SurveyAttachments', () => { } as unknown as DataLoader } as unknown as ISurveyContext; + const authState = getMockAuthState({ base: SystemAdminAuthState }); const mockProjectAuthStateContext: IProjectAuthStateContext = { getProjectParticipant: () => null, hasProjectRole: () => true, @@ -83,15 +84,17 @@ describe('SurveyAttachments', () => { } as unknown as IProjectContext; const { getByText, queryByText } = render( - - - - - - - - - + + + + + + + + + + + ); await waitFor(() => { expect(getByText('Upload')).toBeInTheDocument(); @@ -125,6 +128,7 @@ describe('SurveyAttachments', () => { } as unknown as DataLoader } as unknown as ISurveyContext; + const authState = getMockAuthState({ base: SystemAdminAuthState }); const mockProjectAuthStateContext: IProjectAuthStateContext = { getProjectParticipant: () => null, hasProjectRole: () => true, @@ -147,15 +151,17 @@ describe('SurveyAttachments', () => { } as unknown as IProjectContext; const { getByText } = render( - - - - - - - - - + + + + + + + + + + + ); await waitFor(() => { expect(getByText('No Documents')).toBeInTheDocument(); @@ -185,6 +191,7 @@ describe('SurveyAttachments', () => { } as unknown as DataLoader } as unknown as ISurveyContext; + const authState = getMockAuthState({ base: SystemAdminAuthState }); const mockProjectAuthStateContext: IProjectAuthStateContext = { getProjectParticipant: () => null, hasProjectRole: () => true, @@ -209,15 +216,17 @@ describe('SurveyAttachments', () => { } as unknown as IProjectContext; const { getByText } = render( - - - - - - - - - + + + + + + + + + + + ); await waitFor(() => { @@ -258,6 +267,7 @@ describe('SurveyAttachments', () => { } as unknown as DataLoader } as unknown as ISurveyContext; + const authState = getMockAuthState({ base: SystemAdminAuthState }); const mockProjectAuthStateContext: IProjectAuthStateContext = { getProjectParticipant: () => null, hasProjectRole: () => true, @@ -279,11 +289,9 @@ describe('SurveyAttachments', () => { } as unknown as DataLoader } as unknown as IProjectContext; - const authState = getMockAuthState({ base: SystemAdminAuthState }); - const { baseElement, queryByText, getByTestId, getAllByTestId, queryByTestId } = render( - - + + @@ -293,8 +301,8 @@ describe('SurveyAttachments', () => { - - + + ); await waitFor(() => { @@ -350,6 +358,7 @@ describe('SurveyAttachments', () => { } as unknown as DataLoader } as unknown as ISurveyContext; + const authState = getMockAuthState({ base: SystemAdminAuthState }); const mockProjectAuthStateContext: IProjectAuthStateContext = { getProjectParticipant: () => null, hasProjectRole: () => true, @@ -371,11 +380,9 @@ describe('SurveyAttachments', () => { } as unknown as DataLoader } as unknown as IProjectContext; - const authState = getMockAuthState({ base: SystemAdminAuthState }); - const { baseElement, queryByText, getByTestId, getAllByTestId, queryByTestId } = render( - - + + @@ -385,8 +392,8 @@ describe('SurveyAttachments', () => { - - + + ); await waitFor(() => { @@ -453,6 +460,7 @@ describe('SurveyAttachments', () => { } as unknown as DataLoader } as unknown as IProjectContext; + const authState = getMockAuthState({ base: SystemAdminAuthState }); const mockProjectAuthStateContext: IProjectAuthStateContext = { getProjectParticipant: () => null, hasProjectRole: () => true, @@ -462,11 +470,9 @@ describe('SurveyAttachments', () => { hasLoadedParticipantInfo: true }; - const authState = getMockAuthState({ base: SystemAdminAuthState }); - const { baseElement, queryByText, getAllByTestId, queryByTestId, getAllByRole } = render( - - + + @@ -476,8 +482,8 @@ describe('SurveyAttachments', () => { - - + + ); await waitFor(() => { diff --git a/app/src/features/surveys/view/SurveyAttachments.tsx b/app/src/features/surveys/view/SurveyAttachments.tsx index a24431e98c..c5d369e1f5 100644 --- a/app/src/features/surveys/view/SurveyAttachments.tsx +++ b/app/src/features/surveys/view/SurveyAttachments.tsx @@ -93,7 +93,7 @@ const SurveyAttachments: React.FC = () => { buttonLabel="Upload" buttonTitle="Upload Documents" buttonProps={{ variant: 'contained', disableElevation: true }} - buttonStartIcon={} + buttonStartIcon={} menuItems={[ { menuLabel: 'Upload a Report', diff --git a/app/src/features/surveys/view/SurveyDetails.test.tsx b/app/src/features/surveys/view/SurveyDetails.test.tsx index 314ba8380a..9a1fc6a9d2 100644 --- a/app/src/features/surveys/view/SurveyDetails.test.tsx +++ b/app/src/features/surveys/view/SurveyDetails.test.tsx @@ -1,13 +1,37 @@ import { CodesContext, ICodesContext } from 'contexts/codesContext'; import { SurveyContext } from 'contexts/surveyContext'; +import { createMemoryHistory } from 'history'; +import { GetRegionsResponse } from 'hooks/api/useSpatialApi'; import { DataLoader } from 'hooks/useDataLoader'; import { IGetSurveyForViewResponse } from 'interfaces/useSurveyApi.interface'; +import { Router } from 'react-router'; import { codes } from 'test-helpers/code-helpers'; import { getSurveyForViewResponse } from 'test-helpers/survey-helpers'; import { render, waitFor } from 'test-helpers/test-utils'; +import { useBiohubApi } from '../../../hooks/useBioHubApi'; import SurveyDetails from './SurveyDetails'; +const history = createMemoryHistory({ initialEntries: ['/admin/projects/1/surveys/2'] }); + +jest.mock('../../../hooks/useBioHubApi'); +const mockBiohubApi = useBiohubApi as jest.Mock; + +const mockUseApi = { + spatial: { + getRegions: jest.fn, []>() + } +}; + describe('SurveyDetails', () => { + beforeEach(() => { + mockBiohubApi.mockImplementation(() => mockUseApi); + mockUseApi.spatial.getRegions.mockClear(); + + mockUseApi.spatial.getRegions.mockResolvedValue({ + regions: [] + }); + }); + const mockCodesContext: ICodesContext = { codesDataLoader: { data: codes @@ -30,23 +54,29 @@ describe('SurveyDetails', () => { const mockObservationsDataLoader = { data: null } as DataLoader; const mockSummaryDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; + const mockCritterDataLoader = { data: [] } as DataLoader; + const mockDeploymentDataLoader = { data: [] } as DataLoader; it('renders correctly', async () => { const { getByText } = render( - - - - - + + + + + + + ); await waitFor(() => { diff --git a/app/src/features/surveys/view/SurveyDetails.tsx b/app/src/features/surveys/view/SurveyDetails.tsx index 8ffd2d4f0d..ee41ab33af 100644 --- a/app/src/features/surveys/view/SurveyDetails.tsx +++ b/app/src/features/surveys/view/SurveyDetails.tsx @@ -1,92 +1,144 @@ -import { Theme } from '@mui/material'; +import { mdiPencil } 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 Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; -import { makeStyles } from '@mui/styles'; +import Permits from 'features/surveys/view/components/Permits'; +import SurveyParticipants from 'features/surveys/view/components/SurveyParticipants'; import SurveyProprietaryData from 'features/surveys/view/components/SurveyProprietaryData'; import SurveyPurposeAndMethodologyData from 'features/surveys/view/components/SurveyPurposeAndMethodologyData'; +import { PropsWithChildren } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import Partnerships from './components/Partnerships'; +import SamplingMethods from './components/SamplingMethods'; import SurveyFundingSources from './components/SurveyFundingSources'; import SurveyGeneralInformation from './components/SurveyGeneralInformation'; -import Partnerships from './Partnerships'; - -const useStyles = makeStyles((theme: Theme) => ({ - surveyMetadataContainer: { - '& section + section': { - marginTop: theme.spacing(4) - }, - '& dt': { - flex: '0 0 40%' - }, - '& dd': { - flex: '1 1 auto' - }, - '& .MuiListItem-root': { - paddingTop: theme.spacing(1.5), - paddingBottom: theme.spacing(1.5) - }, - '& .MuiListItem-root:first-of-type': { - paddingTop: 0 - }, - '& .MuiListItem-root:last-of-type': { - paddingBottom: 0 - }, - '& h4': { - fontSize: '14px', - fontWeight: 700, - letterSpacing: '0.02rem', - textTransform: 'uppercase', - color: grey[600], - '& + hr': { - marginTop: theme.spacing(1.5), - marginBottom: theme.spacing(1.5) - } - } - } -})); +import SurveyStudyArea from './components/SurveyStudyArea'; /** * Survey details content for a survey. * * @return {*} */ -const SurveyDetails = () => { - const classes = useStyles(); +const SurveyDetails = () => { return ( - + - + Survey Details + - - - + + + - Funding Sources - - + General Information + + - Partnerships - - + Study Area Location + + - Purpose and Methodology - + Purpose and Methodology + + + Sampling Methods + + + + + Survey Participants + + + + + Funding Sources & Partnerships + + + + + + - Proprietary Information - + Permits + + + + + Proprietary Information - - + + ); }; +export const DetailsWrapper = (props: PropsWithChildren) => ( + } + p={3} + sx={{ + '& h3': { + mb: 2, + flex: '0 0 auto', + fontSize: '0.875rem', + fontWeight: 700, + textTransform: 'uppercase' + }, + '& h4': { + width: { xs: '100%', md: '300px' }, + flex: '0 0 auto', + color: 'text.secondary' + }, + '& dl': { + flex: '1 1 auto', + m: 0 + }, + '& dt': { + flex: '0 0 auto', + width: { xs: '100%', md: '300px' }, + typography: { xs: 'body2', md: 'body1' }, + color: 'text.secondary' + }, + '& dd': { + typography: 'body1', + color: 'text.primary' + }, + '& .row': { + display: 'flex', + flexDirection: 'row', + flexWrap: { xs: 'wrap', md: 'nowrap' }, + gap: { xs: 0, md: '24px' }, + mt: 0, + py: 1, + borderTop: '1px solid ' + grey[300] + }, + '& hr': { + my: 3 + } + }}> + {props.children} + +); + export default SurveyDetails; diff --git a/app/src/features/surveys/view/SurveyHeader.test.tsx b/app/src/features/surveys/view/SurveyHeader.test.tsx index 23e8a9f2cc..deb9d7f81e 100644 --- a/app/src/features/surveys/view/SurveyHeader.test.tsx +++ b/app/src/features/surveys/view/SurveyHeader.test.tsx @@ -1,18 +1,21 @@ import { AuthStateContext, IAuthState } from 'contexts/authStateContext'; import { DialogContextProvider } from 'contexts/dialogContext'; +import { IProjectAuthStateContext, ProjectAuthStateContext } from 'contexts/projectAuthStateContext'; import { IProjectContext, ProjectContext } from 'contexts/projectContext'; import { ISurveyContext, SurveyContext } from 'contexts/surveyContext'; import SurveyHeader from 'features/surveys/view/SurveyHeader'; import { createMemoryHistory } from 'history'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { DataLoader } from 'hooks/useDataLoader'; +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 { getProjectForViewResponse } from 'test-helpers/project-helpers'; import { getSurveyForViewResponse } from 'test-helpers/survey-helpers'; import { cleanup, fireEvent, render, waitFor } from 'test-helpers/test-utils'; -const history = createMemoryHistory({ initialEntries: ['/admin/projects/1/surveys/1'] }); +const history = createMemoryHistory({ initialEntries: ['/admin/projects/1/surveys/2'] }); jest.mock('../../../hooks/useBioHubApi'); const mockBiohubApi = useBiohubApi as jest.Mock; @@ -40,15 +43,46 @@ const mockSurveyContext: ISurveyContext = { sampleSiteDataLoader: { data: null } as DataLoader, + critterDataLoader: { + data: null + } as DataLoader, + deploymentDataLoader: { + data: null + } as DataLoader, surveyId: 1, projectId: 1 }; +const mockProjectContext: IProjectContext = { + projectDataLoader: { + data: getProjectForViewResponse + } as DataLoader<[project_id: number], IGetProjectForViewResponse, unknown>, + artifactDataLoader: { + data: null + } as DataLoader, + surveysListDataLoader: { + data: null, + refresh: () => {} + } as DataLoader, + projectId: 1 +}; + +const mockProjectAuthStateContext: IProjectAuthStateContext = { + getProjectParticipant: () => null, + hasProjectRole: () => true, + hasProjectPermission: () => true, + hasSystemRole: () => true, + getProjectId: () => 1, + hasLoadedParticipantInfo: true +}; + const surveyForView = getSurveyForViewResponse; describe('SurveyHeader', () => { beforeEach(() => { mockBiohubApi.mockImplementation(() => mockUseApi); + + mockUseApi.survey.deleteSurvey.mockResolvedValue(true); }); afterEach(() => { @@ -57,23 +91,19 @@ describe('SurveyHeader', () => { const renderComponent = (authState: IAuthState) => { return render( - - } as unknown as IProjectContext - }> - - - - - - - - - - + + + + + + + + + + + + + ); }; @@ -84,9 +114,15 @@ describe('SurveyHeader', () => { const { getByTestId, findByText, getByText } = renderComponent(authState); - const surveyHeaderText = await findByText('survey name', { selector: 'h1 span' }); + const surveyHeaderText = await findByText('survey name', { selector: 'span' }); expect(surveyHeaderText).toBeVisible(); + fireEvent.click(getByTestId('settings-survey-button')); + + await waitFor(() => { + expect(getByText('Delete Survey')).toBeInTheDocument(); + }); + fireEvent.click(getByTestId('delete-survey-button')); await waitFor(() => { @@ -98,7 +134,9 @@ describe('SurveyHeader', () => { fireEvent.click(getByTestId('yes-button')); await waitFor(() => { - expect(history.location.pathname).toEqual(`/admin/projects/${surveyForView.surveyData.survey_details.id}`); + expect(history.location.pathname).toEqual( + `/admin/projects/${surveyForView.surveyData.survey_details.project_id}` + ); }); }); @@ -107,7 +145,7 @@ describe('SurveyHeader', () => { const { queryByTestId, findByText } = renderComponent(authState); - const surveyHeaderText = await findByText('survey name', { selector: 'h1 span' }); + const surveyHeaderText = await findByText('survey name', { selector: 'span' }); expect(surveyHeaderText).toBeVisible(); expect(queryByTestId('delete-survey-button')).toBeNull(); diff --git a/app/src/features/surveys/view/SurveyHeader.tsx b/app/src/features/surveys/view/SurveyHeader.tsx index 0a18d46be4..97bd77bc66 100644 --- a/app/src/features/surveys/view/SurveyHeader.tsx +++ b/app/src/features/surveys/view/SurveyHeader.tsx @@ -1,59 +1,33 @@ -import { - mdiArrowLeft, - mdiCalendarRangeOutline, - mdiChevronDown, - mdiCogOutline, - mdiPencilOutline, - mdiTrashCanOutline -} from '@mdi/js'; +import { mdiChevronDown, mdiCog, mdiPencil, mdiTrashCanOutline } from '@mdi/js'; import Icon from '@mdi/react'; -import { Theme } from '@mui/material'; -import Box from '@mui/material/Box'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; import Button from '@mui/material/Button'; import CircularProgress from '@mui/material/CircularProgress'; -import Container from '@mui/material/Container'; +import Link from '@mui/material/Link'; import ListItemIcon from '@mui/material/ListItemIcon'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; -import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; -import { makeStyles } from '@mui/styles'; import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; -import PublishSurveyDialog from 'components/publish/PublishSurveyDialog'; -import { ProjectRoleGuard, SystemRoleGuard } from 'components/security/Guards'; +import PublishSurveyIdDialog from 'components/publish/PublishSurveyDialog'; +import { ProjectRoleGuard } from 'components/security/Guards'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; import { DeleteSurveyI18N } from 'constants/i18n'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; -import { AuthStateContext } from 'contexts/authStateContext'; +import { ConfigContext } from 'contexts/configContext'; import { DialogContext } from 'contexts/dialogContext'; import { ProjectContext } from 'contexts/projectContext'; import { SurveyContext } from 'contexts/surveyContext'; import { APIError } from 'hooks/api/useAxios'; +import { useAuthStateContext } from 'hooks/useAuthStateContext'; import { useBiohubApi } from 'hooks/useBioHubApi'; import React, { useContext, useState } from 'react'; import { useHistory } from 'react-router'; -import { Link } from 'react-router-dom'; +import { Link as RouterLink } from 'react-router-dom'; +import { hasAtLeastOneValidValue } from 'utils/authUtils'; import { getFormattedDateRangeString } from 'utils/Utils'; - -const useStyles = makeStyles((theme: Theme) => ({ - pageTitleContainer: { - maxWidth: '150ch', - overflow: 'hidden', - textOverflow: 'ellipsis' - }, - pageTitle: { - display: '-webkit-box', - '-webkit-line-clamp': 2, - '-webkit-box-orient': 'vertical', - paddingTop: theme.spacing(0.5), - paddingBottom: theme.spacing(0.5), - overflow: 'hidden' - }, - pageTitleActions: { - paddingTop: theme.spacing(0.75), - paddingBottom: theme.spacing(0.75) - } -})); +import SurveyBaseHeader from './components/SurveyBaseHeader'; /** * Survey header for a single-survey view. @@ -63,17 +37,18 @@ const useStyles = makeStyles((theme: Theme) => ({ const SurveyHeader = () => { const surveyContext = useContext(SurveyContext); const projectContext = useContext(ProjectContext); + const configContext = useContext(ConfigContext); const surveyWithDetails = surveyContext.surveyDataLoader.data; + const projectWithDetails = projectContext.projectDataLoader.data; - const classes = useStyles(); const history = useHistory(); const biohubApi = useBiohubApi(); const dialogContext = useContext(DialogContext); - const { keycloakWrapper } = useContext(AuthStateContext); + const authStateContext = useAuthStateContext(); const defaultYesNoDialogProps = { dialogTitle: 'Delete Survey?', @@ -142,11 +117,10 @@ const SurveyHeader = () => { }; // Enable delete button if you a system admin or a project admin - const enableDeleteSurveyButton = keycloakWrapper?.hasSystemRole([ - SYSTEM_ROLE.SYSTEM_ADMIN, - SYSTEM_ROLE.DATA_ADMINISTRATOR, - SYSTEM_ROLE.PROJECT_CREATOR - ]); + const enableDeleteSurveyButton = hasAtLeastOneValidValue( + [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR, SYSTEM_ROLE.PROJECT_CREATOR], + authStateContext.simsUserWrapper.roleNames + ); const [menuAnchorEl, setMenuAnchorEl] = useState(null); const [publishSurveyDialogOpen, setPublishSurveyDialogOpen] = useState(false); @@ -155,112 +129,151 @@ const SurveyHeader = () => { return ; } + const publishDate = surveyWithDetails.surveySupplementaryData.survey_metadata_publish?.event_timestamp.split(' ')[0]; + + const BIOHUB_FEATURE_FLAG = configContext?.BIOHUB_FEATURE_FLAG; + return ( <> - - - - - - - - - - Survey: {surveyWithDetails.surveyData.survey_details.survey_name} - - + + + {projectWithDetails?.projectData.project.project_name} + + + {surveyWithDetails.surveyData.survey_details.survey_name} + + + } + subTitle={ + + + + Timeline: + + + {getFormattedDateRangeString( + DATE_FORMAT.ShortMediumDateFormat, + surveyWithDetails.surveyData.survey_details.start_date, + surveyWithDetails.surveyData.survey_details.end_date + )} + + + + } + buttonJSX={ + + + {BIOHUB_FEATURE_FLAG && ( + <> - - Survey Timeline:   - {getFormattedDateRangeString( - DATE_FORMAT.ShortMediumDateFormat, - surveyWithDetails.surveyData.survey_details.start_date, - surveyWithDetails.surveyData.survey_details.end_date + variant="subtitle2" + fontSize="0.9rem" + fontWeight="700" + sx={{ + flex: '0 0 auto', + mr: { sm: 0, md: 0.5 }, + order: { sm: 3, md: 0 } + }}> + {publishDate ? ( + <> + + Published: + + + {publishDate} + + + ) : ( + + Never Published + )} - - - - - - - - - setMenuAnchorEl(null)}> - history.push('edit')}> - - - - Edit Survey Details - - {enableDeleteSurveyButton && ( - - - - - Delete Survey - - )} - - - - - - + + )} + + + + + + setMenuAnchorEl(null)}> + history.push('edit')}> + + + + Edit Survey Details + + {enableDeleteSurveyButton && ( + + + + + Delete Survey + + )} + + + } + /> - setPublishSurveyDialogOpen(false)} /> + setPublishSurveyDialogOpen(false)} /> ); }; diff --git a/app/src/features/surveys/view/SurveyPage.tsx b/app/src/features/surveys/view/SurveyPage.tsx index 5ece9f05f3..1dd18d5371 100644 --- a/app/src/features/surveys/view/SurveyPage.tsx +++ b/app/src/features/surveys/view/SurveyPage.tsx @@ -1,18 +1,27 @@ +import { mdiCog, mdiMapSearchOutline } from '@mdi/js'; +import Icon from '@mdi/react'; import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; import CircularProgress from '@mui/material/CircularProgress'; +import grey from '@mui/material/colors/grey'; import Container from '@mui/material/Container'; -import Grid from '@mui/material/Grid'; import Paper from '@mui/material/Paper'; +import Skeleton from '@mui/material/Skeleton'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; import SurveySubmissionAlertBar from 'components/publish/SurveySubmissionAlertBar'; import { SystemRoleGuard } from 'components/security/Guards'; import { SYSTEM_ROLE } from 'constants/roles'; import { CodesContext } from 'contexts/codesContext'; +import { ObservationsContext } from 'contexts/observationsContext'; import { SurveyContext } from 'contexts/surveyContext'; import SurveyDetails from 'features/surveys/view/SurveyDetails'; import React, { useContext, useEffect } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import ObservationsMap from '../observations/ObservationsMap'; +import ManualTelemetrySection from '../telemetry/ManualTelemetrySection'; import SurveyStudyArea from './components/SurveyStudyArea'; import SurveySummaryResults from './summary-results/SurveySummaryResults'; -import SurveyObservations from './survey-observations/SurveyObservations'; import SurveyAnimals from './SurveyAnimals'; import SurveyAttachments from './SurveyAttachments'; import SurveyHeader from './SurveyHeader'; @@ -27,11 +36,20 @@ import SurveyHeader from './SurveyHeader'; const SurveyPage: React.FC = () => { const codesContext = useContext(CodesContext); const surveyContext = useContext(SurveyContext); + const observationsContext = useContext(ObservationsContext); + + const numObservations: number = + observationsContext.observationsDataLoader.data?.supplementaryObservationData?.observationCount || 0; useEffect(() => { codesContext.codesDataLoader.load(); }, [codesContext.codesDataLoader]); + useEffect(() => { + observationsContext.observationsDataLoader.refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + if (!codesContext.codesDataLoader.data || !surveyContext.surveyDataLoader.data) { return ; } @@ -39,45 +57,107 @@ const SurveyPage: React.FC = () => { return ( <> - - + + + + + Observations ‌ + {!numObservations ? ( + ' ' + ) : ( + + ({numObservations}) + + )} + + + + + {observationsContext.observationsDataLoader.isLoading && ( + + + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/features/surveys/view/Partnerships.tsx b/app/src/features/surveys/view/components/Partnerships.tsx similarity index 57% rename from app/src/features/surveys/view/Partnerships.tsx rename to app/src/features/surveys/view/components/Partnerships.tsx index 116215dcc6..1bae504921 100644 --- a/app/src/features/surveys/view/Partnerships.tsx +++ b/app/src/features/surveys/view/components/Partnerships.tsx @@ -1,36 +1,16 @@ -import { Theme } from '@mui/material'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import { makeStyles } from '@mui/styles'; import assert from 'assert'; import { CodesContext } from 'contexts/codesContext'; import { SurveyContext } from 'contexts/surveyContext'; import { useContext } from 'react'; -const useStyles = makeStyles((theme: Theme) => ({ - projectPartners: { - position: 'relative', - display: 'inline-block', - marginRight: theme.spacing(1.25), - '&::after': { - content: `','`, - position: 'absolute', - top: 0 - }, - '&:last-child::after': { - display: 'none' - } - } -})); - /** * Partnerships content for a survey. * * @return {*} */ const Partnerships = () => { - const classes = useStyles(); - const codesContext = useContext(CodesContext); const surveyContext = useContext(SurveyContext); @@ -46,45 +26,30 @@ const Partnerships = () => { const hasStakeholderPartnerships = Boolean(surveyData.partnerships.stakeholder_partnerships?.length); return ( - - - - Indigenous - + + + Indigenous Partnerships {surveyData.partnerships.indigenous_partnerships?.map((indigenousPartnership: number) => { return ( - + {codes.first_nations?.find((item: any) => item.id === indigenousPartnership)?.name} ); })} - {!hasIndigenousPartnerships && None} - - - Other Partnerships - + + + Other Partnerships {surveyData.partnerships.stakeholder_partnerships?.map((stakeholderPartnership: string) => { return ( - + {stakeholderPartnership} ); })} - {!hasStakeholderPartnerships && ( - - None - - )} + {!hasStakeholderPartnerships && None} ); diff --git a/app/src/features/surveys/view/components/Permits.tsx b/app/src/features/surveys/view/components/Permits.tsx new file mode 100644 index 0000000000..a8bb2518fa --- /dev/null +++ b/app/src/features/surveys/view/components/Permits.tsx @@ -0,0 +1,44 @@ +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { CodesContext } from 'contexts/codesContext'; +import { SurveyContext } from 'contexts/surveyContext'; +import { useContext } from 'react'; + +/** + * Permit content for a survey. + * + * @return {*} + */ +const Permits = () => { + const codesContext = useContext(CodesContext); + const surveyContext = useContext(SurveyContext); + const surveyForViewData = surveyContext.surveyDataLoader.data; + + if (!surveyForViewData || !codesContext.codesDataLoader.data) { + return <>; + } + + const { + surveyData: { permit } + } = surveyForViewData; + + return ( + + {permit.permits?.map((item) => { + return ( + + {`#${item.permit_number}`} + {item.permit_type} + + ); + })} + {!permit.permits.length && ( + + No Permits + + )} + + ); +}; + +export default Permits; diff --git a/app/src/features/surveys/view/components/SamplingMethods.tsx b/app/src/features/surveys/view/components/SamplingMethods.tsx new file mode 100644 index 0000000000..f1038fec6c --- /dev/null +++ b/app/src/features/surveys/view/components/SamplingMethods.tsx @@ -0,0 +1,115 @@ +import Box from '@mui/material/Box'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; +import Typography from '@mui/material/Typography'; +import { CodesContext } from 'contexts/codesContext'; +import { SurveyContext } from 'contexts/surveyContext'; +import { IStratum } from 'features/surveys/components/SurveySiteSelectionForm'; +import { IGetSurveyBlock } from 'interfaces/useSurveyApi.interface'; +import { useContext } from 'react'; + +/** + * General information content for a survey. + * + * @return {*} + */ +const SamplingMethods = () => { + const codesContext = useContext(CodesContext); + const surveyContext = useContext(SurveyContext); + const surveyForViewData = surveyContext.surveyDataLoader.data; + + if (!surveyForViewData || !codesContext.codesDataLoader.data) { + return <>; + } + + const { + surveyData: { site_selection, blocks } + } = surveyForViewData; + + return ( + + + Site Selection Strategies + + {site_selection.strategies?.map((strategy: string) => { + return ( + + {strategy} + + ); + })} + + + + {site_selection.stratums.length > 0 && ( + + Stratums + + {site_selection.stratums?.map((stratum: IStratum) => { + return ( + + + + ); + })} + + + )} + + {blocks.length > 0 && ( + + Blocks + + {blocks?.map((block: IGetSurveyBlock) => { + return ( + + + + ); + })} + + + )} + + ); +}; + +export default SamplingMethods; diff --git a/app/src/features/surveys/view/components/SurveyBaseHeader.tsx b/app/src/features/surveys/view/components/SurveyBaseHeader.tsx new file mode 100644 index 0000000000..aba7f23bff --- /dev/null +++ b/app/src/features/surveys/view/components/SurveyBaseHeader.tsx @@ -0,0 +1,67 @@ +import Box from '@mui/material/Box'; +import { grey } from '@mui/material/colors'; +import Container from '@mui/material/Container'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; + +interface ISurveyHeader { + title: string; + subTitle?: JSX.Element; + breadCrumb?: JSX.Element; + buttonJSX?: JSX.Element; +} +/** + * Generic Survey header for all survey views + * + * @return {*} + */ +const SurveyBaseHeader = (props: ISurveyHeader) => { + const { title, subTitle, breadCrumb, buttonJSX } = props; + + return ( + + + {breadCrumb} + + + + {title} + + {subTitle} + + {buttonJSX} + + + + ); +}; + +export default SurveyBaseHeader; diff --git a/app/src/features/surveys/view/components/SurveyFundingSources.tsx b/app/src/features/surveys/view/components/SurveyFundingSources.tsx index 081e34ee0e..8ae5529dfa 100644 --- a/app/src/features/surveys/view/components/SurveyFundingSources.tsx +++ b/app/src/features/surveys/view/components/SurveyFundingSources.tsx @@ -1,6 +1,4 @@ import Box from '@mui/material/Box'; -import List from '@mui/material/List'; -import ListItem from '@mui/material/ListItem'; import Typography from '@mui/material/Typography'; import { SurveyContext } from 'contexts/surveyContext'; import { useContext } from 'react'; @@ -24,32 +22,29 @@ const SurveyFundingSources = () => { } = surveyForViewData; return ( - <> - - {funding_sources.length > 0 ? ( - <> - {funding_sources.map((surveyFundingSource) => ( - - - - - {surveyFundingSource.funding_source_name} - -  – {getFormattedAmount(surveyFundingSource.amount)} - - - - - - ))} - - ) : ( - - No Funding Sources - - )} - - + + {funding_sources.length > 0 ? ( + <> + {funding_sources.map((surveyFundingSource) => ( + + {surveyFundingSource.funding_source_name} + + {getFormattedAmount(surveyFundingSource.amount)} + + + ))} + + ) : ( + + No Funding Sources + + )} + ); }; diff --git a/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx b/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx index a430ebca63..5e70476522 100644 --- a/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx +++ b/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx @@ -1,7 +1,7 @@ import { CodesContext, ICodesContext } from 'contexts/codesContext'; import { SurveyContext } from 'contexts/surveyContext'; import { DataLoader } from 'hooks/useDataLoader'; -import { IGetObservationSubmissionResponse } from 'interfaces/useObservationApi.interface'; +import { IGetObservationSubmissionResponse } from 'interfaces/useDwcaApi.interface'; import { IGetSurveyForViewResponse } from 'interfaces/useSurveyApi.interface'; import { codes } from 'test-helpers/code-helpers'; import { getObservationSubmissionResponse, getSurveyForViewResponse } from 'test-helpers/survey-helpers'; @@ -30,6 +30,8 @@ describe('SurveyGeneralInformation', () => { >; const mockSummaryDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; + const mockCritterDataLoader = { data: [] } as DataLoader; + const mockDeploymentDataLoader = { data: [] } as DataLoader; const { getByTestId } = render( @@ -41,14 +43,16 @@ describe('SurveyGeneralInformation', () => { artifactDataLoader: mockArtifactDataLoader, observationDataLoader: mockObservationDataLoader, summaryDataLoader: mockSummaryDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader + sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader, + deploymentDataLoader: mockDeploymentDataLoader }}> ); - expect(getByTestId('survey_timeline').textContent).toEqual('Oct 10, 1998 - Feb 26, 2021'); + expect(getByTestId('survey_timeline').textContent).toEqual('October 10, 1998 - February 26, 2021'); }); it('renders correctly with null end date', () => { @@ -72,6 +76,8 @@ describe('SurveyGeneralInformation', () => { >; const mockSummaryDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; + const mockCritterDataLoader = { data: [] } as DataLoader; + const mockDeploymentDataLoader = { data: [] } as DataLoader; const { getByTestId } = render( @@ -83,7 +89,9 @@ describe('SurveyGeneralInformation', () => { artifactDataLoader: mockArtifactDataLoader, observationDataLoader: mockObservationDataLoader, summaryDataLoader: mockSummaryDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader + sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader, + deploymentDataLoader: mockDeploymentDataLoader }}> @@ -104,6 +112,9 @@ describe('SurveyGeneralInformation', () => { const mockSummaryDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; + const mockCritterDataLoader = { data: [] } as DataLoader; + const mockDeploymentDataLoader = { data: [] } as DataLoader; + const { container } = render( { artifactDataLoader: mockArtifactDataLoader, observationDataLoader: mockObservationDataLoader, summaryDataLoader: mockSummaryDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader + sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader, + deploymentDataLoader: mockDeploymentDataLoader }}> diff --git a/app/src/features/surveys/view/components/SurveyGeneralInformation.tsx b/app/src/features/surveys/view/components/SurveyGeneralInformation.tsx index 6ab748082b..74358ac65c 100644 --- a/app/src/features/surveys/view/components/SurveyGeneralInformation.tsx +++ b/app/src/features/surveys/view/components/SurveyGeneralInformation.tsx @@ -1,8 +1,4 @@ import Box from '@mui/material/Box'; -import Divider from '@mui/material/Divider'; -import Grid from '@mui/material/Grid'; -import List from '@mui/material/List'; -import ListItem from '@mui/material/ListItem'; import Typography from '@mui/material/Typography'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; import { CodesContext } from 'contexts/codesContext'; @@ -25,107 +21,103 @@ const SurveyGeneralInformation = () => { } const { - surveyData: { survey_details, species, permit } + surveyData: { survey_details, species } } = surveyForViewData; const codes = codesContext.codesDataLoader.data; - const surveyTypes = + const surveyTypes: string | null = codes.type .filter((code) => survey_details.survey_types.includes(code.id)) .map((code) => code.name) - .join(', ') || ''; + .join(', ') || null; return ( - <> - - General Information - - - - - - Types - - {surveyTypes ? <>{surveyTypes} : 'No Types'} - - - - Timeline - - - {survey_details.end_date ? ( - <> - {getFormattedDateRangeString( - DATE_FORMAT.ShortMediumDateFormat, - survey_details.start_date, - survey_details.end_date - )} - - ) : ( - <> - Start Date:{' '} - {getFormattedDateRangeString(DATE_FORMAT.ShortMediumDateFormat, survey_details.start_date)} - - )} - - - - - Focal Species - - {species.focal_species_names?.map((focalSpecies: string, index: number) => { - return ( - - {focalSpecies} - - ); - })} - - - - Ancillary Species - - {species.ancillary_species_names?.map((ancillarySpecies: string, index: number) => { - return ( - - {ancillarySpecies} - - ); - })} - {species.ancillary_species_names?.length <= 0 && ( - - No Ancillary Species - + + + Type + {surveyTypes ?? 'No Types'} + + + + Timeline + + {survey_details.end_date ? ( + <> + {getFormattedDateRangeString( + DATE_FORMAT.MediumDateFormat, + survey_details.start_date, + survey_details.end_date )} - - + + ) : ( + <> + Start Date: + {getFormattedDateRangeString(DATE_FORMAT.ShortMediumDateFormat, survey_details.start_date)} + + )} + + + + + Species of Interest + + {species.focal_species_names?.map((focalSpecies: string, index: number) => { + return ( + + {focalSpecies} + + ); + })} - - Permits - - - {!permit.permits.length && ( - - - No Permits - - - )} - {permit.permits?.map((item, index: number) => { + + Secondary Species + + {species.ancillary_species_names?.map((ancillarySpecies: string, index: number) => { return ( - - - {item.permit_type} - {item.permit_number} - - + + {ancillarySpecies} + ); })} - + {species.ancillary_species_names?.length <= 0 && ( + No secondary species of interest + )} + - + ); }; diff --git a/app/src/features/surveys/view/components/SurveyParticipants.tsx b/app/src/features/surveys/view/components/SurveyParticipants.tsx new file mode 100644 index 0000000000..57f761d55d --- /dev/null +++ b/app/src/features/surveys/view/components/SurveyParticipants.tsx @@ -0,0 +1,41 @@ +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { SurveyContext } from 'contexts/surveyContext'; +import { useContext } from 'react'; + +/** + * General information content for a survey. + * + * @return {*} + */ +const SurveyParticipants = () => { + const surveyContext = useContext(SurveyContext); + const surveyForViewData = surveyContext.surveyDataLoader.data; + + if (!surveyForViewData) { + return <>; + } + + const { + surveyData: { participants } + } = surveyForViewData; + + return ( + <> + {participants.length > 0 ? ( + + {participants.map((surveyParticipants) => ( + + {surveyParticipants.display_name} + {surveyParticipants.survey_job_name} + + ))} + + ) : ( + No participants + )} + + ); +}; + +export default SurveyParticipants; diff --git a/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx b/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx index 7349c22468..d12f3d456e 100644 --- a/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx +++ b/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx @@ -1,6 +1,6 @@ import { SurveyContext } from 'contexts/surveyContext'; import { DataLoader } from 'hooks/useDataLoader'; -import { IGetObservationSubmissionResponse } from 'interfaces/useObservationApi.interface'; +import { IGetObservationSubmissionResponse } from 'interfaces/useDwcaApi.interface'; import { IGetSurveyForViewResponse } from 'interfaces/useSurveyApi.interface'; import { getObservationSubmissionResponse, getSurveyForViewResponse } from 'test-helpers/survey-helpers'; import { cleanup, render } from 'test-helpers/test-utils'; @@ -21,6 +21,8 @@ describe('SurveyProprietaryData', () => { const mockSummaryDataLoader = { data: null } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; + const mockCritterDataLoader = { data: [] } as DataLoader; + const mockDeploymentDataLoader = { data: [] } as DataLoader; const { getByTestId } = render( { artifactDataLoader: mockArtifactDataLoader, observationDataLoader: mockObservationDataLoader, summaryDataLoader: mockSummaryDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader + sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader, + deploymentDataLoader: mockDeploymentDataLoader }}> @@ -54,6 +58,8 @@ describe('SurveyProprietaryData', () => { const mockSummaryDataLoader = { data: null } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; + const mockCritterDataLoader = { data: [] } as DataLoader; + const mockDeploymentDataLoader = { data: [] } as DataLoader; const { getByTestId } = render( { artifactDataLoader: mockArtifactDataLoader, observationDataLoader: mockObservationDataLoader, summaryDataLoader: mockSummaryDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader + sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader, + deploymentDataLoader: mockDeploymentDataLoader }}> @@ -85,6 +93,8 @@ describe('SurveyProprietaryData', () => { const mockSummaryDataLoader = { data: null } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; + const mockCritterDataLoader = { data: [] } as DataLoader; + const mockDeploymentDataLoader = { data: [] } as DataLoader; const { container } = render( { artifactDataLoader: mockArtifactDataLoader, observationDataLoader: mockObservationDataLoader, summaryDataLoader: mockSummaryDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader + sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader, + deploymentDataLoader: mockDeploymentDataLoader }}> diff --git a/app/src/features/surveys/view/components/SurveyProprietaryData.tsx b/app/src/features/surveys/view/components/SurveyProprietaryData.tsx index 2ef4e6a7ff..a221fb0e8a 100644 --- a/app/src/features/surveys/view/components/SurveyProprietaryData.tsx +++ b/app/src/features/surveys/view/components/SurveyProprietaryData.tsx @@ -1,5 +1,4 @@ import Box from '@mui/material/Box'; -import Grid from '@mui/material/Grid'; import Typography from '@mui/material/Typography'; import { SurveyContext } from 'contexts/surveyContext'; import { useContext } from 'react'; @@ -23,47 +22,33 @@ const SurveyProprietaryData = () => { return ( <> - -
- {!proprietor && ( - - - - The data captured in this survey is not proprietary. - - - - )} - {proprietor && ( - - - - Proprietor Name - - - {proprietor.proprietor_name} - - - - - Data Category - - - {proprietor.proprietor_type_name} - - - - - Category Rationale - - - {proprietor.category_rationale} - - - - )} -
-
+ {!proprietor && ( + + + The data captured in this survey is not proprietary. + + + )} + {proprietor && ( + + + Proprietor Name + + {proprietor.proprietor_name} + + + + Data Category + + {proprietor.proprietor_type_name} + + + + Category Rationale + {proprietor.category_rationale} + + + )} ); }; diff --git a/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx b/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx index 228cfb5505..6e6a9f6d91 100644 --- a/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx +++ b/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx @@ -24,6 +24,8 @@ describe('SurveyPurposeAndMethodologyData', () => { const mockObservationsDataLoader = { data: null } as DataLoader; const mockSummaryDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; + const mockCritterDataLoader = { data: [] } as DataLoader; + const mockDeploymentDataLoader = { data: [] } as DataLoader; const { getByTestId, getAllByTestId } = render( @@ -35,16 +37,16 @@ describe('SurveyPurposeAndMethodologyData', () => { artifactDataLoader: mockArtifactDataLoader, observationDataLoader: mockObservationsDataLoader, summaryDataLoader: mockSummaryDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader + sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader, + deploymentDataLoader: mockDeploymentDataLoader }}> ); - expect(getByTestId('survey_intended_outcome').textContent).toEqual('Intended Outcome 1'); - expect(getByTestId('survey_field_method').textContent).toEqual('Recruitment'); - expect(getByTestId('survey_ecological_season').textContent).toEqual('Season 1'); + 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' @@ -75,8 +77,10 @@ describe('SurveyPurposeAndMethodologyData', () => { const mockObservationsDataLoader = { data: null } as DataLoader; const mockSummaryDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; + const mockCritterDataLoader = { data: [] } as DataLoader; + const mockDeploymentDataLoader = { data: [] } as DataLoader; - const { getByTestId, getAllByTestId } = render( + const { getByTestId, getAllByTestId, queryByTestId } = render( { artifactDataLoader: mockArtifactDataLoader, observationDataLoader: mockObservationsDataLoader, summaryDataLoader: mockSummaryDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader + sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader, + deploymentDataLoader: mockDeploymentDataLoader }}> ); - expect(getByTestId('survey_intended_outcome').textContent).toEqual('Intended Outcome 1'); - expect(getByTestId('survey_field_method').textContent).toEqual('Recruitment'); - expect(getByTestId('survey_ecological_season').textContent).toEqual('Season 1'); + 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('No additional details'); + 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 12b578c14a..b966ca42df 100644 --- a/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.tsx +++ b/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.tsx @@ -1,37 +1,16 @@ -import { Theme } from '@mui/material'; import Box from '@mui/material/Box'; -import Grid from '@mui/material/Grid'; import Typography from '@mui/material/Typography'; -import { makeStyles } from '@mui/styles'; import assert from 'assert'; import { CodesContext } from 'contexts/codesContext'; import { SurveyContext } from 'contexts/surveyContext'; import { useContext } from 'react'; -const useStyles = makeStyles((theme: Theme) => ({ - vantageCodes: { - position: 'relative', - display: 'inline-block', - marginRight: theme.spacing(1.25), - '&::after': { - content: `','`, - position: 'absolute', - top: 0 - }, - '&:last-child::after': { - display: 'none' - } - } -})); - /** * Purpose and Methodology data content for a survey. * * @return {*} */ const SurveyPurposeAndMethodologyData = () => { - const classes = useStyles(); - const codesContext = useContext(CodesContext); const surveyContext = useContext(SurveyContext); @@ -44,73 +23,71 @@ const SurveyPurposeAndMethodologyData = () => { const surveyData = surveyContext.surveyDataLoader.data.surveyData; return ( - <> - -
- - - - Intended Outcome - - - {Boolean(surveyData.purpose_and_methodology.intended_outcome_id) && - codes?.intended_outcomes?.find( - (item: any) => item.id === surveyData.purpose_and_methodology.intended_outcome_id - )?.name} - - - - - Field Method - - - {Boolean(surveyData.purpose_and_methodology.field_method_id) && - codes?.field_methods?.find( - (item: any) => item.id === surveyData.purpose_and_methodology.field_method_id - )?.name} - - - - - - Ecological Season - - - {Boolean(surveyData.purpose_and_methodology.ecological_season_id) && - codes?.ecological_seasons?.find( - (item: any) => item.id === surveyData.purpose_and_methodology.ecological_season_id - )?.name} - - - - - Vantage Code - - {surveyData.purpose_and_methodology.vantage_code_ids?.map((vc_id: number, index: number) => { - return ( - - {codes?.vantage_codes?.find((item: any) => item.id === vc_id)?.name} - - ); - })} - - - - Additional Details - - - {surveyData.purpose_and_methodology.additional_details || 'No additional details'} - - - -
+ + + 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.additional_details && ( + <> + + Additional Details + + {surveyData.purpose_and_methodology.additional_details} + + + + )} + + + Vantage Code(s) + {surveyData.purpose_and_methodology.vantage_code_ids?.map((vc_id: number, index: 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 d6147e617c..f713a5d535 100644 --- a/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx +++ b/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx @@ -21,7 +21,7 @@ const mockUseApi = { } }; -describe('SurveyStudyArea', () => { +describe.skip('SurveyStudyArea', () => { beforeEach(() => { mockBiohubApi.mockImplementation(() => mockUseApi); mockUseApi.survey.getSurveyForView.mockClear(); @@ -45,6 +45,8 @@ describe('SurveyStudyArea', () => { const mockObservationsDataLoader = { data: null } as DataLoader; const mockSummaryDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; + const mockCritterDataLoader = { data: [] } as DataLoader; + const mockDeploymentDataLoader = { data: [] } as DataLoader; const { container } = render( { artifactDataLoader: mockArtifactDataLoader, observationDataLoader: mockObservationsDataLoader, summaryDataLoader: mockSummaryDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader + sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader, + deploymentDataLoader: mockDeploymentDataLoader }}> @@ -81,6 +85,8 @@ describe('SurveyStudyArea', () => { const mockObservationsDataLoader = { data: null } as DataLoader; const mockSummaryDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; + const mockCritterDataLoader = { data: [] } as DataLoader; + const mockDeploymentDataLoader = { data: [] } as DataLoader; const { container, queryByTestId } = render( { artifactDataLoader: mockArtifactDataLoader, observationDataLoader: mockObservationsDataLoader, summaryDataLoader: mockSummaryDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader + sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader, + deploymentDataLoader: mockDeploymentDataLoader }}> @@ -109,6 +117,8 @@ describe('SurveyStudyArea', () => { const mockObservationsDataLoader = { data: null } as DataLoader; const mockSummaryDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; + const mockCritterDataLoader = { data: [] } as DataLoader; + const mockDeploymentDataLoader = { data: [] } as DataLoader; const { container, getByTestId } = render( { artifactDataLoader: mockArtifactDataLoader, observationDataLoader: mockObservationsDataLoader, summaryDataLoader: mockSummaryDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader + sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader, + deploymentDataLoader: mockDeploymentDataLoader }}> @@ -141,6 +153,8 @@ describe('SurveyStudyArea', () => { const mockObservationsDataLoader = { data: null } as DataLoader; const mockSummaryDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; + const mockCritterDataLoader = { data: [] } as DataLoader; + const mockDeploymentDataLoader = { data: [] } as DataLoader; const mockProjectAuthStateContext: IProjectAuthStateContext = { getProjectParticipant: () => null, @@ -161,7 +175,9 @@ describe('SurveyStudyArea', () => { artifactDataLoader: mockArtifactDataLoader, observationDataLoader: mockObservationsDataLoader, summaryDataLoader: mockSummaryDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader + sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader, + deploymentDataLoader: mockDeploymentDataLoader }}> @@ -233,6 +249,8 @@ describe('SurveyStudyArea', () => { const mockObservationsDataLoader = { data: null } as DataLoader; const mockSummaryDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; + const mockCritterDataLoader = { data: [] } as DataLoader; + const mockDeploymentDataLoader = { data: [] } as DataLoader; mockUseApi.survey.getSurveyForView.mockResolvedValue({ surveyData: { @@ -270,7 +288,9 @@ describe('SurveyStudyArea', () => { artifactDataLoader: mockArtifactDataLoader, observationDataLoader: mockObservationsDataLoader, summaryDataLoader: mockSummaryDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader + sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader, + deploymentDataLoader: mockDeploymentDataLoader }}> diff --git a/app/src/features/surveys/view/components/SurveyStudyArea.tsx b/app/src/features/surveys/view/components/SurveyStudyArea.tsx index 1dc2c3d129..5904780c22 100644 --- a/app/src/features/surveys/view/components/SurveyStudyArea.tsx +++ b/app/src/features/surveys/view/components/SurveyStudyArea.tsx @@ -1,63 +1,10 @@ -import { mdiChevronRight, mdiPencilOutline, mdiRefresh } from '@mdi/js'; -import Icon from '@mdi/react'; -import { Theme } from '@mui/material'; 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 IconButton from '@mui/material/IconButton'; -import Typography from '@mui/material/Typography'; -import { makeStyles } from '@mui/styles'; import assert from 'assert'; -import FullScreenViewMapDialog from 'components/boundary/FullScreenViewMapDialog'; import InferredLocationDetails, { IInferredLayers } from 'components/boundary/InferredLocationDetails'; -import EditDialog from 'components/dialog/EditDialog'; -import { ErrorDialog, IErrorDialogProps } from 'components/dialog/ErrorDialog'; -import { IMarkerLayer } from 'components/map/components/MarkerCluster'; -import { IStaticLayer } from 'components/map/components/StaticLayers'; -import MapContainer from 'components/map/MapContainer'; -import { ProjectRoleGuard } from 'components/security/Guards'; -import { H2ButtonToolbar } from 'components/toolbar/ActionToolbars'; -import { EditSurveyStudyAreaI18N } from 'constants/i18n'; -import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; import { SurveyContext } from 'contexts/surveyContext'; -import StudyAreaForm, { - ISurveyLocationForm, - SurveyLocationInitialValues, - SurveyLocationYupSchema -} from 'features/surveys/components/StudyAreaForm'; import { Feature } from 'geojson'; -import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; -import useDataLoader from 'hooks/useDataLoader'; -import useDataLoaderError from 'hooks/useDataLoaderError'; -import { LatLngBoundsExpression } from 'leaflet'; -import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { calculateUpdatedMapBounds } from 'utils/mapBoundaryUploadHelpers'; -import { parseSpatialDataByType } from 'utils/spatial-utils'; - -const useStyles = makeStyles((theme: Theme) => ({ - zoomToBoundaryExtentBtn: { - padding: '3px', - borderRadius: '4px', - background: '#ffffff', - color: '#000000', - border: '2px solid rgba(0,0,0,0.2)', - backgroundClip: 'padding-box', - '&:hover': { - backgroundColor: '#eeeeee' - } - }, - metaSectionHeader: { - color: grey[600], - fontWeight: 700, - textTransform: 'uppercase', - '& + hr': { - marginTop: theme.spacing(0.75), - marginBottom: theme.spacing(0.75) - } - } -})); +import { useContext, useEffect, useState } from 'react'; /** * View survey - Study area section @@ -65,30 +12,10 @@ const useStyles = makeStyles((theme: Theme) => ({ * @return {*} */ const SurveyStudyArea = () => { - const classes = useStyles(); - const biohubApi = useBiohubApi(); - const surveyContext = useContext(SurveyContext); - + const biohubApi = useBiohubApi(); // Survey data must be loaded by the parent before this component is rendered assert(surveyContext.surveyDataLoader.data); - - const occurrence_submission_id = - surveyContext.observationDataLoader.data?.surveyObservationData.occurrence_submission_id; - - const [markerLayers, setMarkerLayers] = useState([]); - const [staticLayers, setStaticLayers] = useState([]); - - const surveyLocations = surveyContext.surveyDataLoader.data?.surveyData?.locations; - const surveyLocation = surveyLocations[0] || null; - const surveyGeometry = useMemo(() => surveyLocation?.geojson || [], [surveyLocation]); - - const [openEditDialog, setOpenEditDialog] = useState(false); - const [studyAreaFormData, setStudyAreaFormData] = useState(SurveyLocationInitialValues); - - const [bounds, setBounds] = useState(undefined); - const [showFullScreenViewMapDialog, setShowFullScreenViewMapDialog] = useState(false); - const [nonEditableGeometries, setNonEditableGeometries] = useState([]); const [inferredLayersInfo, setInferredLayersInfo] = useState({ parks: [], nrm: [], @@ -96,239 +23,58 @@ const SurveyStudyArea = () => { wmu: [] }); - const mapDataLoader = useDataLoader((projectId: number, occurrenceSubmissionId: number) => - biohubApi.observation.getOccurrencesForView(projectId, occurrenceSubmissionId) - ); - useDataLoaderError(mapDataLoader, () => { - return { - dialogTitle: 'Error Loading Map Data', - dialogText: - 'An error has occurred while attempting to load map data, please try again. If the error persists, please contact your system administrator.' - }; - }); - useEffect(() => { - if (mapDataLoader.data) { - const result = parseSpatialDataByType(mapDataLoader.data); + let isMounted = true; - setMarkerLayers(result.markerLayers); - setStaticLayers(result.staticLayers); - } - }, [mapDataLoader.data]); - - useEffect(() => { - if (occurrence_submission_id) { - mapDataLoader.load(surveyContext.projectId, occurrence_submission_id); - } - }, [mapDataLoader, occurrence_submission_id, surveyContext.projectId]); + const locations = surveyContext.surveyDataLoader.data?.surveyData?.locations; - const zoomToBoundaryExtent = useCallback(() => { - setBounds(calculateUpdatedMapBounds(surveyGeometry)); - }, [surveyGeometry]); + const getRegions = async (features: Feature[]) => { + try { + const regions = await biohubApi.spatial.getRegions(features); - useEffect(() => { - const nonEditableGeometriesResult = surveyGeometry.map((geom: Feature) => { - return { feature: geom }; - }); - - if (nonEditableGeometriesResult.length) { - setNonEditableGeometries(nonEditableGeometriesResult); - } - - zoomToBoundaryExtent(); - }, [surveyGeometry, occurrence_submission_id, setNonEditableGeometries, zoomToBoundaryExtent]); - - // TODO: This component should not define error dialog props in state and should instead consume the dialog context. - const [errorDialogProps, setErrorDialogProps] = useState({ - dialogTitle: EditSurveyStudyAreaI18N.editErrorTitle, - dialogText: EditSurveyStudyAreaI18N.editErrorText, - open: false, - onClose: () => { - setErrorDialogProps({ ...errorDialogProps, open: false }); - }, - onOk: () => { - setErrorDialogProps({ ...errorDialogProps, open: false }); - } - }); - - const showErrorDialog = (textDialogProps?: Partial) => { - setErrorDialogProps({ ...errorDialogProps, ...textDialogProps, open: true }); - }; - - const handleDialogEditOpen = () => { - if (!surveyLocation) { - return; - } - - setStudyAreaFormData({ - locations: [ - { - survey_location_id: surveyLocation.survey_location_id, - name: surveyLocation.name, - description: surveyLocation.description, - geojson: surveyLocation.geojson, - revision_count: surveyLocation.revision_count + if (!isMounted) { + return; } - ] - }); - - setOpenEditDialog(true); - }; - const handleDialogEditSave = async (values: ISurveyLocationForm) => { - if (!surveyLocation) { - return; - } - - try { - const surveyData = { - locations: values.locations.map((item) => { - return { - survey_location_id: item.survey_location_id, - name: item.name, - description: item.description, - geojson: item.geojson, - revision_count: surveyLocation.revision_count - }; - }) - }; + setInferredLayersInfo({ + parks: regions.regions + .filter((item) => item.sourceLayer === 'WHSE_TANTALIS.TA_PARK_ECORES_PA_SVW') + .map((item) => item.regionName), + nrm: regions.regions + .filter((item) => item.sourceLayer === 'WHSE_ADMIN_BOUNDARIES.ADM_NR_REGIONS_SPG') + .map((item) => item.regionName), + env: regions.regions + .filter((item) => item.sourceLayer === 'WHSE_ADMIN_BOUNDARIES.EADM_WLAP_REGION_BND_AREA_SVW') + .map((item) => item.regionName), + wmu: regions.regions + .filter((item) => item.sourceLayer === 'WHSE_WILDLIFE_MANAGEMENT.WAA_WILDLIFE_MGMT_UNITS_SVW') + .map((item) => item.regionName) + }); + } catch (error) { + console.error(error); + } + }; - await biohubApi.survey.updateSurvey(surveyContext.projectId, surveyContext.surveyId, surveyData); - } catch (error) { - const apiError = error as APIError; - showErrorDialog({ dialogText: apiError.message, dialogErrorDetails: apiError.errors, open: true }); - return; - } finally { - setOpenEditDialog(false); + if (locations) { + const features: Feature[] = []; + locations.forEach((item) => { + item.geojson.forEach((geo) => { + features.push(geo); + }); + }); + getRegions(features); } - surveyContext.surveyDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); - }; - - const handleOpenFullScreenMap = () => { - setShowFullScreenViewMapDialog(true); - }; - - const handleCloseFullScreenMap = () => { - setShowFullScreenViewMapDialog(false); - }; + return () => { + isMounted = false; + }; + }, [biohubApi.spatial, surveyContext.surveyDataLoader.data?.surveyData?.locations]); return ( - <> - , - initialValues: studyAreaFormData, - validationSchema: SurveyLocationYupSchema - }} - onCancel={() => setOpenEditDialog(false)} - onSave={handleDialogEditSave} - /> - - - } - description={surveyLocation?.name} - layers={} - backButtonTitle={'Back To Survey'} - mapTitle={'Study Area'} - /> - - - - } - buttonOnClick={() => handleDialogEditOpen()} - buttonProps={{ variant: 'text' }} - renderButton={(buttonProps) => ( - - -
- + + + ); }; -/** - * Memoized wrapper of `MapContainer` to ensure the map only re-renders if specificF props change. - * - * @return {*} - */ -const MemoizedMapContainer = React.memo(MapContainer, (prevProps, nextProps) => { - return ( - prevProps.nonEditableGeometries === nextProps.nonEditableGeometries && - prevProps.bounds === nextProps.bounds && - prevProps.markerLayers === nextProps.markerLayers && - prevProps.staticLayers === nextProps.staticLayers - ); -}); - export default SurveyStudyArea; diff --git a/app/src/features/surveys/view/survey-animals/AddEditAnimal.test.tsx b/app/src/features/surveys/view/survey-animals/AddEditAnimal.test.tsx new file mode 100644 index 0000000000..ea897970a3 --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/AddEditAnimal.test.tsx @@ -0,0 +1,209 @@ +import { AuthStateContext } from 'contexts/authStateContext'; +import { IProjectAuthStateContext, ProjectAuthStateContext } from 'contexts/projectAuthStateContext'; +import { IProjectContext, ProjectContext } from 'contexts/projectContext'; +import { ISurveyContext, SurveyContext } from 'contexts/surveyContext'; +import * as Formik from 'formik'; +import { FieldArray, FieldArrayRenderProps } from 'formik'; +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 { AddEditAnimal } from './AddEditAnimal'; +import { AnimalSchema, AnimalSex, IAnimal } from './animal'; +import { ANIMAL_SECTIONS_FORM_MAP, IAnimalSections } from './animal-sections'; + +jest.mock('hooks/useQuery', () => ({ useQuery: () => ({ cid: 0 }) })); +jest.mock('../../../../hooks/useBioHubApi.ts'); +jest.mock('../../../../hooks/useTelemetryApi'); +jest.mock('../../../../hooks/useCritterbaseApi'); +const mockFormik = jest.spyOn(Formik, 'useFormikContext'); +const mockBiohubApi = useBiohubApi as jest.Mock; +const mockTelemetryApi = useTelemetryApi as jest.Mock; +const mockCritterbaseApi = useCritterbaseApi as jest.Mock; + +const mockValues: IAnimal = { + general: { + taxon_id: 'taxon', + taxon_name: 'taxon_name', + animal_id: 'alias', + critter_id: 'critter', + sex: AnimalSex.UNKNOWN, + wlh_id: '1' + }, + captures: [{ projection_mode: 'utm' } as any], + markings: [], + measurements: [], + mortality: [{ projection_mode: 'utm' } as any], + collectionUnits: [], + family: [], + device: [], + images: [] +}; + +const mockUseFormik = { + submitForm: jest.fn(), + isValid: true, + resetForm: jest.fn(), + values: mockValues, + isSubmitting: false, + initialValues: mockValues, + isValidating: false, + status: undefined +} as any; + +const mockUseBiohub = { + survey: { + getSurveyCritters: jest.fn(), + getDeploymentsInSurvey: jest.fn() + } +}; + +const mockUseTelemetry = { + devices: { + getDeviceDetails: jest.fn() + } +}; + +const mockUseCritterbase = { + 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 = (section: IAnimalSections) => ( + + + + + + {}}> + + {(formikArrayHelpers: FieldArrayRenderProps) => ( + + )} + + + + + + + +); + +describe('AddEditAnimal', () => { + beforeEach(async () => { + mockFormik.mockImplementation(() => mockUseFormik); + 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 General section', async () => { + const screen = render(page('General')); + await waitFor(() => { + const general = screen.getByText('General'); + expect(general).toBeInTheDocument(); + }); + }); + it('should render the Ecological Units section and open dialog', async () => { + const screen = render(page('Ecological Units')); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Add Unit' })).toBeInTheDocument(); + }); + await waitFor(() => { + fireEvent.click(screen.getByRole('button', { name: 'Add Unit' })); + }); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + it('should render the Markings section and open dialog', async () => { + const screen = render(page('Markings')); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Add Marking' })).toBeInTheDocument(); + }); + await waitFor(() => { + fireEvent.click(screen.getByRole('button', { name: 'Add Marking' })); + }); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + it('should render the Measurement section and open dialog', async () => { + const screen = render(page('Measurements')); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Add Measurement' })).toBeInTheDocument(); + }); + await waitFor(() => { + fireEvent.click(screen.getByRole('button', { name: 'Add Measurement' })); + }); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + it('should render the Family section and open dialog', async () => { + const screen = render(page('Family')); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Add Relationship' })).toBeInTheDocument(); + }); + await waitFor(() => { + fireEvent.click(screen.getByRole('button', { name: 'Add Relationship' })); + }); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); +}); diff --git a/app/src/features/surveys/view/survey-animals/AddEditAnimal.tsx b/app/src/features/surveys/view/survey-animals/AddEditAnimal.tsx new file mode 100644 index 0000000000..4c9cac64d0 --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/AddEditAnimal.tsx @@ -0,0 +1,296 @@ +import { mdiPlus } from '@mdi/js'; +import Icon from '@mdi/react'; +import LoadingButton from '@mui/lab/LoadingButton/LoadingButton'; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Toolbar, + Typography, + useMediaQuery, + useTheme +} from '@mui/material'; +import CircularProgress from '@mui/material/CircularProgress'; +import grey from '@mui/material/colors/grey'; +import Stack from '@mui/system/Stack'; +import { SurveyAnimalsI18N } from 'constants/i18n'; +import { DialogContext } from 'contexts/dialogContext'; +import { SurveyContext } from 'contexts/surveyContext'; +import { FieldArrayRenderProps, useFormikContext } from 'formik'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { useQuery } from 'hooks/useQuery'; +import { useTelemetryApi } from 'hooks/useTelemetryApi'; +import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; +import { useContext, useEffect, useMemo, useState } from 'react'; +import { setMessageSnackbar } from 'utils/Utils'; +import { ANIMAL_FORM_MODE, IAnimal } from './animal'; +import { ANIMAL_SECTIONS_FORM_MAP, IAnimalSections } from './animal-sections'; +import { AnimalSectionDataCards } from './AnimalSectionDataCards'; +import { CaptureAnimalFormContent } from './form-sections/CaptureAnimalForm'; +import { CollectionUnitAnimalFormContent } from './form-sections/CollectionUnitAnimalForm'; +import { FamilyAnimalFormContent } from './form-sections/FamilyAnimalForm'; +import GeneralAnimalForm from './form-sections/GeneralAnimalForm'; +import { MarkingAnimalFormContent } from './form-sections/MarkingAnimalForm'; +import MeasurementAnimalFormContent from './form-sections/MeasurementAnimalForm'; +import { MortalityAnimalFormContent } from './form-sections/MortalityAnimalForm'; +import { IAnimalDeployment, IAnimalTelemetryDeviceFile } from './telemetry-device/device'; +import TelemetryDeviceFormContent from './telemetry-device/TelemetryDeviceFormContent'; + +interface IAddEditAnimalProps { + section: IAnimalSections; + critterData?: IDetailedCritterWithInternalId[]; + deploymentData?: IAnimalDeployment[]; + telemetrySaveAction: (data: IAnimalTelemetryDeviceFile[], formMode: ANIMAL_FORM_MODE) => Promise; + deploymentRemoveAction: (deploymentId: string) => void; + formikArrayHelpers: FieldArrayRenderProps; +} + +export const AddEditAnimal = (props: IAddEditAnimalProps) => { + const { section, critterData, telemetrySaveAction, deploymentRemoveAction, formikArrayHelpers } = props; + + const theme = useTheme(); + const telemetryApi = useTelemetryApi(); + const cbApi = useCritterbaseApi(); + const surveyContext = useContext(SurveyContext); + const dialogContext = useContext(DialogContext); + const { cid: survey_critter_id } = useQuery(); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const { submitForm, isValid, resetForm, values, isSubmitting, initialValues, isValidating, status } = + useFormikContext(); + + const { data: allFamilies, refresh: refreshFamilies } = useDataLoader(cbApi.family.getAllFamilies); + const { refresh: refreshDeviceDetails } = useDataLoader(telemetryApi.devices.getDeviceDetails); + const { data: measurements, refresh: refreshMeasurements } = useDataLoader(cbApi.lookup.getTaxonMeasurements); + + const [showDialog, setShowDialog] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + const [formMode, setFormMode] = useState(ANIMAL_FORM_MODE.EDIT); + + const dialogTitle = + formMode === ANIMAL_FORM_MODE.ADD + ? `Add ${ANIMAL_SECTIONS_FORM_MAP[section].dialogTitle}` + : `Edit ${ANIMAL_SECTIONS_FORM_MAP[section].dialogTitle}`; + + useEffect(() => { + refreshFamilies(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [critterData]); + + useEffect(() => { + refreshMeasurements(values.general.taxon_id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [values.general.taxon_id]); + + useEffect(() => { + if (!status?.success && status?.msg) { + // if the status of the request fails reset the form + resetForm(); + } + }, [initialValues, resetForm, status]); + + const renderSingleForm = useMemo(() => { + const sectionMap: Partial> = { + [SurveyAnimalsI18N.animalGeneralTitle]: , + [SurveyAnimalsI18N.animalMarkingTitle]: , + [SurveyAnimalsI18N.animalMeasurementTitle]: ( + + ), + [SurveyAnimalsI18N.animalCaptureTitle]: , + [SurveyAnimalsI18N.animalMortalityTitle]: , + [SurveyAnimalsI18N.animalFamilyTitle]: ( + + ), + [SurveyAnimalsI18N.animalCollectionUnitTitle]: , + Telemetry: + }; + return sectionMap[section]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + allFamilies, + deploymentRemoveAction, + measurements, + props.deploymentData, + formMode, + section, + selectedIndex, + survey_critter_id, + values.captures, + values.device, + values.mortality + ]); + + if (!surveyContext.surveyDataLoader.data) { + return ; + } + + const handleSaveTelemetry = async (saveValues: IAnimal) => { + const vals = formMode === ANIMAL_FORM_MODE.ADD ? [saveValues.device[selectedIndex]] : saveValues.device; + try { + await telemetrySaveAction(vals, formMode); + refreshDeviceDetails(Number(saveValues.device[selectedIndex].device_id)); + } catch (err) { + setMessageSnackbar('Telemetry save failed!', dialogContext); + } + }; + + return ( + + + + {values?.general?.animal_id ? `Animal Details > ${values.general.animal_id}` : 'No animal selected'} + + + + + + {values.general.critter_id ? ( + + + + + {section} + + + {/* Not using EditDialog due to the parent component needing the formik state */} + { + if (formMode === ANIMAL_FORM_MODE.ADD) { + formikArrayHelpers.remove(selectedIndex); + } + setFormMode(ANIMAL_FORM_MODE.EDIT); + }}> + {dialogTitle} + {renderSingleForm} + + { + if (section === 'Telemetry') { + await handleSaveTelemetry(values); + } else { + submitForm(); + } + setFormMode(ANIMAL_FORM_MODE.EDIT); + setShowDialog(false); + }} + loading={isValidating || isSubmitting || !!status}> + Save + + + + + {!ANIMAL_SECTIONS_FORM_MAP[section]?.addBtnText || + (section === 'Mortality Events' && initialValues.mortality.length >= 1) ? null : ( + + )} + + + + {ANIMAL_SECTIONS_FORM_MAP[section].infoText} + + + { + setSelectedIndex(idx); + setShowDialog(true); + }} + section={section} + allFamilies={allFamilies} + /> + + + ) : ( + + + No Animal Selected + + + )} + + ); +}; diff --git a/app/src/features/surveys/view/survey-animals/AnimalList.tsx b/app/src/features/surveys/view/survey-animals/AnimalList.tsx new file mode 100644 index 0000000000..f6b5b6d712 --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/AnimalList.tsx @@ -0,0 +1,193 @@ +import { mdiChevronDown, mdiPlus } from '@mdi/js'; +import Icon from '@mdi/react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Button, + Divider, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Skeleton, + Toolbar, + Typography +} from '@mui/material'; +import grey from '@mui/material/colors/grey'; +import { Box } from '@mui/system'; +import { SurveyAnimalsI18N } from 'constants/i18n'; +import { useQuery } from 'hooks/useQuery'; +import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; +import { useMemo } from 'react'; +import { useHistory } from 'react-router-dom'; +import { ANIMAL_SECTIONS_FORM_MAP, IAnimalSections } from './animal-sections'; + +interface IAnimalListProps { + isLoading?: boolean; + critterData?: IDetailedCritterWithInternalId[]; + selectedSection: IAnimalSections; + onSelectSection: (section: IAnimalSections) => void; + onAddButton: () => void; +} + +const ListPlaceholder = (props: { displaySkeleton: boolean }) => + props.displaySkeleton ? ( + + + + + ) : ( + + No Animals + + ); + +const AnimalList = (props: IAnimalListProps) => { + const { isLoading, selectedSection, onSelectSection, critterData, onAddButton } = props; + const { cid: survey_critter_id } = useQuery(); + + const history = useHistory(); + + const sortedCritterData = useMemo(() => { + return [...(critterData ?? [])].sort( + (a, b) => new Date(a.create_timestamp).getTime() - new Date(b.create_timestamp).getTime() + ); + }, [critterData]); + + const handleCritterSelect = (id: string) => { + if (id === survey_critter_id) { + history.replace(history.location.pathname); + } else { + history.push(`?cid=${id}`); + } + onSelectSection(SurveyAnimalsI18N.animalGeneralTitle); + }; + + return ( + + + + Animals + + + + + + + + {!sortedCritterData.length ? ( + + ) : ( + sortedCritterData.map((critter) => ( + + + } + onClick={() => handleCritterSelect(critter.survey_critter_id.toString())} + 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.taxon} • {critter.sex} + + + + + + + {(Object.keys(ANIMAL_SECTIONS_FORM_MAP) as IAnimalSections[]).map((section) => ( + { + onSelectSection(section); + }}> + + + + {section} + + ))} + + + + )) + )} + + + + ); +}; + +export default AnimalList; diff --git a/app/src/features/surveys/view/survey-animals/AnimalSectionDataCards.tsx b/app/src/features/surveys/view/survey-animals/AnimalSectionDataCards.tsx new file mode 100644 index 0000000000..99e7d0375b --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/AnimalSectionDataCards.tsx @@ -0,0 +1,216 @@ +import Collapse from '@mui/material/Collapse'; +import { SurveyAnimalsI18N } from 'constants/i18n'; +import { DialogContext } from 'contexts/dialogContext'; +import { EditDeleteStubCard } from 'features/surveys/components/EditDeleteStubCard'; +import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; +import { IFamily } from 'hooks/cb_api/useFamilyApi'; +import moment from 'moment'; +import { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { TransitionGroup } from 'react-transition-group'; +import { setMessageSnackbar } from 'utils/Utils'; +import { IAnimal } from './animal'; +import { ANIMAL_SECTIONS_FORM_MAP, IAnimalSections } from './animal-sections'; +import GeneralAnimalSummary from './GeneralAnimalSummary'; + +export type SubHeaderData = Record; + +interface IAnimalSectionDataCardsProps { + /* + * section selected from the vertical nav bar ie: 'General' + */ + section: IAnimalSections; + + /* + * handler for the card edit action, needs index of the selected card + */ + onEditClick: (idx: number) => void; + + /* + * providing additional family information for rendering the family cards with english readable values + */ + allFamilies?: IFamily[]; +} + +/** + * Renders animal data as cards for the selected section + * + * @param {AnimalSectionDataCardsProps} props + * + * @return {*} + * + **/ + +export const AnimalSectionDataCards = (props: IAnimalSectionDataCardsProps) => { + const { section, onEditClick, allFamilies } = props; + + const { submitForm, initialValues, isSubmitting, status } = useFormikContext(); + const [canDisplaySnackbar, setCanDisplaySnackbar] = useState(false); + const statusRef = useRef<{ success: boolean; msg: string } | undefined>(); + + const dialogContext = useContext(DialogContext); + const formatDate = (dt: Date) => moment(dt).format('MMM Do[,] YYYY'); + + useEffect(() => { + // This delays the snackbar from entering until the card has finished animating + // Stores the custom status returned from formik before its deleted + // Manually setting when canDisplaySnackbar occurs ie: editing does not have animation + if (statusRef.current && canDisplaySnackbar) { + setTimeout(() => { + statusRef.current && setMessageSnackbar(statusRef.current?.msg, dialogContext); + statusRef.current = undefined; + setCanDisplaySnackbar(false); + }, 500); + } + + if (status) { + statusRef.current = status; + } + }, [canDisplaySnackbar, status?.msg, status?.success, status, dialogContext]); + + 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 sectionCardData = useMemo(() => { + const sectionData: Record> = { + [SurveyAnimalsI18N.animalGeneralTitle]: [ + { + header: `General: ${initialValues.general.animal_id}`, + subHeader: formatSubHeader({ + Taxon: initialValues.general.taxon_name, + Sex: initialValues.general.sex, + 'WLH ID': initialValues.general.wlh_id + }), + key: 'general-key' + } + ], + [SurveyAnimalsI18N.animalMarkingTitle]: initialValues.markings.map((marking) => ({ + header: `${marking.marking_type}`, + subHeader: formatSubHeader({ Location: marking.body_location, Colour: marking.primary_colour }), + key: marking.marking_id ?? 'new-marking-key' + })), + [SurveyAnimalsI18N.animalMeasurementTitle]: initialValues.measurements.map((measurement) => ({ + header: `${measurement.measurement_name}: ${measurement.option_label ?? measurement.value}`, + subHeader: `Date of Measurement: ${formatDate(measurement.measured_timestamp)}`, + key: measurement.measurement_qualitative_id ?? measurement.measurement_quantitative_id ?? 'new-measurement-key' + })), + [SurveyAnimalsI18N.animalCaptureTitle]: initialValues.captures.map((capture) => ({ + header: `${formatDate(capture.capture_timestamp)}`, + subHeader: formatSubHeader({ Latitude: capture.capture_latitude, Longitude: capture.capture_longitude }), + key: capture.capture_id ?? 'new-capture-key' + })), + [SurveyAnimalsI18N.animalMortalityTitle]: initialValues.mortality.map((mortality) => ({ + header: `${formatDate(mortality.mortality_timestamp)}`, + subHeader: formatSubHeader({ + Latitude: mortality.mortality_latitude, + Longitude: mortality.mortality_longitude + }), + key: mortality.mortality_id ?? 'new-mortality-key' + })), + [SurveyAnimalsI18N.animalFamilyTitle]: initialValues.family.map((family) => { + const family_label = allFamilies?.find((a) => a.family_id === family.family_id)?.family_label; + return { + header: `${family_label}`, + subHeader: formatSubHeader({ Relationship: family.relationship }), + key: family.family_id ?? 'new-family-key' + }; + }), + [SurveyAnimalsI18N.animalCollectionUnitTitle]: initialValues.collectionUnits.map((collectionUnit) => ({ + header: `${collectionUnit.unit_name}`, + subHeader: formatSubHeader({ Category: collectionUnit.category_name }), + key: collectionUnit.critter_collection_unit_id ?? 'new-collection-unit-key' + })), + Telemetry: initialValues.device.map((device) => ({ + header: `Device: ${device.device_id}`, + subHeader: formatSubHeader({ + Make: device.device_make, + Model: device.device_model, + Deployments: device.deployments?.length ?? 0 + }), + key: `${device.device_id}` + })) + }; + return sectionData[section]; + }, [ + initialValues.general.animal_id, + initialValues.general.taxon_name, + initialValues.general.sex, + initialValues.markings, + initialValues.measurements, + initialValues.captures, + initialValues.mortality, + initialValues.family, + initialValues.collectionUnits, + initialValues.device, + initialValues.general.wlh_id, + section, + allFamilies + ]); + + const showDeleteDialog = (onConfirmDelete: () => void) => { + const close = () => dialogContext.setYesNoDialog({ open: false }); + dialogContext.setYesNoDialog({ + dialogTitle: `Delete ${ANIMAL_SECTIONS_FORM_MAP[section].dialogTitle}`, + dialogText: 'Are you sure you want to delete this record?', + isLoading: isSubmitting, + open: true, + onYes: async () => { + onConfirmDelete(); + close(); + }, + onNo: () => close(), + onClose: () => close() + }); + }; + + return ( + + {({ remove }: FieldArrayRenderProps) => { + const handleClickEdit = (idx: number) => { + setCanDisplaySnackbar(true); + onEditClick(idx); + }; + if (section === SurveyAnimalsI18N.animalGeneralTitle) { + return handleClickEdit(0)} />; + } + return ( + + {sectionCardData.map((cardData, index) => { + const submitFormRemoveCard = () => { + remove(index); + submitForm(); + }; + const handleDelete = () => { + showDeleteDialog(submitFormRemoveCard); + }; + return ( + { + setCanDisplaySnackbar(true); + }}> + { + handleClickEdit(index); + }} + onClickDelete={section === 'Telemetry' ? undefined : handleDelete} + /> + + ); + })} + + ); + }} + + ); +}; diff --git a/app/src/features/surveys/view/survey-animals/GeneralAnimalSummary.tsx b/app/src/features/surveys/view/survey-animals/GeneralAnimalSummary.tsx new file mode 100644 index 0000000000..a5497956cd --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/GeneralAnimalSummary.tsx @@ -0,0 +1,93 @@ +import { mdiContentCopy, mdiPencilOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +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 Box from '@mui/system/Box'; +import { DialogContext } from 'contexts/dialogContext'; +import { useFormikContext } from 'formik'; +import { useContext } from 'react'; +import { setMessageSnackbar } from 'utils/Utils'; +import { DetailsWrapper } from '../SurveyDetails'; +import { IAnimal } from './animal'; + +interface IGeneralDetail { + title: string; + value?: string; + valueEndIcon?: JSX.Element; +} + +interface GeneralAnimalSummaryProps { + /* + * Callback to be fired when edit action selected + */ + handleEdit: () => void; +} + +const GeneralAnimalSummary = (props: GeneralAnimalSummaryProps) => { + const dialogContext = useContext(DialogContext); + + const { + initialValues: { general } + } = useFormikContext(); + + const animalGeneralDetails: Array = [ + { title: 'Alias', value: general.animal_id }, + { title: 'Taxon', value: general.taxon_name }, + { title: 'Sex', value: general.sex }, + { title: 'Wildlife Health ID', value: general.wlh_id }, + { + title: 'Critterbase ID', + value: general.critter_id, + valueEndIcon: ( + { + navigator.clipboard.writeText(general.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/IndividualAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/IndividualAnimalForm.tsx deleted file mode 100644 index bb221d5751..0000000000 --- a/app/src/features/surveys/view/survey-animals/IndividualAnimalForm.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import FormikDevDebugger from 'components/formik/FormikDevDebugger'; -import { Form, useFormikContext } from 'formik'; -import { Critter, IAnimal } from './animal'; -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'; - -/** - * Renders The 'Individual Animals' Form displayed in Survey view - * Note: Lots of conditionally rendered sections. - * - * @params {IndividualAnimalFormProps} - * @returns {*} - * - **/ - -export enum ANIMAL_FORM_MODE { - ADD = 'add', - EDIT = 'edit' -} - -const IndividualAnimalForm = () => { - const { values } = useFormikContext(); - - return ( -
- - - - - - - - - - ); -}; - -export default IndividualAnimalForm; diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsPage.test.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsPage.test.tsx new file mode 100644 index 0000000000..cdcc4872d1 --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/SurveyAnimalsPage.test.tsx @@ -0,0 +1,152 @@ +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: () => ({ cid: 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() + } +}; + +const mockUseTelemetry = { + devices: { + getDeviceDetails: jest.fn() + } +}; + +const mockUseCritterbase = { + 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(); + }); + + fireEvent.click(screen.getByText('Add')); + + await waitFor(() => { + expect(screen.getByText('Create Animal')).toBeInTheDocument(); + }); + + 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(); + }); + + 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 new file mode 100644 index 0000000000..eb15a3f3b6 --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/SurveyAnimalsPage.tsx @@ -0,0 +1,319 @@ +import EditDialog from 'components/dialog/EditDialog'; +import { AttachmentType } from 'constants/attachments'; +import { SurveyAnimalsI18N } from 'constants/i18n'; +import { DialogContext } from 'contexts/dialogContext'; +import { SurveyContext } from 'contexts/surveyContext'; +import { SurveySectionFullPageLayout } from 'features/surveys/components/SurveySectionFullPageLayout'; +import { FieldArray, FieldArrayRenderProps, Formik } from 'formik'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { useQuery } from 'hooks/useQuery'; +import { useTelemetryApi } from 'hooks/useTelemetryApi'; +import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; +import { isEqual as _deepEquals, omitBy } from 'lodash'; +import { useContext, useMemo, useState } from 'react'; +import { datesSameNullable, setMessageSnackbar } from 'utils/Utils'; +import { AddEditAnimal } from './AddEditAnimal'; +import { AnimalSchema, AnimalSex, ANIMAL_FORM_MODE, Critter, IAnimal } from './animal'; +import { createCritterUpdatePayload, transformCritterbaseAPIResponseToForm } from './animal-form-helpers'; +import { ANIMAL_SECTIONS_FORM_MAP, IAnimalSections } from './animal-sections'; +import AnimalList from './AnimalList'; +import GeneralAnimalForm from './form-sections/GeneralAnimalForm'; +import { + Device, + IAnimalTelemetryDevice, + IAnimalTelemetryDeviceFile, + IDeploymentTimespan +} from './telemetry-device/device'; + +export const SurveyAnimalsPage = () => { + const [selectedSection, setSelectedSection] = useState(SurveyAnimalsI18N.animalGeneralTitle); + const { cid: survey_critter_id } = useQuery(); + const [openAddDialog, setOpenAddDialog] = useState(false); + const bhApi = useBiohubApi(); + const telemetryApi = useTelemetryApi(); + const dialogContext = useContext(DialogContext); + const { surveyId, projectId, artifactDataLoader } = useContext(SurveyContext); + + const { + data: critterData, + load: loadCritters, + refresh: refreshCritters, + isLoading: crittersLoading + } = useDataLoader(() => bhApi.survey.getSurveyCritters(projectId, surveyId)); + + const { + data: deploymentData, + load: loadDeployments, + refresh: refreshDeployments + } = useDataLoader(() => bhApi.survey.getDeploymentsInSurvey(projectId, surveyId)); + + loadCritters(); + loadDeployments(); + + const defaultFormValues: IAnimal = useMemo(() => { + return { + general: { wlh_id: '', taxon_id: '', taxon_name: '', animal_id: '', sex: AnimalSex.UNKNOWN, critter_id: '' }, + captures: [], + markings: [], + mortality: [], + collectionUnits: [], + measurements: [], + family: [], + images: [], + device: [] + }; + }, []); + + const critterAsFormikValues = useMemo(() => { + const existingCritter = critterData?.find( + (critter: IDetailedCritterWithInternalId) => Number(survey_critter_id) === Number(critter.survey_critter_id) + ); + if (!existingCritter) { + return defaultFormValues; + } + const animal = transformCritterbaseAPIResponseToForm(existingCritter); + const crittersDeployments = deploymentData?.filter((a) => a.critter_id === existingCritter.critter_id); + let deployments: IAnimalTelemetryDevice[] = []; + if (crittersDeployments) { + //Any suggestions on something better than this reduce is welcome. + //Idea is to transform flat rows of {device_id, ..., deployment_id, attachment_end, attachment_start} + //to {device_id, ..., deployments: [{deployment_id, attachment_start, attachment_end}]} + const red = crittersDeployments.reduce((acc: IAnimalTelemetryDevice[], curr) => { + const currObj = acc.find((a: any) => a.device_id === curr.device_id); + const { attachment_end, attachment_start, deployment_id, ...rest } = curr; + const deployment = { + deployment_id, + attachment_start: attachment_start?.split('T')?.[0] ?? '', + attachment_end: attachment_end?.split('T')?.[0] + }; + if (!currObj) { + acc.push({ ...rest, deployments: [deployment] }); + } else { + currObj.deployments?.push(deployment); + } + return acc; + }, []); + deployments = red; + } else { + deployments = []; + } + animal.device = deployments; + + return animal; + }, [critterData, deploymentData, survey_critter_id, defaultFormValues]); + + const handleRemoveDeployment = async (deployment_id: string) => { + try { + if (survey_critter_id === undefined) { + setMessageSnackbar('No critter set!', dialogContext); + } + await bhApi.survey.removeDeployment(projectId, surveyId, Number(survey_critter_id), deployment_id); + } catch (e) { + setMessageSnackbar('Failed to delete deployment.', dialogContext); + return; + } + + refreshDeployments(); + }; + + const handleCritterSave = async (currentFormValues: IAnimal, formMode: ANIMAL_FORM_MODE) => { + const postCritterPayload = async () => { + const critter = new Critter(currentFormValues); + setOpenAddDialog(false); + await bhApi.survey.createCritterAndAddToSurvey(projectId, surveyId, critter); + }; + const patchCritterPayload = async () => { + const initialFormValues = critterAsFormikValues; + if (!initialFormValues) { + throw Error('Could not obtain initial form values.'); + } + const { create: createCritter, update: updateCritter } = createCritterUpdatePayload( + initialFormValues, + currentFormValues + ); + const surveyCritter = critterData?.find( + (critter) => Number(critter.survey_critter_id) === Number(survey_critter_id) + ); + if (!survey_critter_id || !surveyCritter) { + throw Error('The internal critter id for this row was not set correctly.'); + } + await bhApi.survey.updateSurveyCritter( + projectId, + surveyId, + surveyCritter.survey_critter_id, + updateCritter, + createCritter + ); + }; + try { + if (formMode === ANIMAL_FORM_MODE.ADD) { + await postCritterPayload(); + //Manually setting the message snackbar at this point + setMessageSnackbar('Animal added to survey', dialogContext); + } else { + await patchCritterPayload(); + } + refreshDeployments(); + refreshCritters(); + return { success: true, msg: 'Successfully updated animal' }; + } catch (err) { + setMessageSnackbar(`Submmision failed ${(err as Error).message}`, dialogContext); + return { success: false, msg: `Submmision failed ${(err as Error).message}` }; + } + }; + + const uploadAttachment = async (file?: File, attachmentType?: AttachmentType) => { + try { + if (file && attachmentType === AttachmentType.KEYX) { + await bhApi.survey.uploadSurveyKeyx(projectId, surveyId, file); + } else if (file && attachmentType === AttachmentType.OTHER) { + await bhApi.survey.uploadSurveyAttachments(projectId, surveyId, file); + } + } catch (error) { + throw new Error(`Failed to upload attachment ${file?.name}`); + } + }; + + const handleAddTelemetry = async (survey_critter_id: number, data: IAnimalTelemetryDeviceFile[]) => { + const critter = critterData?.find((a) => a.survey_critter_id === survey_critter_id); + if (!critter) console.log('Did not find critter in addTelemetry!'); + const { attachmentFile, attachmentType, ...critterTelemetryDevice } = { + ...data[0], + critter_id: critter?.critter_id ?? '' + }; + try { + // Upload attachment if there is one + await uploadAttachment(attachmentFile, attachmentType); + // create new deployment record + const critterTelemNoBlanks = omitBy( + critterTelemetryDevice, + (value) => value === '' || value === null + ) as IAnimalTelemetryDevice & { critter_id: string }; + await bhApi.survey.addDeployment(projectId, surveyId, survey_critter_id, critterTelemNoBlanks); + setMessageSnackbar('Successfully added deployment.', dialogContext); + artifactDataLoader.refresh(projectId, surveyId); + } catch (error: unknown) { + if (error instanceof Error) { + setMessageSnackbar('Failed to add deployment' + (error?.message ? `: ${error.message}` : '.'), dialogContext); + } else { + setMessageSnackbar('Failed to add deployment.', dialogContext); + } + } + }; + + const updateDevice = async (formValues: IAnimalTelemetryDevice) => { + const existingDevice = deploymentData?.find((deployment) => deployment.device_id === formValues.device_id); + const formDevice = new Device({ collar_id: existingDevice?.collar_id, ...formValues }); + if (existingDevice && !_deepEquals(new Device(existingDevice), formDevice)) { + try { + await telemetryApi.devices.upsertCollar(formDevice); + } catch (error) { + throw new Error(`Failed to update collar ${formDevice.collar_id}`); + } + } + }; + + const updateDeployments = async (formDeployments: IDeploymentTimespan[], survey_critter_id: number) => { + for (const formDeployment of formDeployments ?? []) { + const existingDeployment = deploymentData?.find( + (animalDeployment) => animalDeployment.deployment_id === formDeployment.deployment_id + ); + if ( + !datesSameNullable(formDeployment?.attachment_start, existingDeployment?.attachment_start) || + !datesSameNullable(formDeployment?.attachment_end, existingDeployment?.attachment_end) + ) { + try { + await bhApi.survey.updateDeployment(projectId, surveyId, survey_critter_id, formDeployment); + } catch (error) { + throw new Error(`Failed to update deployment ${formDeployment.deployment_id}`); + } + } + } + }; + + const handleEditTelemetry = async (survey_critter_id: number, data: IAnimalTelemetryDeviceFile[]) => { + const errors: string[] = []; + for (const { attachmentFile, attachmentType, ...formValues } of data) { + try { + await uploadAttachment(attachmentFile, attachmentType); + await updateDevice(formValues); + await updateDeployments(formValues.deployments ?? [], survey_critter_id); + } catch (error) { + const deviceErr = `Device ${formValues.device_id}`; + const err = error instanceof Error ? `${deviceErr} ${error.message}` : `${deviceErr} unknown error`; + errors.push(err); + } + } + errors.length + ? setMessageSnackbar('Failed to save some data: ' + errors.join(', '), dialogContext) + : setMessageSnackbar('Updated deployment and device data successfully.', dialogContext); + }; + + const handleTelemetrySave = async ( + survey_critter_id: number, + data: IAnimalTelemetryDeviceFile[], + telemetryFormMode: ANIMAL_FORM_MODE + ) => { + if (telemetryFormMode === ANIMAL_FORM_MODE.ADD) { + await handleAddTelemetry(survey_critter_id, data); + } else if (telemetryFormMode === ANIMAL_FORM_MODE.EDIT) { + await handleEditTelemetry(survey_critter_id, data); + } + refreshDeployments(); + }; + + return ( + <> + { + const status = await handleCritterSave(values, ANIMAL_FORM_MODE.EDIT); + actions.setStatus(status); + }}> + setOpenAddDialog(true)} + critterData={critterData} + isLoading={crittersLoading} + selectedSection={selectedSection} + onSelectSection={(section) => setSelectedSection(section)} + /> + } + mainComponent={ + + {(formikArrayHelpers: FieldArrayRenderProps) => ( + handleTelemetrySave(Number(survey_critter_id), data, mode)} + deploymentRemoveAction={handleRemoveDeployment} + formikArrayHelpers={formikArrayHelpers} + /> + )} + + } + /> + + , + initialValues: defaultFormValues, + validationSchema: AnimalSchema + }} + dialogSaveButtonLabel="Create Animal" + onCancel={() => setOpenAddDialog(false)} + onSave={(values) => handleCritterSave(values, ANIMAL_FORM_MODE.ADD)} + /> + + ); +}; diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx index c72943c2ec..c77c89f7d2 100644 --- a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx +++ b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx @@ -1,9 +1,9 @@ import { GridColDef } from '@mui/x-data-grid'; -import { CustomDataGrid } from 'components/tables/CustomDataGrid'; +import { CustomDataGrid } from 'components/data-grid/CustomDataGrid'; import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; import moment from 'moment'; -import { IAnimalDeployment } from './device'; import SurveyAnimalsTableActions from './SurveyAnimalsTableActions'; +import { IAnimalDeployment } from './telemetry-device/device'; interface ISurveyAnimalsTableEntry { survey_critter_id: number; @@ -18,8 +18,6 @@ interface ISurveyAnimalsTableProps { deviceData?: IAnimalDeployment[]; onMenuOpen: (critter_id: number) => void; onRemoveCritter: (critter_id: number) => void; - onAddDevice: (critter_id: number) => void; - onEditDevice: (device_id: number) => void; onEditCritter: (critter_id: number) => void; onMapOpen: () => void; } @@ -29,13 +27,11 @@ export const SurveyAnimalsTable = ({ deviceData, onMenuOpen, onRemoveCritter, - onAddDevice, - onEditDevice, onEditCritter, onMapOpen }: ISurveyAnimalsTableProps): JSX.Element => { const animalDeviceData: ISurveyAnimalsTableEntry[] = deviceData - ? animalData + ? [...animalData] // spreading this prevents this error "TypeError: Cannot assign to read only property '0' of object '[object Array]' in typescript" .sort((a, b) => new Date(a.create_timestamp).getTime() - new Date(b.create_timestamp).getTime()) //This sort needed to avoid arbitrary reordering of the table when it refreshes after adding or editing .map((animal) => { const deployments = deviceData.filter((device) => device.critter_id === animal.critter_id); @@ -101,9 +97,7 @@ export const SurveyAnimalsTable = ({ critter_id={params.row.survey_critter_id} devices={params.row?.deployments} onMenuOpen={onMenuOpen} - onAddDevice={onAddDevice} onEditCritter={onEditCritter} - onEditDevice={onEditDevice} onRemoveCritter={onRemoveCritter} onMapOpen={onMapOpen} /> diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx index 9b92b35590..240c000bd7 100644 --- a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx +++ b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx @@ -6,16 +6,18 @@ 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 './device'; +import { IAnimalDeployment } from './telemetry-device/device'; + +type ICritterFn = (critter_id: number) => void; export interface ITableActionsMenuProps { critter_id: number; devices?: IAnimalDeployment[]; - onMenuOpen: (critter_id: number) => void; - onAddDevice: (critter_id: number) => void; - onEditDevice: (critter_id: number) => void; - onEditCritter: (critter_id: number) => void; - onRemoveCritter: (critter_id: number) => void; + onMenuOpen: ICritterFn; + onAddDevice?: ICritterFn; + onEditDevice?: ICritterFn; + onEditCritter: ICritterFn; + onRemoveCritter: ICritterFn; onMapOpen: () => void; } @@ -53,22 +55,25 @@ const SurveyAnimalsTableActions = (props: ITableActionsMenuProps) => { MenuListProps={{ 'aria-labelledby': 'basic-button' }}> - { - handleClose(); - props.onAddDevice(props.critter_id); - }} - data-testid="animal-table-row-add-device"> - - - - Add Telemetry Device - - {props.devices?.length ? ( + {props.onAddDevice ? ( { handleClose(); - props.onEditDevice(props.critter_id); + 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"> @@ -77,6 +82,7 @@ const SurveyAnimalsTableActions = (props: ITableActionsMenuProps) => { Edit Telemetry Devices ) : null} + { handleClose(); @@ -88,6 +94,7 @@ const SurveyAnimalsTableActions = (props: ITableActionsMenuProps) => { Edit Animal + {!props.devices?.length && ( { @@ -101,6 +108,7 @@ const SurveyAnimalsTableActions = (props: ITableActionsMenuProps) => { Remove Animal )} + {props.devices?.length ? ( { diff --git a/app/src/features/surveys/view/survey-animals/TelemetryDeviceForm.tsx b/app/src/features/surveys/view/survey-animals/TelemetryDeviceForm.tsx deleted file mode 100644 index 4d8baf5ee3..0000000000 --- a/app/src/features/surveys/view/survey-animals/TelemetryDeviceForm.tsx +++ /dev/null @@ -1,297 +0,0 @@ -import { mdiTrashCanOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import { Box, IconButton, Typography } from '@mui/material'; -import Card from '@mui/material/Card'; -import CardContent from '@mui/material/CardContent'; -import CardHeader from '@mui/material/CardHeader'; -import { grey } from '@mui/material/colors'; -import Grid from '@mui/material/Grid'; -import YesNoDialog from 'components/dialog/YesNoDialog'; -import CustomNumberField from 'components/fields/CustomNumberField'; -import CustomTextField from 'components/fields/CustomTextField'; -import SingleDateField from 'components/fields/SingleDateField'; -import TelemetrySelectField from 'components/fields/TelemetrySelectField'; -import { AttachmentType } from 'constants/attachments'; -import { PG_MAX_INT } from 'constants/misc'; -import { Form, useFormikContext } from 'formik'; -import useDataLoader from 'hooks/useDataLoader'; -import { useTelemetryApi } from 'hooks/useTelemetryApi'; -import { Fragment, useEffect, useState } from 'react'; -import { IAnimalTelemetryDevice, IDeploymentTimespan } from './device'; -import { TelemetryFileUpload } from './TelemetryFileUpload'; - -export enum TELEMETRY_DEVICE_FORM_MODE { - ADD = 'add', - EDIT = 'edit' -} - -export interface IAnimalTelemetryDeviceFile extends IAnimalTelemetryDevice { - attachmentFile?: File; - attachmentType?: AttachmentType; -} - -const AttachmentFormSection = (props: { index: number; deviceMake: string }) => { - return ( - <> - {props.deviceMake === 'Vectronic' && ( - <> - {`Vectronic KeyX File (Optional)`} - - - )} - {props.deviceMake === 'Lotek' && ( - <> - {`Lotek Config File (Optional)`} - - - )} - - ); -}; - -const DeploymentFormSection = ({ - index, - deployments, - mode, - removeAction -}: { - index: number; - deployments: IDeploymentTimespan[]; - mode: TELEMETRY_DEVICE_FORM_MODE; - removeAction: (deployment_id: string) => void; -}): JSX.Element => { - const [openDeleteDialog, setOpenDeleteDialog] = useState(false); - const [deploymentToDelete, setDeploymentToDelete] = useState(null); - return ( - <> - setOpenDeleteDialog(false)} - onNo={() => setOpenDeleteDialog(false)} - onYes={async () => { - if (deploymentToDelete) { - removeAction(deploymentToDelete); - } - setOpenDeleteDialog(false); - }} - /> - - {deployments.map((deploy, i) => { - return ( - - - - - - - - {mode === TELEMETRY_DEVICE_FORM_MODE.EDIT && ( - - { - setDeploymentToDelete(String(deploy.deployment_id)); - setOpenDeleteDialog(true); - }}> - - - - )} - - ); - })} - - - ); -}; - -interface IDeviceFormSectionProps { - mode: TELEMETRY_DEVICE_FORM_MODE; - values: IAnimalTelemetryDeviceFile[]; - index: number; - removeAction: (deploymentId: string) => void; -} - -const DeviceFormSection = ({ values, index, mode, removeAction }: IDeviceFormSectionProps): JSX.Element => { - const api = useTelemetryApi(); - - const { data: bctwDeviceData, refresh } = useDataLoader(() => api.devices.getDeviceDetails(values[index].device_id)); - - useEffect(() => { - if (values[index].device_id) { - refresh(); - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [values[index].device_id]); - - return ( - <> - - - Device Metadata - - - - - - - - - - - - { - const codeVals = await api.devices.getCodeValues('frequency_unit'); - return codeVals.map((a) => a.description); - }} - /> - - - - - - - - - - - - {((values[index].device_make === 'Vectronic' && !bctwDeviceData?.keyXStatus) || - values[index].device_make === 'Lotek') && ( - - - Upload Attachment - - - - )} - - - Deployment Dates - - { - - } - - - ); -}; - -interface ITelemetryDeviceFormProps { - mode: TELEMETRY_DEVICE_FORM_MODE; - removeAction: (deployment_id: string) => void; -} - -const TelemetryDeviceForm = ({ mode, removeAction }: ITelemetryDeviceFormProps) => { - const { values } = useFormikContext(); - - return ( -
- <> - {values.map((device, idx) => ( - - - - - - - ))} - -
- ); -}; - -export default TelemetryDeviceForm; diff --git a/app/src/features/surveys/view/survey-animals/animal-form-helpers.test.ts b/app/src/features/surveys/view/survey-animals/animal-form-helpers.test.ts index df16a53408..c7eb015a41 100644 --- a/app/src/features/surveys/view/survey-animals/animal-form-helpers.test.ts +++ b/app/src/features/surveys/view/survey-animals/animal-form-helpers.test.ts @@ -187,7 +187,6 @@ describe('animal form helpers', () => { describe('createCritterUpdatePayload', () => { it('should return an object containing two instances of Critter', () => { const capture: IAnimalCapture = { - _id: '', capture_id: '8b9281ea-fbe8-411c-9b50-70ffd08737cb', capture_location_id: undefined, release_location_id: undefined, @@ -210,28 +209,30 @@ describe('animal form helpers', () => { }; const marking: IAnimalMarking = { - _id: '', marking_id: undefined, marking_type_id: '845f27ac-f0b2-4128-9615-18980e5c8caa', taxon_marking_body_location_id: '46e6b939-3485-4c45-9f26-607489e50def', primary_colour_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097', secondary_colour_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097', + primary_colour: 'red', + body_location: 'Rear Leg', + marking_type: 'tag', marking_comment: '' }; const measure: IAnimalMeasurement = { - _id: '', measurement_qualitative_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097', measurement_quantitative_id: undefined, taxon_measurement_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097', qualitative_option_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097', value: undefined, measured_timestamp: new Date(), - measurement_comment: 'a' + measurement_comment: 'a', + measurement_name: 'weight', + option_label: 'test' }; const mortality: IAnimalMortality = { - _id: '', mortality_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097', location_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097', mortality_longitude: 0, @@ -251,14 +252,14 @@ describe('animal form helpers', () => { }; const collectionUnits: IAnimalCollectionUnit = { - _id: '', collection_unit_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097', + unit_name: 'Pop', + category_name: 'Population Unit', collection_category_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097', critter_collection_unit_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097' }; const family: IAnimalRelationship = { - _id: '', family_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097', relationship: 'child' }; @@ -282,7 +283,7 @@ describe('animal form helpers', () => { collectionUnits, { ...collectionUnits, critter_collection_unit_id: '0ec88875-0219-4635-b7cf-8da8ba732fc1' } ], - device: undefined + device: [] }; const currentFormValues: IAnimal = { @@ -301,7 +302,7 @@ describe('animal form helpers', () => { family: [], images: [], collectionUnits: [], - device: undefined + device: [] }; const { create, update } = createCritterUpdatePayload(initialFormValues, currentFormValues); diff --git a/app/src/features/surveys/view/survey-animals/animal-form-helpers.ts b/app/src/features/surveys/view/survey-animals/animal-form-helpers.ts index 7c4bea6ace..3a055bf146 100644 --- a/app/src/features/surveys/view/survey-animals/animal-form-helpers.ts +++ b/app/src/features/surveys/view/survey-animals/animal-form-helpers.ts @@ -1,6 +1,6 @@ import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; import { v4 } from 'uuid'; -import { AnimalSex, Critter, IAnimal } from './animal'; +import { AnimalSex, Critter, IAnimal, newFamilyIdPlaceholder } from './animal'; /** * Takes the 'detailed' format response from the Critterbase DB and transforms the response into an object that is usable @@ -10,7 +10,7 @@ import { AnimalSex, Critter, IAnimal } from './animal'; * @param existingCritter The critter as seen from the Critterbase DB * @returns {*} IAnimal */ -export const transformCritterbaseAPIResponseToForm = (existingCritter: IDetailedCritterWithInternalId) => { +export const transformCritterbaseAPIResponseToForm = (existingCritter: IDetailedCritterWithInternalId): IAnimal => { //This is a pretty long albeit straightforward function, which is why it's been lifted out of the main TSX file. //Perhaps some of this could be automated by iterating through each object entries, but I don't think //it's necessarily a bad thing to have it this explicit when so many parts need to be handled in particular ways. @@ -40,7 +40,6 @@ export const transformCritterbaseAPIResponseToForm = (existingCritter: IDetailed release_utm_easting: 0, release_utm_northing: 0, projection_mode: 'wgs', - _id: v4(), show_release: !!cap.release_location, capture_location_id: cap.capture_location_id ?? undefined, release_location_id: cap.release_location_id ?? undefined @@ -50,11 +49,12 @@ export const transformCritterbaseAPIResponseToForm = (existingCritter: IDetailed primary_colour_id: mark.primary_colour_id ?? '', secondary_colour_id: mark.secondary_colour_id ?? '', marking_comment: mark.comment ?? '', - _id: v4() + primary_colour: mark.primary_colour ?? undefined, + marking_type: mark.marking_type ?? undefined, + body_location: mark.body_location ?? undefined })), mortality: existingCritter?.mortality.map((mor) => ({ ...mor, - _id: v4(), mortality_comment: mor.mortality_comment ?? '', mortality_timestamp: new Date(mor.mortality_timestamp), mortality_latitude: mor.location.latitude, @@ -72,41 +72,40 @@ export const transformCritterbaseAPIResponseToForm = (existingCritter: IDetailed location_id: mor.location_id ?? undefined })), collectionUnits: existingCritter.collection_units.map((a) => ({ - ...a, - _id: v4() + ...a })), measurements: [ ...existingCritter.measurement.qualitative.map((meas) => ({ ...meas, measurement_quantitative_id: undefined, - _id: v4(), value: undefined, measured_timestamp: meas.measured_timestamp ? new Date(meas.measured_timestamp) : ('' as unknown as Date), - measurement_comment: meas.measurement_comment ?? '' + measurement_comment: meas.measurement_comment ?? '', + measurement_name: meas.measurement_name ?? undefined, + option_label: meas.option_label })), ...existingCritter.measurement.quantitative.map((meas) => ({ ...meas, - _id: v4(), measurement_qualitative_id: undefined, qualitative_option_id: undefined, measured_timestamp: meas.measured_timestamp ? new Date(meas.measured_timestamp) : ('' as unknown as Date), - measurement_comment: meas.measurement_comment ?? '' + measurement_comment: meas.measurement_comment ?? '', + measurement_name: meas.measurement_name ?? undefined, + option_label: undefined })) ], family: [ ...existingCritter.family_child.map((ch) => ({ - _id: v4(), family_id: ch.family_id, relationship: 'child' })), ...existingCritter.family_parent.map((par) => ({ - _id: v4(), family_id: par.family_id, relationship: 'parent' })) ], images: [], - device: undefined + device: [] }; }; @@ -204,13 +203,21 @@ export const createCritterUpdatePayload = ( (prevFam) => currFam.family_id === prevFam.family_id && currFam.relationship === prevFam.relationship ) ) { + let familyId = currFam.family_id; + if (currFam.family_id === newFamilyIdPlaceholder) { + familyId = v4(); + createCritter.families.families.push({ + family_id: familyId, + family_label: `${currentFormValues.general.animal_id}-${currentFormValues.general.taxon_name}_family` + }); + } currFam.relationship === 'parent' ? createCritter.families.parents.push({ - family_id: currFam.family_id, + family_id: familyId, parent_critter_id: initialCritter.critter_id }) : createCritter.families.children.push({ - family_id: currFam.family_id, + family_id: familyId, child_critter_id: initialCritter.critter_id }); } diff --git a/app/src/features/surveys/view/survey-animals/animal-sections.ts b/app/src/features/surveys/view/survey-animals/animal-sections.ts new file mode 100644 index 0000000000..fdd2158017 --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/animal-sections.ts @@ -0,0 +1,258 @@ +import { + mdiAccessPoint, + mdiFamilyTree, + mdiFormatListGroup, + mdiInformationOutline, + mdiRuler, + mdiSkullOutline, + mdiSpiderWeb, + mdiTagOutline +} from '@mdi/js'; +import { SurveyAnimalsI18N } from 'constants/i18n'; +import { v4 } from 'uuid'; +import { + AnimalSex, + IAnimal, + IAnimalCapture, + IAnimalCollectionUnit, + IAnimalMarking, + IAnimalMeasurement, + IAnimalMortality, + IAnimalRelationship, + ProjectionMode +} from './animal'; + +export type IAnimalSections = + | 'General' + | 'Ecological Units' + | 'Markings' + | 'Measurements' + | 'Capture Events' + | 'Mortality Events' + | 'Family' + | 'Telemetry'; + +interface IAnimalSectionsMap + extends Record< + IAnimalSections, + { + animalKeyName: keyof IAnimal; + defaultFormValue: () => object; + addBtnText?: string; + dialogTitle: string; + infoText: string; + mdiIcon: string; + } + > { + [SurveyAnimalsI18N.animalGeneralTitle]: { + animalKeyName: 'general'; + //This probably needs to change to the correct object, general does not use the formikArray pattern + defaultFormValue: () => object; + addBtnText?: string; + dialogTitle: string; + infoText: string; + mdiIcon: string; + }; + [SurveyAnimalsI18N.animalCollectionUnitTitle]: { + animalKeyName: 'collectionUnits'; + defaultFormValue: () => IAnimalCollectionUnit; + addBtnText: string; + dialogTitle: string; + infoText: string; + mdiIcon: string; + }; + [SurveyAnimalsI18N.animalMarkingTitle]: { + animalKeyName: 'markings'; + defaultFormValue: () => IAnimalMarking; + addBtnText: string; + dialogTitle: string; + infoText: string; + mdiIcon: string; + }; + [SurveyAnimalsI18N.animalMeasurementTitle]: { + animalKeyName: 'measurements'; + defaultFormValue: () => IAnimalMeasurement; + addBtnText: string; + dialogTitle: string; + infoText: string; + mdiIcon: string; + }; + [SurveyAnimalsI18N.animalCaptureTitle]: { + animalKeyName: 'captures'; + defaultFormValue: () => IAnimalCapture; + addBtnText: string; + dialogTitle: string; + infoText: string; + mdiIcon: string; + }; + [SurveyAnimalsI18N.animalMortalityTitle]: { + animalKeyName: 'mortality'; + defaultFormValue: () => IAnimalMortality; + addBtnText: string; + dialogTitle: string; + infoText: string; + mdiIcon: string; + }; + [SurveyAnimalsI18N.animalFamilyTitle]: { + animalKeyName: 'family'; + defaultFormValue: () => IAnimalRelationship; + addBtnText: string; + dialogTitle: string; + infoText: string; + mdiIcon: string; + }; +} + +export const ANIMAL_SECTIONS_FORM_MAP: IAnimalSectionsMap = { + [SurveyAnimalsI18N.animalGeneralTitle]: { + animalKeyName: 'general', + defaultFormValue: () => ({ + wlh_id: '', + taxon_id: '', + taxon_name: '', + animal_id: '', + sex: AnimalSex.UNKNOWN, + critter_id: '' + }), + dialogTitle: 'General Information', + infoText: SurveyAnimalsI18N.animalGeneralHelp, + mdiIcon: mdiInformationOutline + }, + [SurveyAnimalsI18N.animalCollectionUnitTitle]: { + animalKeyName: 'collectionUnits', + addBtnText: 'Add Unit', + defaultFormValue: () => ({ + _id: v4(), + collection_unit_id: '', + category_name: '', + unit_name: '', + collection_category_id: '', + critter_collection_unit_id: undefined + }), + dialogTitle: 'Ecological Unit', + infoText: SurveyAnimalsI18N.animalCollectionUnitHelp, + mdiIcon: mdiFormatListGroup + }, + [SurveyAnimalsI18N.animalMarkingTitle]: { + animalKeyName: 'markings', + addBtnText: 'Add Marking', + defaultFormValue: () => ({ + marking_type_id: '', + taxon_marking_body_location_id: '', + primary_colour_id: '', + secondary_colour_id: '', + marking_comment: '', + marking_id: undefined, + primary_colour: '', + secondary_colour: '', + marking_type: '', + body_location: '' + }), + dialogTitle: 'Marking', + infoText: SurveyAnimalsI18N.animalMarkingHelp, + mdiIcon: mdiTagOutline + }, + [SurveyAnimalsI18N.animalMeasurementTitle]: { + animalKeyName: 'measurements', + addBtnText: 'Add Measurement', + defaultFormValue: () => ({ + measurement_qualitative_id: undefined, + measurement_quantitative_id: undefined, + taxon_measurement_id: '', + value: '' as unknown as number, + qualitative_option_id: '', + measured_timestamp: '' as unknown as Date, + measurement_comment: '', + measurement_name: '', + option_label: '' + }), + dialogTitle: 'Measurement', + infoText: SurveyAnimalsI18N.animalMeasurementHelp, + mdiIcon: mdiRuler + }, + [SurveyAnimalsI18N.animalMortalityTitle]: { + animalKeyName: 'mortality', + addBtnText: 'Add Mortality', + defaultFormValue: () => ({ + mortality_longitude: '' as unknown as number, + mortality_latitude: '' as unknown as number, + mortality_utm_northing: '' as unknown as number, + mortality_utm_easting: '' as unknown as number, + mortality_timestamp: '' as unknown as Date, + mortality_coordinate_uncertainty: 10, + mortality_comment: '', + proximate_cause_of_death_id: '', + proximate_cause_of_death_confidence: '', + proximate_predated_by_taxon_id: '', + ultimate_cause_of_death_id: '', + ultimate_cause_of_death_confidence: '', + ultimate_predated_by_taxon_id: '', + projection_mode: 'wgs' as ProjectionMode, + mortality_id: undefined, + location_id: undefined + }), + dialogTitle: 'Mortality', + infoText: SurveyAnimalsI18N.animalMortalityHelp, + mdiIcon: mdiSkullOutline + }, + [SurveyAnimalsI18N.animalFamilyTitle]: { + animalKeyName: 'family', + addBtnText: 'Add Relationship', + defaultFormValue: () => ({ + family_id: '', + relationship: undefined + }), + dialogTitle: 'Family Relationship', + infoText: SurveyAnimalsI18N.animalFamilyHelp, + mdiIcon: mdiFamilyTree + }, + [SurveyAnimalsI18N.animalCaptureTitle]: { + animalKeyName: 'captures', + addBtnText: 'Add Capture Event', + defaultFormValue: () => ({ + capture_latitude: '' as unknown as number, + capture_longitude: '' as unknown as number, + capture_utm_northing: '' as unknown as number, + capture_utm_easting: '' as unknown as number, + capture_comment: '', + capture_coordinate_uncertainty: 10, + capture_timestamp: '' as unknown as Date, + projection_mode: 'wgs' as ProjectionMode, + show_release: false, + release_latitude: '' as unknown as number, + release_longitude: '' as unknown as number, + release_utm_northing: '' as unknown as number, + release_utm_easting: '' as unknown as number, + release_comment: '', + release_timestamp: '' as unknown as Date, + release_coordinate_uncertainty: 10, + capture_id: undefined, + capture_location_id: undefined, + release_location_id: undefined + }), + dialogTitle: 'Capture Event', + infoText: SurveyAnimalsI18N.animalCaptureHelp, + mdiIcon: mdiSpiderWeb + }, + Telemetry: { + animalKeyName: 'device', + addBtnText: 'Add Device / Deployment', + defaultFormValue: () => ({ + device_id: '' as unknown as number, + device_make: '', + frequency: '' as unknown as number, + frequency_unit: '', + device_model: '', + deployments: [ + { + deployment_id: '', + attachment_start: '', + attachment_end: undefined + } + ] + }), + dialogTitle: 'Device / Deployment', + infoText: SurveyAnimalsI18N.telemetryDeviceHelp, + mdiIcon: mdiAccessPoint + } +}; diff --git a/app/src/features/surveys/view/survey-animals/animal.test.ts b/app/src/features/surveys/view/survey-animals/animal.test.ts index c3c7d19c74..b67f21a76d 100644 --- a/app/src/features/surveys/view/survey-animals/animal.test.ts +++ b/app/src/features/surveys/view/survey-animals/animal.test.ts @@ -22,7 +22,6 @@ const animal: IAnimal = { }, captures: [ { - _id: v4(), capture_id: v4(), capture_location_id: undefined, release_location_id: undefined, @@ -46,23 +45,30 @@ const animal: IAnimal = { ], markings: [ { - _id: v4(), - marking_type_id: '274fe690-e253-4987-b11a-5b762d38adf3', taxon_marking_body_location_id: '372020d9-b9ee-4eb3-abdd-b476711bd1aa', primary_colour_id: '4aa3cce7-94d0-42d0-a183-078db5fbdd34', secondary_colour_id: '0b0dbfaa-fcc9-443f-8ac9-a22106663cba', marking_comment: 'asdf', - marking_id: v4() + marking_id: v4(), + marking_type: 'tag', + body_location: 'head', + primary_colour: 'blue' } ], mortality: [], measurements: [], family: [], images: [], - device: undefined, + device: [], collectionUnits: [ - { collection_category_id: 'a', collection_unit_id: 'b', _id: v4(), critter_collection_unit_id: v4() } + { + collection_category_id: 'a', + collection_unit_id: 'b', + critter_collection_unit_id: v4(), + category_name: 'Population Unit', + unit_name: 'pop' + } ] }; @@ -96,7 +102,6 @@ describe('Animal', () => { ...animal, markings: [ { - _id: 'test', marking_type_id: 'a', taxon_marking_body_location_id: 'b' } as IAnimalMarking diff --git a/app/src/features/surveys/view/survey-animals/animal.ts b/app/src/features/surveys/view/survey-animals/animal.ts index 0dad0188c5..da27595553 100644 --- a/app/src/features/surveys/view/survey-animals/animal.ts +++ b/app/src/features/surveys/view/survey-animals/animal.ts @@ -4,7 +4,12 @@ import moment from 'moment'; import yup from 'utils/YupSchema'; import { v4 } from 'uuid'; import { AnyObjectSchema, InferType, reach } from 'yup'; -import { AnimalTelemetryDeviceSchema } from './device'; +import { AnimalTelemetryDeviceSchema } from './telemetry-device/device'; + +export enum ANIMAL_FORM_MODE { + ADD = 'add', + EDIT = 'edit' +} export enum AnimalSex { MALE = 'Male', @@ -28,13 +33,16 @@ export const getAnimalFieldName = (animalKey: keyof IAnimal, fieldKey: keyof */ export const lastAnimalValueValid = (animalKey: keyof IAnimal, values: IAnimal) => { const section = values[animalKey]; - const lastIndex = section.length - 1; - const lastValue = section[lastIndex]; - if (!lastValue) { - return true; + if (Array.isArray(section)) { + const lastIndex = section?.length - 1; + const lastValue = section[lastIndex]; + if (!lastValue) { + return true; + } + const schema = reach(AnimalSchema, `${animalKey}[${lastIndex}]`); + return schema.isValidSync(lastValue); } - const schema = reach(AnimalSchema, `${animalKey}[${lastIndex}]`); - return schema.isValidSync(lastValue); + return true; }; /** @@ -70,7 +78,6 @@ export const AnimalGeneralSchema = yup.object({}).shape({ }); export const AnimalCaptureSchema = yup.object({}).shape({ - _id: yup.string().required(), capture_id: yup.string(), capture_location_id: yup.string(), release_location_id: yup.string(), @@ -104,54 +111,69 @@ export const AnimalCaptureSchema = yup.object({}).shape({ release_comment: yup.string().optional() }); -export const AnimalMarkingSchema = yup.object({}).shape({ - _id: yup.string().required(), +export const AnimalMarkingSchema = yup.object({ marking_id: yup.string(), - marking_type_id: yup.string().required(req), - taxon_marking_body_location_id: yup.string().required(req), + marking_type_id: yup.string().required('Type is required'), + taxon_marking_body_location_id: yup.string().required('Location is required'), primary_colour_id: yup.string().optional(), secondary_colour_id: yup.string().optional(), - marking_comment: yup.string() + marking_comment: yup.string(), + primary_colour: yup.string().optional(), + marking_type: yup.string().optional(), + body_location: yup.string().optional() }); export const AnimalCollectionUnitSchema = yup.object({}).shape({ - _id: yup.string().required(), - collection_unit_id: yup.string().required(), - collection_category_id: yup.string().required(), - critter_collection_unit_id: yup.string() + collection_unit_id: yup.string().required('Name is required'), + collection_category_id: yup.string().required('Category is required'), + critter_collection_unit_id: yup.string(), + unit_name: yup.string().optional(), + category_name: yup.string().optional() }); export const AnimalMeasurementSchema = yup.object({}).shape( { - _id: yup.string().required(), measurement_qualitative_id: yup.string(), measurement_quantitative_id: yup.string(), - taxon_measurement_id: yup.string().required(req), + taxon_measurement_id: yup.string().required('Type is required'), qualitative_option_id: yup.string().when('value', { - is: (value: '' | number) => value === 0 || !value, - then: yup.string().required(req), + is: (value: '' | number) => value === '' || value == null, + then: yup.string().required('Value is required'), otherwise: yup.string() }), value: numSchema.when('qualitative_option_id', { is: (qualitative_option_id: string) => !qualitative_option_id, - then: numSchema.required(req), + then: numSchema.required('Value is required'), otherwise: numSchema }), - measured_timestamp: dateSchema.required(req), - measurement_comment: yup.string() + measured_timestamp: dateSchema.required('Date is required'), + measurement_comment: yup.string(), + option_label: yup.string().optional(), + measurement_name: yup.string().optional() }, [['value', 'qualitative_option_id']] ); export const AnimalMortalitySchema = yup.object({}).shape({ - _id: yup.string().required(), mortality_id: yup.string(), location_id: yup.string(), - mortality_longitude: lonSchema.when('projection_mode', { is: 'wgs', then: lonSchema.required(req) }), - mortality_latitude: latSchema.when('projection_mode', { is: 'wgs', then: latSchema.required(req) }), - mortality_utm_northing: numSchema.when('projection_mode', { is: 'utm', then: numSchema.required(req) }), - mortality_utm_easting: numSchema.when('projection_mode', { is: 'utm', then: numSchema.required(req) }), - mortality_timestamp: dateSchema.required(req), + mortality_longitude: lonSchema.when('projection_mode', { + is: 'wgs', + then: lonSchema.required('Longitude is required') + }), + mortality_latitude: latSchema.when('projection_mode', { + is: 'wgs', + then: latSchema.required('Latitude is required') + }), + mortality_utm_northing: numSchema.when('projection_mode', { + is: 'utm', + then: numSchema.required('UTM Northing is required') + }), + mortality_utm_easting: numSchema.when('projection_mode', { + is: 'utm', + then: numSchema.required('UTM Easting is required') + }), + mortality_timestamp: dateSchema.required('Mortality Date is required'), mortality_coordinate_uncertainty: numSchema, mortality_comment: yup.string(), proximate_cause_of_death_id: yup.string().uuid().required(req), @@ -164,8 +186,6 @@ export const AnimalMortalitySchema = yup.object({}).shape({ }); export const AnimalRelationshipSchema = yup.object({}).shape({ - _id: yup.string().required(), - family_id: yup.string().required(req), relationship: yup.mixed().oneOf(['parent', 'child', 'sibling']).required(req) }); @@ -181,7 +201,7 @@ export const AnimalSchema = yup.object({}).shape({ family: yup.array().of(AnimalRelationshipSchema).required(), images: yup.array().of(AnimalImageSchema).required(), collectionUnits: yup.array().of(AnimalCollectionUnitSchema).required(), - device: AnimalTelemetryDeviceSchema.default(undefined) + device: yup.array().of(AnimalTelemetryDeviceSchema).required() }); export const LocationSchema = yup.object({}).shape({ @@ -250,11 +270,17 @@ type ICritterCapture = Omit< export type ICritterMarking = Omit; -export type ICritterCollection = Omit; +export type ICritterCollection = Omit; -type ICritterQualitativeMeasurement = Omit; +type ICritterQualitativeMeasurement = Omit< + ICritterID & IAnimalMeasurement, + 'value' | '_id' | 'option_label' | 'measurement_name' +>; -type ICritterQuantitativeMeasurement = Omit; +type ICritterQuantitativeMeasurement = Omit< + ICritterID & IAnimalMeasurement, + 'qualitative_option_id' | '_id' | 'option_label' | 'measurement_name' +>; type ICapturesAndLocations = { captures: ICritterCapture[]; capture_locations: ICritterLocation[] }; type IMortalityAndLocation = { mortalities: ICritterMortality[]; mortalities_locations: ICritterLocation[] }; @@ -379,8 +405,8 @@ export class Critter { const mortality_location = { latitude: Number(mortality.mortality_latitude), - longitude: Number(mortality.mortality_latitude), - coordinate_uncertainty: Number(mortality.mortality_latitude), + longitude: Number(mortality.mortality_longitude), + coordinate_uncertainty: Number(mortality.mortality_coordinate_uncertainty), coordinate_uncertainty_unit: 'm' }; @@ -421,7 +447,7 @@ export class Critter { _formatCritterCollectionUnits(animal_collections: IAnimalCollectionUnit[]): ICritterCollection[] { return animal_collections.map((collection) => ({ critter_id: this.critter_id, - ...omit(collection, ['_id', 'collection_category_id']) + ...omit(collection, ['collection_category_id']) })); } 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 index e86fed8f6b..e4b9859408 100644 --- a/app/src/features/surveys/view/survey-animals/form-sections/CaptureAnimalForm.tsx +++ b/app/src/features/surveys/view/survey-animals/form-sections/CaptureAnimalForm.tsx @@ -1,194 +1,113 @@ -import { Checkbox, FormControlLabel, Grid } from '@mui/material'; +import { Box, Checkbox, FormControlLabel, Grid, Stack } from '@mui/material'; +import Typography from '@mui/material/Typography'; import CustomTextField from 'components/fields/CustomTextField'; import SingleDateField from 'components/fields/SingleDateField'; import { SurveyAnimalsI18N } from 'constants/i18n'; -import { Field, FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; -import React, { Fragment, useState } from 'react'; -import { v4 } from 'uuid'; +import { useFormikContext } from 'formik'; import { AnimalCaptureSchema, getAnimalFieldName, IAnimal, IAnimalCapture, isRequiredInSchema } from '../animal'; -import TextInputToggle from '../TextInputToggle'; -import FormSectionWrapper from './FormSectionWrapper'; import LocationEntryForm from './LocationEntryForm'; + /** - * Renders the Capture section for the Individual Animal form - * - * Note A: Using the name properties must stay in sync with - * values object and nested arrays. - * ie: values = { capture: [{id: 'test'}] }; name = 'capture.[0].id'; + * Renders the Capture form inputs * - * Note B: FormSectionWrapper uses a Grid container to render children elements. - * Children of FormSectionWrapper can use Grid items to organize inputs. + * index of formik array item + * @param {index} * * @return {*} - */ - -type ProjectionMode = 'wgs' | 'utm'; -const CaptureAnimalForm = () => { - const { values } = useFormikContext(); - - const name: keyof IAnimal = 'captures'; - const newCapture: IAnimalCapture = { - _id: v4(), - - capture_latitude: '' as unknown as number, - capture_longitude: '' as unknown as number, - capture_utm_northing: '' as unknown as number, - capture_utm_easting: '' as unknown as number, - capture_comment: '', - capture_coordinate_uncertainty: 10, - capture_timestamp: '' as unknown as Date, - projection_mode: 'wgs' as ProjectionMode, - show_release: false, - release_latitude: '' as unknown as number, - release_longitude: '' as unknown as number, - release_utm_northing: '' as unknown as number, - release_utm_easting: '' as unknown as number, - release_comment: '', - release_timestamp: '' as unknown as Date, - release_coordinate_uncertainty: 10, - capture_id: undefined, - capture_location_id: undefined, - release_location_id: undefined - }; - - const canAddNewCapture = () => { - const lastCapture = values.captures[values.captures.length - 1]; - if (!lastCapture) { - return true; - } - const { capture_latitude, capture_longitude, capture_timestamp, capture_coordinate_uncertainty } = lastCapture; - return capture_latitude && capture_longitude && capture_timestamp && capture_coordinate_uncertainty; - }; - - return ( - - {({ remove, push }: FieldArrayRenderProps) => ( - <> - push(newCapture)} - handleRemoveSection={remove}> - {values.captures.map((cap, index) => ( - - ))} - - - )} - - ); -}; + * + **/ interface CaptureAnimalFormContentProps { - name: keyof IAnimal; index: number; - value: IAnimalCapture; } -const CaptureAnimalFormContent = ({ name, index, value }: CaptureAnimalFormContentProps) => { - const { handleBlur, values, handleChange } = useFormikContext(); - const [showCaptureComment, setShowCaptureComment] = useState(false); - const [showReleaseComment, setShowReleaseComment] = useState(false); +export const CaptureAnimalFormContent = ({ index }: CaptureAnimalFormContentProps) => { + const name: keyof IAnimal = 'captures'; - const showReleaseSection = values.captures[index].show_release; + const { values, handleChange } = useFormikContext(); - const renderCaptureFields = (): JSX.Element => { - return ( - - - (name, 'capture_timestamp', index)} - required={true} - label={'Capture Date'} - other={{ size: 'small' }} - /> - - - setShowCaptureComment((c) => !c), toggleState: showCaptureComment }} - label="Add comment about this Capture"> - (name, 'capture_comment', index)} - handleBlur={handleBlur} + const value = values.captures?.[index]; + + const showReleaseSection = value?.show_release; + + return ( + + + Event Dates + + + (name, 'capture_timestamp', index)} + required={true} + label={'Capture Date'} /> - - - - (name, 'show_release', index)} - /> - } - label={SurveyAnimalsI18N.animalCaptureReleaseRadio} - /> + + + (name, 'release_timestamp', index)} + label={'Release Date'} + /> +
- - ); - }; + - const renderReleaseFields = (): JSX.Element => { - return ( - - - (name, 'release_timestamp', index)} - label={'Release Date'} - other={{ size: 'small' }} - /> - - - setShowReleaseComment((c) => !c), toggleState: showReleaseComment }}> - (name, 'release_comment', index)} - handleBlur={handleBlur} + + Release Location + (name, 'show_release', index)} + /> + } + label={SurveyAnimalsI18N.animalCaptureReleaseRadio} /> - - - - ); - }; + + ]} + /> - return ( - + + Additional Information + (name, 'capture_comment', index)} + /> + + ); }; -export default CaptureAnimalForm; +export default CaptureAnimalFormContent; 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 index d52d4469a2..a45a308e8d 100644 --- 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 @@ -1,8 +1,8 @@ -import { SurveyAnimalsI18N } from 'constants/i18n'; import { Formik } from 'formik'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import { fireEvent, render, waitFor } from 'test-helpers/test-utils'; -import CollectionUnitAnimalForm from './CollectionUnitAnimalForm'; +import { render, waitFor } from 'test-helpers/test-utils'; +import { ANIMAL_SECTIONS_FORM_MAP } from '../animal-sections'; +import CollectionUnitAnimalFormContent from './CollectionUnitAnimalForm'; jest.mock('hooks/useCritterbaseApi'); @@ -14,27 +14,29 @@ const mockUseCritterbase = { } }; +const defaultCollectionUnit = ANIMAL_SECTIONS_FORM_MAP['Ecological Units'].defaultFormValue; + describe('CollectionUnitAnimalForm', () => { beforeEach(() => { mockUseCritterbaseApi.mockImplementation(() => mockUseCritterbase); mockUseCritterbase.lookup.getSelectOptions.mockClear(); }); - it('should display a new part of the form when add unit clicked', async () => { + 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(() => { - fireEvent.click(getByText(SurveyAnimalsI18N.animalCollectionUnitAddBtn)); - expect(getByText(SurveyAnimalsI18N.animalCollectionUnitTitle2)).toBeInTheDocument(); - expect(getByText('Unit Category')).toBeInTheDocument(); - expect(getByText('Unit Name')).toBeInTheDocument(); + 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 index cda5edd589..049256c5e1 100644 --- a/app/src/features/surveys/view/survey-animals/form-sections/CollectionUnitAnimalForm.tsx +++ b/app/src/features/surveys/view/survey-animals/form-sections/CollectionUnitAnimalForm.tsx @@ -1,41 +1,22 @@ import { Grid } from '@mui/material'; import CbSelectField from 'components/fields/CbSelectField'; -import { SurveyAnimalsI18N } from 'constants/i18n'; -import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import useDataLoader from 'hooks/useDataLoader'; -import { Fragment, useEffect } from 'react'; -import { v4 } from 'uuid'; +import { useFormikContext } from 'formik'; import { AnimalCollectionUnitSchema, getAnimalFieldName, IAnimal, IAnimalCollectionUnit, - isRequiredInSchema, - lastAnimalValueValid + isRequiredInSchema } from '../animal'; -import FormSectionWrapper from './FormSectionWrapper'; -const CollectionUnitAnimalForm = () => { - const api = useCritterbaseApi(); - const { values } = useFormikContext(); - const { data: categoriesData, refresh } = useDataLoader(api.lookup.getSelectOptions); - - useEffect(() => { - if (values.general.taxon_id) { - refresh({ route: 'lookups/taxon-collection-categories', query: `taxon_id=${values.general.taxon_id}` }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [values.general.taxon_id]); +interface ICollectionUnitAnimalFormContentProps { + index: number; +} +export const CollectionUnitAnimalFormContent = ({ index }: ICollectionUnitAnimalFormContentProps) => { const name: keyof IAnimal = 'collectionUnits'; - const newCollectionUnit = (): IAnimalCollectionUnit => ({ - _id: v4(), - collection_unit_id: '', - collection_category_id: '', - critter_collection_unit_id: undefined - }); + const { values, setFieldValue } = useFormikContext(); //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 = values.collectionUnits.reduce((acc: Record, curr) => { @@ -45,58 +26,47 @@ const CollectionUnitAnimalForm = () => { return acc; }, {}); + const handleCategoryName = (_value: string, label: string) => { + setFieldValue(getAnimalFieldName(name, 'category_name', index), label); + }; + + const handleUnitName = (_value: string, label: string) => { + setFieldValue(getAnimalFieldName(name, 'unit_name', index), label); + }; + return ( - - {({ remove, push }: FieldArrayRenderProps) => ( - <> - push(newCollectionUnit())} - handleRemoveSection={remove}> - {values.collectionUnits.map((unit, index) => ( - - - (name, 'collection_category_id', index)} - id={'collection_category_id'} - disabledValues={disabledCategories} - query={`taxon_id=${values.general.taxon_id}`} - route={'lookups/taxon-collection-categories'} - controlProps={{ - size: 'small', - required: isRequiredInSchema(AnimalCollectionUnitSchema, 'collection_category_id') - }} - /> - - - (name, 'collection_unit_id', index)} - controlProps={{ - size: 'small', - required: isRequiredInSchema(AnimalCollectionUnitSchema, 'collection_unit_id') - }} - /> - - - ))} - - - )} - + + + (name, 'collection_category_id', index)} + id={'collection_category_id'} + disabledValues={disabledCategories} + query={`taxon_id=${values.general.taxon_id}`} + route={'lookups/taxon-collection-categories'} + controlProps={{ + size: 'medium', + required: isRequiredInSchema(AnimalCollectionUnitSchema, 'collection_category_id') + }} + handleChangeSideEffect={handleCategoryName} + /> + + + (name, 'collection_unit_id', index)} + controlProps={{ + size: 'medium', + required: isRequiredInSchema(AnimalCollectionUnitSchema, 'collection_unit_id') + }} + handleChangeSideEffect={handleUnitName} + /> + + ); }; -export default CollectionUnitAnimalForm; +export default CollectionUnitAnimalFormContent; 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 index d2b6dd49fd..f9e75b92e8 100644 --- 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 @@ -1,8 +1,7 @@ -import { SurveyAnimalsI18N } from 'constants/i18n'; import { Formik } from 'formik'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import { fireEvent, render, waitFor } from 'test-helpers/test-utils'; -import FamilyAnimalForm from './FamilyAnimalForm'; +import { render, waitFor } from 'test-helpers/test-utils'; +import FamilyAnimalFormContent from './FamilyAnimalForm'; jest.mock('hooks/useCritterbaseApi'); @@ -23,20 +22,18 @@ describe('FamilyAnimalForm', () => { mockUseCritterbase.lookup.getSelectOptions.mockClear(); mockUseCritterbase.family.getAllFamilies.mockClear(); }); - it('should display a new part of the form when add unit clicked', async () => { + 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(() => { - fireEvent.click(getByText(SurveyAnimalsI18N.animalFamilyAddBtn)); - expect(getByText(SurveyAnimalsI18N.animalFamilyTitle2)).toBeInTheDocument(); expect(getByText('Family ID')).toBeInTheDocument(); expect(getByText('Relationship')).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 index 343eb54558..a66b0f1975 100644 --- a/app/src/features/surveys/view/survey-animals/form-sections/FamilyAnimalForm.tsx +++ b/app/src/features/surveys/view/survey-animals/form-sections/FamilyAnimalForm.tsx @@ -3,22 +3,19 @@ import { grey } from '@mui/material/colors'; import { makeStyles } from '@mui/styles'; import ComponentDialog from 'components/dialog/ComponentDialog'; import { CbSelectWrapper } from 'components/fields/CbSelectFieldWrapper'; -import { SurveyAnimalsI18N } from 'constants/i18n'; -import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; +import { useFormikContext } from 'formik'; +import { IFamily } from 'hooks/cb_api/useFamilyApi'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; import useDataLoader from 'hooks/useDataLoader'; -import React, { Fragment, useState } from 'react'; -import { v4 } from 'uuid'; +import { useState } from 'react'; import { AnimalRelationshipSchema, getAnimalFieldName, IAnimal, IAnimalRelationship, isRequiredInSchema, - lastAnimalValueValid, newFamilyIdPlaceholder } from '../animal'; -import FormSectionWrapper from './FormSectionWrapper'; const useStyles = makeStyles((theme: Theme) => ({ surveyMetadataContainer: { '& dt': { @@ -41,34 +38,14 @@ const useStyles = makeStyles((theme: Theme) => ({ } })); -/** - * Renders the Family section for the Individual Animal form - * - * This form needs to validate against the Critterbase critter table, as only critters that have already been - * added to Critterbase are permissible as family members. - * - * @return {*} - **/ -const FamilyAnimalForm = () => { - const { values, handleChange } = useFormikContext(); - const critterbase = useCritterbaseApi(); - const { data: allFamilies, load } = useDataLoader(critterbase.family.getAllFamilies); - const { data: familyHierarchy, load: loadHierarchy } = useDataLoader(critterbase.family.getImmediateFamily); - const [showFamilyStructure, setShowFamilyStructure] = useState(false); - if (!allFamilies) { - load(); - } - - const classes = useStyles(); +interface IFamilyAnimalFormContentProps { + index: number; + allFamilies?: IFamily[]; +} +export const FamilyAnimalFormContent = ({ index, allFamilies }: IFamilyAnimalFormContentProps) => { const name: keyof IAnimal = 'family'; - const newRelationship: IAnimalRelationship = { - _id: v4(), - - family_id: '', - relationship: undefined - }; - + const { values, handleChange } = useFormikContext(); const disabledFamilyIds = values.family.reduce((acc: Record, curr) => { if (curr.family_id) { acc[curr.family_id] = true; @@ -76,141 +53,128 @@ const FamilyAnimalForm = () => { return acc; }, {}); + const classes = useStyles(); + const [showFamilyStructure, setShowFamilyStructure] = useState(false); + const critterbase = useCritterbaseApi(); + const { data: familyHierarchy, load: loadHierarchy } = useDataLoader(critterbase.family.getImmediateFamily); return ( - - {({ remove, push }: FieldArrayRenderProps) => ( - <> - push(newRelationship)} - handleRemoveSection={remove}> - {values.family.map((fam, index) => ( - - - (name, 'family_id', index)} - onChange={handleChange} - controlProps={{ - size: 'small', - required: isRequiredInSchema(AnimalRelationshipSchema, 'family_id') - }}> - {[ - ...(allFamilies ?? []), - { family_id: newFamilyIdPlaceholder, family_label: newFamilyIdPlaceholder } - ]?.map((a) => ( - - {a.family_label ? a.family_label : a.family_id} - - ))} - - - - - (name, 'relationship', index)} - onChange={handleChange} - controlProps={{ - size: 'small', - required: isRequiredInSchema(AnimalRelationshipSchema, 'relationship') - }}> - - Parent in - - - Child in - - + + + (name, 'family_id', index)} + onChange={handleChange} + controlProps={{ + size: 'medium', + required: isRequiredInSchema(AnimalRelationshipSchema, 'family_id') + }}> + {[...(allFamilies ?? []), { family_id: newFamilyIdPlaceholder, family_label: newFamilyIdPlaceholder }]?.map( + (family) => ( + + {family.family_label ? family.family_label : family.family_id} + + ) + )} + + + + + (name, 'relationship', index)} + onChange={handleChange} + controlProps={{ + size: 'medium', + required: isRequiredInSchema(AnimalRelationshipSchema, 'relationship') + }}> + + Parent in + + + Child in + + + + + + + + setShowFamilyStructure(false)}> + + + Family ID + + + {values.family[index]?.family_id} + + + Parents: +
    + {familyHierarchy?.parents.map((a) => ( +
  • + + + + Critter ID + + {a.critter_id} + + + + Animal ID + + {a.animal_id} + - - - - - setShowFamilyStructure(false)}> - - - Family ID - - - {values.family[index]?.family_id} - - - Parents: -
      - {familyHierarchy?.parents.map((a) => ( -
    • - - - - Critter ID - - {a.critter_id} - - - - Animal ID - - {a.animal_id} - - -
    • - ))} -
    -
    - - Children: -
      - {familyHierarchy?.children.map( - ( - a: { critter_id: string; animal_id: string } //I will type this better I promise - ) => ( -
    • - - - - Critter ID - - {a.critter_id} - - - - Animal ID - - {a.animal_id} - - -
    • - ) - )} -
    -
    -
    -
    - - ))} - - - )} - +
  • + ))} +
+
+ + Children: +
    + {familyHierarchy?.children.map( + ( + a: { critter_id: string; animal_id: string } //I will type this better I promise + ) => ( +
  • + + + + Critter ID + + {a.critter_id} + + + + Animal ID + + {a.animal_id} + + +
  • + ) + )} +
+
+
+
+
); }; -export default FamilyAnimalForm; +export default FamilyAnimalFormContent; diff --git a/app/src/features/surveys/view/survey-animals/form-sections/FormSectionWrapper.tsx b/app/src/features/surveys/view/survey-animals/form-sections/FormSectionWrapper.tsx deleted file mode 100644 index 12b4a0b68a..0000000000 --- a/app/src/features/surveys/view/survey-animals/form-sections/FormSectionWrapper.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { mdiPlus, mdiTrashCanOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import { Box, Button, Divider, Grid, IconButton, PaperProps, Typography } from '@mui/material'; -import Card from '@mui/material/Card'; -import CardContent from '@mui/material/CardContent'; -import CardHeader from '@mui/material/CardHeader'; -import { grey } from '@mui/material/colors'; -import { useFormikContext } from 'formik'; -import { IAnimal } from '../animal'; - -interface FormSectionWrapperProps { - title: string; // Title ie: General / Capture Information etc - titleHelp: string; // Text to display under title, subtitle text - addedSectionTitle?: string; // Header to display inside added section - btnLabel?: string; // Add section btn label ie: 'Add Capture Event' - disableAddBtn?: boolean; - handleAddSection?: () => void; // function to call when add btn selected - handleRemoveSection?: (index: number) => void; // function to call when "X" btn selected - innerPaperProps?: PaperProps; - children: JSX.Element[] | JSX.Element; - maxSections?: number; -} - -/** - * Wrapper for rendering the section inputs with additional controls for - * adding deleting form sections/inputs. - * - * @param { FormSectionWrapperProps } - * @return {*} - * - **/ -const FormSectionWrapper = ({ - title, - addedSectionTitle, - titleHelp, - children, - handleAddSection, - disableAddBtn, - handleRemoveSection, - innerPaperProps, - btnLabel, - maxSections -}: FormSectionWrapperProps) => { - const { values } = useFormikContext(); - //For convienence, vs rendering duplicated components for children and children[] - const childs = Array.isArray(children) ? children : [children]; - const showBtn = btnLabel && handleAddSection && (maxSections === undefined || childs.length < maxSections); - - return ( - <> - - - {title} - - {titleHelp} - - {childs.map((child, idx) => ( - - - {addedSectionTitle ? ( - <>{childs.length > 1 ? `${addedSectionTitle} (${idx + 1})` : `${addedSectionTitle}`} - ) : null} - - } - action={ - <> - {handleRemoveSection && childs.length >= 1 ? ( - handleRemoveSection(idx)}> - - - ) : null} - - } - sx={{ - py: 1.5, - background: grey[100], - fontSize: '0.875rem' - }}> - - - {child} - - - - ))} - {showBtn ? ( - - ) : null} - - - ); -}; - -export default FormSectionWrapper; 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 index 12f5fe07be..adcc9483a1 100644 --- a/app/src/features/surveys/view/survey-animals/form-sections/GeneralAnimalForm.tsx +++ b/app/src/features/surveys/view/survey-animals/form-sections/GeneralAnimalForm.tsx @@ -1,12 +1,11 @@ -import Box from '@mui/material/Box'; import Grid from '@mui/material/Grid'; -import Typography from '@mui/material/Typography'; import HelpButtonTooltip from 'components/buttons/HelpButtonTooltip'; import CbSelectField from 'components/fields/CbSelectField'; import CustomTextField from 'components/fields/CustomTextField'; import { SurveyAnimalsI18N } from 'constants/i18n'; import { useFormikContext } from 'formik'; import { AnimalGeneralSchema, getAnimalFieldName, IAnimal, IAnimalGeneral, isRequiredInSchema } from '../animal'; +import { ANIMAL_SECTIONS_FORM_MAP } from '../animal-sections'; /** * Renders the General section for the Individual Animal form @@ -15,73 +14,57 @@ import { AnimalGeneralSchema, getAnimalFieldName, IAnimal, IAnimalGeneral, isReq */ const GeneralAnimalForm = () => { - const { setFieldValue, handleBlur, values } = useFormikContext(); - const name: keyof IAnimal = 'general'; - + const { setFieldValue, values } = useFormikContext(); + const { animalKeyName } = ANIMAL_SECTIONS_FORM_MAP[SurveyAnimalsI18N.animalGeneralTitle]; const handleTaxonName = (_value: string, label: string) => { - setFieldValue(getAnimalFieldName(name, 'taxon_name'), label); + setFieldValue(getAnimalFieldName(animalKeyName, 'taxon_name'), label); }; return ( - - {SurveyAnimalsI18N.animalGeneralTitle} - - {SurveyAnimalsI18N.animalGeneralHelp} - - - - - - (name, 'taxon_id')} - controlProps={{ - required: isRequiredInSchema(AnimalGeneralSchema, 'taxon_id'), - disabled: !!values.collectionUnits.length - }} - label={'Species'} - id={'taxon'} - route={'lookups/taxons'} - handleChangeSideEffect={handleTaxonName} - /> - - - + + + (name, 'sex')} - controlProps={{ required: isRequiredInSchema(AnimalGeneralSchema, 'sex') }} - label="Sex" - id={'sex'} - route={'lookups/sex'} + name={getAnimalFieldName(animalKeyName, 'taxon_id')} + controlProps={{ + required: isRequiredInSchema(AnimalGeneralSchema, 'taxon_id'), + disabled: !!values.collectionUnits.length + }} + label={'Species'} + id={'taxon'} + route={'lookups/taxons'} + handleChangeSideEffect={handleTaxonName} + /> + + + + (animalKeyName, 'sex')} + controlProps={{ required: isRequiredInSchema(AnimalGeneralSchema, 'sex') }} + label="Sex" + id={'sex'} + route={'lookups/sex'} + /> + + + + (animalKeyName, 'animal_id')} + /> + + + + + (animalKeyName, 'wlh_id')} /> - - - - (name, 'animal_id')} - handleBlur={handleBlur} - /> - - - - - (name, 'wlh_id')} - handleBlur={handleBlur} - /> - - + - +
); }; 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 index 1bb031a5b4..c4679170b3 100644 --- a/app/src/features/surveys/view/survey-animals/form-sections/LocationEntryForm.tsx +++ b/app/src/features/surveys/view/survey-animals/form-sections/LocationEntryForm.tsx @@ -1,15 +1,31 @@ -import { Box, FormControlLabel, FormGroup, Grid, Switch, Tab, Tabs } from '@mui/material'; +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 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 CustomTextField from 'components/fields/CustomTextField'; +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 MapContainer from 'components/map/MapContainer'; +import { MAP_DEFAULT_CENTER, MAP_DEFAULT_ZOOM } from 'constants/spatial'; import { useFormikContext } from 'formik'; import { LatLng } from 'leaflet'; -import { ChangeEvent, useState } from 'react'; +import { ChangeEvent, Fragment, useState } from 'react'; +import { LayersControl, MapContainer as LeafletMapContainer } from 'react-leaflet'; import { getLatLngAsUtm, getUtmAsLatLng } from 'utils/mapProjectionHelpers'; -import { coerceZero, formatLabel } from 'utils/Utils'; +import { coerceZero } from 'utils/Utils'; import { getAnimalFieldName, IAnimal, ProjectionMode } from '../animal'; +type Marker = 'primary' | 'secondary' | null; + export type LocationEntryFields = { + fieldsetTitle?: string; + latitude: keyof T; longitude: keyof T; coordinate_uncertainty: keyof T; @@ -36,9 +52,8 @@ const LocationEntryForm = ({ otherPrimaryFields, otherSecondaryFields }: LocationEntryFormProps) => { - const { handleBlur, setFieldValue } = useFormikContext(); - const [tabState, setTabState] = useState(0); //Controls whether we are on the Forms tab or the Map tab. - const [placeSecondaryMode, setPlaceSecondary] = useState(false); //Controls whether left clicking on the map will place the capture or release marker. + const { setFieldValue } = useFormikContext(); + const [markerEnabled, setMarkerEnabled] = useState(null); const handleMarkerPlacement = (e: LatLng, fields: LocationEntryFields) => { setFieldValue(getAnimalFieldName(name, fields.latitude, index), e.lat.toFixed(3)); @@ -71,9 +86,9 @@ const LocationEntryForm = ({ }; const onProjectionModeSwitch = (e: ChangeEvent) => { - //This gets called everytime the toggle element fires. We need to do a projection each time so that the new fields that get shown + //This gets called every time the toggle element fires. We need to do a projection each time so that the new fields that get shown //will be in sync with the values from the ones that were just hidden. - if (value.projection_mode === 'wgs') { + if (value?.projection_mode === 'wgs') { setLatLonFromUTM(primaryLocationFields); setLatLonFromUTM(secondaryLocationFields); } else { @@ -84,7 +99,7 @@ const LocationEntryForm = ({ }; const getCurrentMarkerPos = (fields: LocationEntryFields): LatLng => { - if (value.projection_mode === 'utm') { + if (value?.projection_mode === 'utm') { const latlng_coords = getUtmAsLatLng( coerceZero(value[fields.utm_northing]), coerceZero(value[fields.utm_easting]) @@ -95,73 +110,84 @@ const LocationEntryForm = ({ } }; - const renderLocationFields = (fields: LocationEntryFields): JSX.Element => { + const handleMarkerSelected = (event: React.MouseEvent, enableMarker: Marker) => { + setMarkerEnabled(enableMarker); + }; + + const renderLocationFields = (fields?: LocationEntryFields): JSX.Element => { + if (!fields) { + return <>; + } return ( - <> - {value.projection_mode === 'wgs' ? ( - <> - + + {value?.projection_mode === 'wgs' ? ( + + (name, fields.latitude, index)} - handleBlur={handleBlur} /> - + (name, fields.longitude, index)} - handleBlur={handleBlur} /> - + ) : ( - <> - + + (name, fields.utm_northing, index)} - handleBlur={handleBlur} /> - + (name, fields.utm_easting, index)} - handleBlur={handleBlur} /> - + )} - + (name, fields.coordinate_uncertainty, index)} - handleBlur={handleBlur} /> - + ); }; const renderResizableMarker = ( - fields: LocationEntryFields, + fields: LocationEntryFields | undefined, listening: boolean, color: MarkerIconColor ): JSX.Element => { + if (!fields) { + return <>; + } return ( handleMarkerPlacement(p, fields)} + handlePlace={(p) => { + handleMarkerPlacement(p, fields); + setMarkerEnabled(null); + }} handleResize={(n) => { setFieldValue(getAnimalFieldName(name, fields.coordinate_uncertainty, index), n.toFixed(3)); }} @@ -170,69 +196,107 @@ const LocationEntryForm = ({ }; return ( - <> - - { - setTabState(newVal); - }}> - - - - + + + {primaryLocationFields.fieldsetTitle ? ( + {primaryLocationFields.fieldsetTitle} + ) : null} - {tabState === 0 ? ( - <> - - - - } - label="UTM Coordinates" - /> - - + {renderLocationFields(primaryLocationFields)} - {otherPrimaryFields} - {secondaryLocationFields ? ( - <> - {renderLocationFields(secondaryLocationFields)} - {otherSecondaryFields} - - ) : null} - - ) : ( - - {secondaryLocationFields ? ( - - setPlaceSecondary(b)} size={'small'} /> + + } + label="Use UTM Coordinates" + /> + + + + {otherSecondaryFields ? ( + + {otherSecondaryFields} + {renderLocationFields(secondaryLocationFields)} + + ) : null} + + + Location Preview + + - - ) : null} - - + + {primaryLocationFields ? ( + + {`Set ${primaryLocationFields?.fieldsetTitle ?? 'Primary Location'}`} + + ) : null} + {secondaryLocationFields ? ( + + {`Set ${secondaryLocationFields?.fieldsetTitle ?? 'Secondary Location'}`} + + ) : null} + + + + + ) ]} /> - - - )} - + + + + + + + ); }; 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 index 069ddf2ba2..08f3007cde 100644 --- a/app/src/features/surveys/view/survey-animals/form-sections/MarkingAnimalForm.tsx +++ b/app/src/features/surveys/view/survey-animals/form-sections/MarkingAnimalForm.tsx @@ -1,135 +1,153 @@ -import { Grid } from '@mui/material'; +import { Button, Grid } from '@mui/material'; +import EditDialog from 'components/dialog/EditDialog'; import CbSelectField from 'components/fields/CbSelectField'; import CustomTextField from 'components/fields/CustomTextField'; -import { SurveyAnimalsI18N } from 'constants/i18n'; -import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import useDataLoader from 'hooks/useDataLoader'; -import { Fragment, useEffect } from 'react'; -import { v4 } from 'uuid'; +import FormikDevDebugger from 'components/formik/FormikDevDebugger'; +import { EditDeleteStubCard } from 'features/surveys/components/EditDeleteStubCard'; +import { useFormikContext } from 'formik'; +import { useState } from 'react'; import { AnimalMarkingSchema, + ANIMAL_FORM_MODE, getAnimalFieldName, IAnimal, IAnimalMarking, - isRequiredInSchema, - lastAnimalValueValid + isRequiredInSchema } from '../animal'; -import TextInputToggle from '../TextInputToggle'; -import FormSectionWrapper from './FormSectionWrapper'; +import { ANIMAL_SECTIONS_FORM_MAP } from '../animal-sections'; -/** - * Renders the Marking section for the Individual Animal form - * - * @return {*} +type MarkingAnimalFormProps = + | { + taxon_id: string; // temp will probably place inside a context + display: 'button'; + marking?: never; + } + | { + taxon_id: string; + display: 'card'; + marking: IAnimalMarking; + }; +/* + * Note: This is a placeholder component for how to handle the form sections individually + * allows easier management of the individual form sections with push / patch per form + * vs how it's currently implemented with one large payload that updates/removes/creates critter meta */ +export const MarkingAnimalForm = (props: MarkingAnimalFormProps) => { + const [dialogMode, setDialogMode] = useState(null); + const markingInfo = ANIMAL_SECTIONS_FORM_MAP['Markings']; + return ( + <> + placeholder for marking component
, + initialValues: props?.marking ?? markingInfo.defaultFormValue(), + validationSchema: AnimalMarkingSchema + }} + onCancel={() => setDialogMode(null)} + onSave={(values) => { + setDialogMode(null); + console.log(values); + }} + /> + {props.display === 'button' ? ( + + ) : ( + + )} + + ); +}; -const MarkingAnimalForm = () => { - const api = useCritterbaseApi(); - const { values, handleBlur } = useFormikContext(); - const { data: bodyLocations, load, refresh } = useDataLoader(api.lookup.getTaxonMarkingBodyLocations); +interface IMarkingAnimalFormContentProps { + index: number; +} - if (values.general.taxon_id) { - load(values.general.taxon_id); - } +export const MarkingAnimalFormContent = ({ index }: IMarkingAnimalFormContentProps) => { + const name: keyof IAnimal = 'markings'; - useEffect(() => { - refresh(values.general.taxon_id); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [values.general.taxon_id]); + const { values, setFieldValue } = useFormikContext(); - const name: keyof IAnimal = 'markings'; - const newMarking: IAnimalMarking = { - _id: v4(), + const handlePrimaryColourName = (_value: string, label: string) => { + setFieldValue(getAnimalFieldName(name, 'primary_colour', index), label); + }; + + const handleMarkingTypeName = (_value: string, label: string) => { + setFieldValue(getAnimalFieldName(name, 'marking_type', index), label); + }; - marking_type_id: '', - taxon_marking_body_location_id: '', - primary_colour_id: '', - secondary_colour_id: '', - marking_comment: '', - marking_id: undefined + const handleMarkingLocationName = (_value: string, label: string) => { + setFieldValue(getAnimalFieldName(name, 'body_location', index), label); }; return ( - - {({ remove, push }: FieldArrayRenderProps) => ( - <> - push(newMarking)} - handleRemoveSection={remove}> - {values?.markings?.map((mark, index) => ( - - - (name, 'marking_type_id', index)} - id="marking_type" - route="lookups/marking-types" - controlProps={{ - size: 'small', - required: isRequiredInSchema(AnimalMarkingSchema, 'marking_type_id') - }} - /> - - - (name, 'taxon_marking_body_location_id', index)} - id="marking_body_location" - route="xref/taxon-marking-body-locations" - query={`taxon_id=${values.general.taxon_id}`} - controlProps={{ - size: 'small', - required: isRequiredInSchema(AnimalMarkingSchema, 'taxon_marking_body_location_id') - }} - /> - - - (name, 'primary_colour_id', index)} - id="primary_colour_id" - route="lookups/colours" - controlProps={{ - size: 'small', - required: isRequiredInSchema(AnimalMarkingSchema, 'primary_colour_id') - }} - /> - - - (name, 'secondary_colour_id', index)} - id="secondary_colour_id" - route="lookups/colours" - controlProps={{ - size: 'small', - required: isRequiredInSchema(AnimalMarkingSchema, 'secondary_colour_id') - }} - /> - - - - (name, 'marking_comment', index)} - other={{ size: 'small', required: isRequiredInSchema(AnimalMarkingSchema, 'marking_comment') }} - handleBlur={handleBlur} - /> - - - - ))} - - - )} - + + + (name, 'marking_type_id', index)} + id="marking_type" + route="lookups/marking-types" + controlProps={{ + size: 'medium', + required: isRequiredInSchema(AnimalMarkingSchema, 'marking_type_id') + }} + handleChangeSideEffect={handleMarkingTypeName} + /> + + + (name, 'taxon_marking_body_location_id', index)} + id="marking_body_location" + route="xref/taxon-marking-body-locations" + query={`taxon_id=${values.general.taxon_id}`} + controlProps={{ + size: 'medium', + required: isRequiredInSchema(AnimalMarkingSchema, 'taxon_marking_body_location_id') + }} + handleChangeSideEffect={handleMarkingLocationName} + /> + + + (name, 'primary_colour_id', index)} + id="primary_colour_id" + route="lookups/colours" + controlProps={{ + size: 'medium', + required: isRequiredInSchema(AnimalMarkingSchema, 'primary_colour_id') + }} + handleChangeSideEffect={handlePrimaryColourName} + /> + + + (name, 'secondary_colour_id', index)} + id="secondary_colour_id" + route="lookups/colours" + controlProps={{ + size: 'medium', + required: isRequiredInSchema(AnimalMarkingSchema, 'secondary_colour_id') + }} + /> + + + (name, 'marking_comment', index)} + other={{ + size: 'medium', + multiline: true, + minRows: 3, + required: isRequiredInSchema(AnimalMarkingSchema, 'marking_comment') + }} + /> + + + ); }; - -export default MarkingAnimalForm; 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 index df4e61d6b6..3b7bcd204a 100644 --- a/app/src/features/surveys/view/survey-animals/form-sections/MeasurementAnimalForm.tsx +++ b/app/src/features/surveys/view/survey-animals/form-sections/MeasurementAnimalForm.tsx @@ -3,127 +3,69 @@ import CbSelectField from 'components/fields/CbSelectField'; import { CbSelectWrapper } from 'components/fields/CbSelectFieldWrapper'; import CustomTextField from 'components/fields/CustomTextField'; import SingleDateField from 'components/fields/SingleDateField'; -import { SurveyAnimalsI18N } from 'constants/i18n'; -import { Field, FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; +import { Field, useFormikContext } from 'formik'; import { IMeasurementStub } from 'hooks/cb_api/useLookupApi'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import useDataLoader from 'hooks/useDataLoader'; -import { has } from 'lodash-es'; -import { Fragment, useEffect, useState } from 'react'; -import { v4 } from 'uuid'; +import { has, startCase } from 'lodash-es'; +import { useEffect, useState } from 'react'; import { AnimalMeasurementSchema, + ANIMAL_FORM_MODE, getAnimalFieldName, IAnimal, IAnimalMeasurement, isRequiredInSchema } from '../animal'; -import TextInputToggle from '../TextInputToggle'; -import FormSectionWrapper from './FormSectionWrapper'; - -const NAME: keyof IAnimal = 'measurements'; /** - * Renders the Measurement section for the Individual Animal form - * - * Note a: Requesting the raw unformatted measurement data to allow easier lookups - * Displays both qualitative / quantitative measurement options in one dropdown. - * The value / option selector needs to change based on the chosen measurement in first select. - * - * Note b: Custom quantiative measurement validation based on min / max values in database. + * Renders the Measurement form inputs * * @return {*} */ -const MeasurementAnimalForm = () => { - const api = useCritterbaseApi(); - const { values } = useFormikContext(); - - const { data: measurements, refresh, load } = useDataLoader(api.lookup.getTaxonMeasurements); - - if (values.general.taxon_id) { - load(values.general.taxon_id); - } - - useEffect(() => { - refresh(values.general.taxon_id); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [values.general.taxon_id]); - - const newMeasurement: IAnimalMeasurement = { - _id: v4(), - measurement_qualitative_id: undefined, - measurement_quantitative_id: undefined, - taxon_measurement_id: '', - value: '' as unknown as number, - qualitative_option_id: '', - measured_timestamp: '' as unknown as Date, - measurement_comment: '' - }; - - const canAddMeasurement = () => { - const lastMeasurement = values.measurements[values.measurements.length - 1]; - if (!lastMeasurement) { - return true; - } - const { value, qualitative_option_id, taxon_measurement_id, measured_timestamp } = lastMeasurement; - const hasValueOrOption = value || qualitative_option_id; - return taxon_measurement_id && measured_timestamp && hasValueOrOption; - }; - - return ( - - {({ remove, push }: FieldArrayRenderProps) => ( - <> - push(newMeasurement)} - handleRemoveSection={remove}> - {values.measurements.map((measurement, index) => ( - - ))} - - - )} - - ); -}; - interface MeasurementFormContentProps { index: number; measurements?: IMeasurementStub[]; + mode: ANIMAL_FORM_MODE; } -const MeasurementFormContent = ({ index, measurements }: MeasurementFormContentProps) => { +export const MeasurementAnimalFormContent = (props: MeasurementFormContentProps) => { + const { index, measurements, mode } = props; + const name: keyof IAnimal = 'measurements'; const { values, handleChange, setFieldValue, handleBlur } = useFormikContext(); - const [measurement, setMeasurement] = useState(); + const taxonMeasurementId = values.measurements?.[index]?.taxon_measurement_id; + const [currentMeasurement, setCurrentMeasurement] = useState( + measurements?.find((lookup_measurement) => lookup_measurement.taxon_measurement_id === taxonMeasurementId) + ); + const isQuantMeasurement = has(currentMeasurement, 'unit'); - const taxonMeasurementId = values.measurements[index].taxon_measurement_id; - const isQuantMeasurement = has(measurement, 'unit'); + const taxonMeasurementIDName = getAnimalFieldName(name, 'taxon_measurement_id', index); + const valueName = getAnimalFieldName(name, 'value', index); + const optionName = getAnimalFieldName(name, 'qualitative_option_id', index); - const tMeasurementIDName = getAnimalFieldName(NAME, 'taxon_measurement_id', index); - const valueName = getAnimalFieldName(NAME, 'value', index); - const optionName = getAnimalFieldName(NAME, 'qualitative_option_id', index); + useEffect(() => { + setCurrentMeasurement(measurements?.find((m) => m.taxon_measurement_id === taxonMeasurementId)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [measurements]); //Sometimes will not display the correct fields without this useEffect but could have side effects, may need to revisit. const handleMeasurementTypeChange = (event: SelectChangeEvent) => { handleChange(event); setFieldValue(valueName, ''); setFieldValue(optionName, ''); const m = measurements?.find((m) => m.taxon_measurement_id === event.target.value); - setMeasurement(m); + setCurrentMeasurement(m); + handleMeasurementName('', m?.measurement_name ?? ''); }; const validateValue = async (val: '' | number) => { - const min = measurement?.min_value ?? 0; - const max = measurement?.max_value; - const unit = measurement?.unit ? ` ${measurement.unit}'s` : ``; + const min = currentMeasurement?.min_value ?? 0; + const max = currentMeasurement?.max_value; + const unit = currentMeasurement?.unit ? currentMeasurement.unit : ``; if (val === '') { return; } + if (isNaN(val)) { + return `Must be a number`; + } if (val < min) { return `Measurement must be greater than ${min}${unit}`; } @@ -132,26 +74,33 @@ const MeasurementFormContent = ({ index, measurements }: MeasurementFormContentP } }; + const handleMeasurementName = (_value: string, label: string) => { + setFieldValue(getAnimalFieldName('measurements', 'measurement_name', index), label); + }; + + const handleQualOptionName = (_value: string, label: string) => { + setFieldValue(getAnimalFieldName('measurements', 'option_label', index), label); + }; + return ( - - + + {measurements?.map((m) => ( - {m.measurement_name} + {startCase(m.measurement_name)} ))} - + {!isQuantMeasurement && taxonMeasurementId ? ( ) : ( )} - + (NAME, 'measured_timestamp', index)} + name={getAnimalFieldName(name, 'measured_timestamp', index)} required={isRequiredInSchema(AnimalMeasurementSchema, 'measured_timestamp')} - label={'Measured Date'} - other={{ size: 'small' }} + label="Date Measurement Taken" /> - - (NAME, 'measurement_comment', index)} - handleBlur={handleBlur} - /> - + (name, 'measurement_comment', index)} + /> - + ); }; -export default MeasurementAnimalForm; +export default MeasurementAnimalFormContent; 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 index 1eb7dfe673..b46aad54fc 100644 --- a/app/src/features/surveys/view/survey-animals/form-sections/MortalityAnimalForm.tsx +++ b/app/src/features/surveys/view/survey-animals/form-sections/MortalityAnimalForm.tsx @@ -1,220 +1,154 @@ -import { Grid } from '@mui/material'; +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 CbSelectField from 'components/fields/CbSelectField'; import CustomTextField from 'components/fields/CustomTextField'; import SingleDateField from 'components/fields/SingleDateField'; -import { SurveyAnimalsI18N } from 'constants/i18n'; -import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; -import React, { Fragment, useState } from 'react'; -import { v4 } from 'uuid'; +import { useFormikContext } from 'formik'; +import { useState } from 'react'; import { AnimalMortalitySchema, getAnimalFieldName, IAnimal, IAnimalMortality, isRequiredInSchema } from '../animal'; -import TextInputToggle from '../TextInputToggle'; -import FormSectionWrapper from './FormSectionWrapper'; import LocationEntryForm from './LocationEntryForm'; -/** - * Renders the Mortality section for the Individual Animal form - * - * Note A: Using the name properties must stay in sync with - * values object and nested arrays. - * ie: values = { mortality: [{id: 'test'}] }; name = 'mortality.[0].id'; - * - * Note B: FormSectionWrapper uses a Grid container to render children elements. - * Children of FormSectionWrapper can use Grid items to organize inputs. - * - * Note C: Mortality gets set like an array here, though it should only ever contain one value. - * This might seem odd, but this is in line with how critterbase stores these values. - * To encourage the max of one rule, we use the maxSections prop here to prevent additional copies of the form - * from rendering. - * - * @return {*} - */ - -type ProjectionMode = 'wgs' | 'utm'; -const MortalityAnimalForm = () => { - const { values } = useFormikContext(); - - const name: keyof IAnimal = 'mortality'; - const newMortality: IAnimalMortality = { - _id: v4(), - - mortality_longitude: '' as unknown as number, - mortality_latitude: '' as unknown as number, - mortality_utm_northing: '' as unknown as number, - mortality_utm_easting: '' as unknown as number, - mortality_timestamp: '' as unknown as Date, - mortality_coordinate_uncertainty: 10, - mortality_comment: '', - proximate_cause_of_death_id: '', - proximate_cause_of_death_confidence: '', - proximate_predated_by_taxon_id: '', - ultimate_cause_of_death_id: '', - ultimate_cause_of_death_confidence: '', - ultimate_predated_by_taxon_id: '', - projection_mode: 'wgs' as ProjectionMode, - mortality_id: undefined, - location_id: undefined - }; - - return ( - - {({ remove, push }: FieldArrayRenderProps) => ( - <> - push(newMortality)} - handleRemoveSection={remove}> - {values.mortality.map((mort, index) => ( - - ))} - - - )} - - ); -}; - interface MortalityAnimalFormContentProps { - name: keyof IAnimal; index: number; - value: IAnimalMortality; } -const MortalityAnimalFormContent = ({ name, index, value }: MortalityAnimalFormContentProps) => { - const { handleBlur } = useFormikContext(); +export const MortalityAnimalFormContent = ({ index }: MortalityAnimalFormContentProps) => { + const name: keyof IAnimal = 'mortality'; + const { values } = useFormikContext(); const [pcodTaxonDisabled, setPcodTaxonDisabled] = useState(true); //Controls whether you can select taxons from the PCOD Taxon dropdown. const [ucodTaxonDisabled, setUcodTaxonDisabled] = useState(true); //Controls whether you can select taxons from the UCOD Taxon dropdown. - const [showMortalityComment, setShowMortalityComment] = useState(false); - const renderFields = (): JSX.Element => { - return ( - - - (name, 'mortality_timestamp', index)} - required={isRequiredInSchema(AnimalMortalitySchema, 'mortality_timestamp')} - label={'Mortality Date'} - other={{ size: 'small' }} - /> - - - (name, 'proximate_cause_of_death_id', index)} - handleChangeSideEffect={(_value, label) => setPcodTaxonDisabled(!label.includes('Predation'))} - label={'PCOD Reason'} - controlProps={{ - size: 'small', - required: isRequiredInSchema(AnimalMortalitySchema, 'proximate_cause_of_death_id') - }} - id={`${index}-pcod-reason`} - route={'lookups/cods'} - /> - - - (name, 'proximate_cause_of_death_confidence', index)} - label={'PCOD Confidence'} - controlProps={{ - size: 'small', - required: isRequiredInSchema(AnimalMortalitySchema, 'proximate_cause_of_death_confidence') - }} - id={`${index}-pcod-confidence`} - route={'lookups/cause-of-death-confidence'} - /> - - - (name, 'proximate_predated_by_taxon_id', index)} - label={'PCOD Taxon'} - controlProps={{ - size: 'small', - disabled: pcodTaxonDisabled, - required: isRequiredInSchema(AnimalMortalitySchema, 'proximate_predated_by_taxon_id') - }} - id={`${index}-pcod-taxon`} - route={'lookups/taxons'} - /> - - - (name, 'ultimate_cause_of_death_id', index)} - handleChangeSideEffect={(_value, label) => { - setUcodTaxonDisabled(!label.includes('Predation')); - }} - label={'UCOD Reason'} - controlProps={{ - size: 'small', - required: isRequiredInSchema(AnimalMortalitySchema, 'ultimate_cause_of_death_id') - }} - id={`${index}-ucod-reason`} - route={'lookups/cods'} - /> - - - (name, 'ultimate_cause_of_death_confidence', index)} - label={'UCOD Confidence'} - controlProps={{ - size: 'small', - required: isRequiredInSchema(AnimalMortalitySchema, 'ultimate_cause_of_death_confidence') - }} - id={`${index}-ucod-confidence`} - route={'lookups/cause-of-death-confidence'} - /> - - - (name, 'ultimate_predated_by_taxon_id', index)} - label={'UCOD Taxon'} - controlProps={{ - size: 'small', - disabled: ucodTaxonDisabled, - required: isRequiredInSchema(AnimalMortalitySchema, 'ultimate_predated_by_taxon_id') - }} - id={`${index}-ucod-taxon`} - route={'lookups/taxons'} - /> - - - setShowMortalityComment((c) => !c), toggleState: showMortalityComment }}> - (name, 'mortality_comment', index)} - handleBlur={handleBlur} - /> - - - - ); - }; + const value = values.mortality[index]; return ( - <> + + + Date of Event + (name, 'mortality_timestamp', index)} + required={isRequiredInSchema(AnimalMortalitySchema, 'mortality_timestamp')} + label={'Mortality Date'} + aria-label="Mortality Date" + /> + + - + + + Proximate Cause of Death + + + (name, 'proximate_cause_of_death_id', index)} + handleChangeSideEffect={(_value, label) => setPcodTaxonDisabled(!label.includes('Predation'))} + orderBy={'asc'} + label={'Reason'} + controlProps={{ + required: isRequiredInSchema(AnimalMortalitySchema, 'proximate_cause_of_death_id') + }} + id={`${index}-pcod-reason`} + route={'lookups/cods'} + /> + + + (name, 'proximate_cause_of_death_confidence', index)} + label={'Confidence'} + controlProps={{ + required: isRequiredInSchema(AnimalMortalitySchema, 'proximate_cause_of_death_confidence') + }} + id={`${index}-pcod-confidence`} + route={'lookups/cause-of-death-confidence'} + /> + + + (name, 'proximate_predated_by_taxon_id', index)} + label={'Taxon'} + controlProps={{ + disabled: pcodTaxonDisabled, + required: isRequiredInSchema(AnimalMortalitySchema, 'proximate_predated_by_taxon_id') + }} + id={`${index}-pcod-taxon`} + route={'lookups/taxons'} + /> + + + + + + Ultimate Cause of Death + + + (name, 'ultimate_cause_of_death_id', index)} + orderBy={'asc'} + handleChangeSideEffect={(_value, label) => { + setUcodTaxonDisabled(!label.includes('Predation')); + }} + label={'Reason'} + controlProps={{ + required: isRequiredInSchema(AnimalMortalitySchema, 'ultimate_cause_of_death_id') + }} + id={`${index}-ucod-reason`} + route={'lookups/cods'} + /> + + + (name, 'ultimate_cause_of_death_confidence', index)} + label={'Confidence'} + controlProps={{ + required: isRequiredInSchema(AnimalMortalitySchema, 'ultimate_cause_of_death_confidence') + }} + id={`${index}-ucod-confidence`} + route={'lookups/cause-of-death-confidence'} + /> + + + (name, 'ultimate_predated_by_taxon_id', index)} + label={'Taxon'} + controlProps={{ + disabled: ucodTaxonDisabled, + required: isRequiredInSchema(AnimalMortalitySchema, 'ultimate_predated_by_taxon_id') + }} + id={`${index}-ucod-taxon`} + route={'lookups/taxons'} + /> + + + + + + Additional Details + (name, 'mortality_comment', index)} + /> + + ); }; -export default MortalityAnimalForm; +export default MortalityAnimalFormContent; 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 new file mode 100644 index 0000000000..472d1d529c --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/telemetry-device/DeploymentForm.tsx @@ -0,0 +1,143 @@ +import { mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import { IconButton } from '@mui/material'; +import Grid from '@mui/material/Grid'; +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 bhApi = useBiohubApi(); + const { cid: 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 bhApi.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/DeploymentFormSection.tsx b/app/src/features/surveys/view/survey-animals/telemetry-device/DeploymentFormSection.tsx new file mode 100644 index 0000000000..d81a15063a --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/telemetry-device/DeploymentFormSection.tsx @@ -0,0 +1,149 @@ +import { mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import { IconButton } from '@mui/material'; +import Grid from '@mui/material/Grid'; +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, IAnimal } from '../animal'; +import { IDeploymentTimespan } from './device'; + +interface DeploymentFormSectionProps { + index: number; + mode: ANIMAL_FORM_MODE; + deviceDetails?: IGetDeviceDetailsResponse; +} + +export const DeploymentFormSection = (props: DeploymentFormSectionProps): JSX.Element => { + const { index, mode, deviceDetails } = props; + const animalKeyName: keyof IAnimal = 'device'; + + const bhApi = useBiohubApi(); + const { cid: 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[animalKeyName]?.[index]; + const deployments = device.deployments; + + const handleRemoveDeployment = async (deployment_id: string) => { + try { + if (survey_critter_id === undefined) { + setMessageSnackbar('No critter set!', dialogContext); + } + await bhApi.survey.removeDeployment(projectId, surveyId, Number(survey_critter_id), deployment_id); + const deployments = values.device[index].deployments; + 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 (index === undefined) { + return; + } + 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(`device.${index}.deployments.${i}.attachment_start`) }} + validate={() => deploymentOverlapTest(deploy)} + /> + + + validateField(`device.${index}.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 new file mode 100644 index 0000000000..23a70e2791 --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryDeviceForm.tsx @@ -0,0 +1,167 @@ +import { Box, Grid, Typography } from '@mui/material'; +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, validateField } = useFormikContext(); + + const device = values; + + const { data: deviceDetails, refresh } = useDataLoader(() => + telemetryApi.devices.getDeviceDetails(Number(device.device_id)) + ); + + const validateDeviceMake = async (value: number | '') => { + const deviceMake = deviceDetails?.device?.device_make; + if (device.device_id && deviceMake && deviceMake !== value && mode === ANIMAL_FORM_MODE.ADD) { + return `The current make for this device is ${deviceMake}`; + } + }; + + useEffect(() => { + if (!device.device_id || !device.device_make) { + return; + } + refresh(); + validateField(`device_make`); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [device.device_id, device.device_make, deviceDetails?.device?.device_make]); + + if (!device) { + return <>; + } + + return ( + <> + + + Device Metadata + + + + + + + + + + + + { + const codeVals = await telemetryApi.devices.getCodeValues('frequency_unit'); + return codeVals.map((a) => a.description); + }} + /> + + + + + + + + + + + + {((device.device_make === 'Vectronic' && !deviceDetails?.keyXStatus) || device.device_make === 'Lotek') && ( + + + 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/TelemetryDeviceFormContent.tsx b/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryDeviceFormContent.tsx new file mode 100644 index 0000000000..a7cfa5c48e --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryDeviceFormContent.tsx @@ -0,0 +1,176 @@ +import { Box, Grid, Typography } from '@mui/material'; +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, IAnimal } from '../animal'; +import { DeploymentFormSection } from './DeploymentFormSection'; +import TelemetryFileUpload from './TelemetryFileUpload'; + +interface TelemetryDeviceFormContentProps { + index: number; + mode: ANIMAL_FORM_MODE; +} +const TelemetryDeviceFormContent = (props: TelemetryDeviceFormContentProps) => { + const { index, mode } = props; + + const telemetryApi = useTelemetryApi(); + const { values, validateField } = useFormikContext(); + let device: any; + if (values.device?.[index]) { + device = values.device?.[index]; + } else { + device = { + survey_critter_id: '', + deployments: [], + device_id: '', + device_make: '', + device_model: '', + frequency: '', + frequency_unit: '' + }; + } + + const { data: deviceDetails, refresh } = useDataLoader(() => telemetryApi.devices.getDeviceDetails(device.device_id)); + + const validateDeviceMake = async (value: number | '') => { + const deviceMake = deviceDetails?.device?.device_make; + if (device.device_id && deviceMake && deviceMake !== value && mode === ANIMAL_FORM_MODE.ADD) { + return `The current make for this device is ${deviceMake}`; + } + }; + + useEffect(() => { + if (!device.device_id || !device.device_make) { + return; + } + refresh(); + validateField(`device.${index}.device_make`); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [device.device_id, device.device_make, deviceDetails?.device?.device_make, index]); + + if (!device) { + return <>; + } + + return ( + <> + + + Device Metadata + + + + + + + + + + + + { + const codeVals = await telemetryApi.devices.getCodeValues('frequency_unit'); + return codeVals.map((a) => a.description); + }} + /> + + + + + + + + + + + + {((device.device_make === 'Vectronic' && !deviceDetails?.keyXStatus) || device.device_make === 'Lotek') && ( + + + Upload Attachment + + {device.device_make === 'Vectronic' && ( + <> + {`Vectronic KeyX File (Optional)`} + + + )} + {device.device_make === 'Lotek' && ( + <> + {`Lotek Config File (Optional)`} + + + )} + + )} + + + Deployments + + + + + + ); +}; + +export default TelemetryDeviceFormContent; diff --git a/app/src/features/surveys/view/survey-animals/TelemetryFileUpload.test.tsx b/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryFileUpload.test.tsx similarity index 94% rename from app/src/features/surveys/view/survey-animals/TelemetryFileUpload.test.tsx rename to app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryFileUpload.test.tsx index 067d7f4c01..a0203bc5b5 100644 --- a/app/src/features/surveys/view/survey-animals/TelemetryFileUpload.test.tsx +++ b/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryFileUpload.test.tsx @@ -7,7 +7,7 @@ describe('TelemetryFileUpload component', () => { it('should render with correct props', async () => { const { getByTestId } = render( - + ); diff --git a/app/src/features/surveys/view/survey-animals/TelemetryFileUpload.tsx b/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryFileUpload.tsx similarity index 74% rename from app/src/features/surveys/view/survey-animals/TelemetryFileUpload.tsx rename to app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryFileUpload.tsx index bf6103aea3..845bf0562c 100644 --- a/app/src/features/surveys/view/survey-animals/TelemetryFileUpload.tsx +++ b/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryFileUpload.tsx @@ -3,18 +3,20 @@ import { IFileHandler, UploadFileStatus } from 'components/file-upload/FileUploa import { AttachmentType, ProjectSurveyAttachmentValidExtensions } from 'constants/attachments'; import { useFormikContext } from 'formik'; import React from 'react'; -import { IAnimalTelemetryDeviceFile } from './TelemetryDeviceForm'; +import { IAnimalTelemetryDeviceFile } from './device'; -export const TelemetryFileUpload: React.FC<{ attachmentType: AttachmentType; index: number }> = (props) => { +export const TelemetryFileUpload: React.FC<{ attachmentType: AttachmentType; fileKey: string; typeKey: string }> = ( + props +) => { const { setFieldValue } = useFormikContext<{ formValues: IAnimalTelemetryDeviceFile[] }>(); const fileHandler: IFileHandler = (file) => { - setFieldValue(`${props.index}.attachmentFile`, file); - setFieldValue(`${props.index}.attachmentType`, props.attachmentType); + setFieldValue(props.fileKey, file); + setFieldValue(props.typeKey, props.attachmentType); }; const replaceHandler: IReplaceHandler = () => { - setFieldValue(`${props.index}.attachmentFile`, null); - setFieldValue(`${props.index}.attachmentType`, props.attachmentType); + setFieldValue(props.fileKey, null); + setFieldValue(props.typeKey, props.attachmentType); }; return ( diff --git a/app/src/features/surveys/view/survey-animals/TelemetryMap.tsx b/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryMap.tsx similarity index 77% rename from app/src/features/surveys/view/survey-animals/TelemetryMap.tsx rename to app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryMap.tsx index 6d6b78b8dd..96b1fd595b 100644 --- a/app/src/features/surveys/view/survey-animals/TelemetryMap.tsx +++ b/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryMap.tsx @@ -1,10 +1,14 @@ import { Box, Paper, Typography } from '@mui/material'; -import MapContainer from 'components/map/MapContainer'; +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 { Feature } from 'geojson'; import L, { LatLng } from 'leaflet'; import moment from 'moment'; import { useMemo, useState } from 'react'; -import { GeoJSON } from 'react-leaflet'; +import { GeoJSON, LayersControl, MapContainer as LeafletMapContainer } from 'react-leaflet'; import { uuidToColor } from 'utils/Utils'; import { v4 } from 'uuid'; import { IAnimalDeployment, ITelemetryPointCollection } from './device'; @@ -115,21 +119,30 @@ const TelemetryMap = ({ deploymentData, telemetryData }: ITelemetryMapProps): JS }; return ( - ( - - )), - 0} colourMap={legendColours} /> - ]} - /> + zoom={MAP_DEFAULT_ZOOM} + center={MAP_DEFAULT_CENTER}> + + + ( + + )), + 0} colourMap={legendColours} /> + ]} + /> + + + + ); }; diff --git a/app/src/features/surveys/view/survey-animals/device.ts b/app/src/features/surveys/view/survey-animals/telemetry-device/device.ts similarity index 79% rename from app/src/features/surveys/view/survey-animals/device.ts rename to app/src/features/surveys/view/survey-animals/telemetry-device/device.ts index 27ac4c9fda..00fd395456 100644 --- a/app/src/features/surveys/view/survey-animals/device.ts +++ b/app/src/features/surveys/view/survey-animals/telemetry-device/device.ts @@ -1,3 +1,4 @@ +import { AttachmentType } from 'constants/attachments'; import { FeatureCollection } from 'geojson'; import yup from 'utils/YupSchema'; import { InferType } from 'yup'; @@ -27,9 +28,9 @@ export const AnimalDeploymentTimespanSchema = yup.object({}).shape({ export const AnimalTelemetryDeviceSchema = yup.object({}).shape({ device_id: intSchema, device_make: yup.string().required(req), - frequency: numSchema, + frequency: numSchema.nullable(), frequency_unit: yup.string().nullable(), - device_model: yup.string(), + device_model: yup.string().nullable(), deployments: yup.array(AnimalDeploymentTimespanSchema) }); @@ -47,19 +48,24 @@ export const AnimalDeploymentSchema = yup.object({}).shape({ frequency_unit: yup.string() }); +export interface IAnimalTelemetryDeviceFile extends IAnimalTelemetryDevice { + attachmentFile?: File; + attachmentType?: AttachmentType; +} + export class Device implements Omit { device_id: number; device_make: string; - device_model: string; - frequency: number; - frequency_unit: 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 = String(obj.device_model); - this.frequency = Number(obj.frequency); - this.frequency_unit = String(obj.frequency_unit); + 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-observations/SurveyObservations.test.tsx b/app/src/features/surveys/view/survey-observations/SurveyObservations.test.tsx index 665aeb482d..51e8b0aeb7 100644 --- a/app/src/features/surveys/view/survey-observations/SurveyObservations.test.tsx +++ b/app/src/features/surveys/view/survey-observations/SurveyObservations.test.tsx @@ -1,7 +1,9 @@ +import { AuthStateContext } from 'contexts/authStateContext'; import { ISurveyContext, SurveyContext } from 'contexts/surveyContext'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { DataLoader } from 'hooks/useDataLoader'; import { MemoryRouter } from 'react-router'; +import { getMockAuthState, SystemAdminAuthState } from 'test-helpers/auth-helpers'; import { getObservationSubmissionResponse } from 'test-helpers/survey-helpers'; import { cleanup, render, waitFor } from 'test-helpers/test-utils'; import SurveyObservations from './SurveyObservations'; @@ -39,16 +41,22 @@ describe('SurveyObservations', () => { surveyDataLoader: {} as unknown as DataLoader, summaryDataLoader: {} as unknown as DataLoader, sampleSiteDataLoader: {} as unknown as DataLoader, + critterDataLoader: {} as unknown as DataLoader, + deploymentDataLoader: {} as unknown as DataLoader, surveyId: 1, projectId: 1 }; + const authState = getMockAuthState({ base: SystemAdminAuthState }); + const { getByText } = render( - - - - - + + + + + + + ); await waitFor(() => { diff --git a/app/src/features/surveys/view/survey-observations/SurveyObservations.tsx b/app/src/features/surveys/view/survey-observations/SurveyObservations.tsx index a7a68d1de8..46d2d0a67c 100644 --- a/app/src/features/surveys/view/survey-observations/SurveyObservations.tsx +++ b/app/src/features/surveys/view/survey-observations/SurveyObservations.tsx @@ -2,16 +2,12 @@ import Box from '@mui/material/Box'; 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 FileUpload from 'components/file-upload/FileUpload'; -import { IUploadHandler } from 'components/file-upload/FileUploadItem'; import { DialogContext } from 'contexts/dialogContext'; import { SurveyContext } from 'contexts/surveyContext'; import NoSurveySectionData from 'features/surveys/components/NoSurveySectionData'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useInterval } from 'hooks/useInterval'; -import { IUploadObservationSubmissionResponse } from 'interfaces/useObservationApi.interface'; -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useEffect } from 'react'; import LoadingObservationsCard from './components/LoadingObservationsCard'; import ObservationFileCard from './components/ObservationFileCard'; import ObservationMessagesCard from './components/ObservationMessagesCard'; @@ -22,8 +18,6 @@ const SurveyObservations: React.FC = () => { const dialogContext = useContext(DialogContext); const surveyContext = useContext(SurveyContext); - const [openImportObservations, setOpenImportObservations] = useState(false); - const projectId = surveyContext.projectId as number; const surveyId = surveyContext.surveyId as number; @@ -52,29 +46,6 @@ const SurveyObservations: React.FC = () => { } }, [occurrenceSubmission, submissionPollingInterval]); - const importObservations = (): IUploadHandler => { - return async (file, cancelToken, handleFileUploadProgress) => { - return biohubApi.observation - .uploadObservationSubmission(projectId, surveyId, file, cancelToken, handleFileUploadProgress) - .then((result: IUploadObservationSubmissionResponse) => { - if (file.type === 'application/x-zip-compressed' || file.type === 'application/zip') { - // Process a DwCA zip file - return biohubApi.observation.processDWCFile(projectId, result.submissionId); - } - - // Process an Observation Template file - return biohubApi.observation.processOccurrences(projectId, result.submissionId, surveyId); - }) - .finally(() => { - surveyContext.observationDataLoader.refresh(projectId, surveyId); - }); - }; - }; - - function handleCloseImportObservations() { - setOpenImportObservations(false); - } - function handleDelete() { if (!occurrenceSubmission) { return; @@ -89,7 +60,7 @@ const SurveyObservations: React.FC = () => { noButtonLabel: 'Cancel', open: true, onYes: async () => { - await biohubApi.observation.deleteObservationSubmission( + await biohubApi.dwca.deleteObservationSubmission( projectId, surveyId, occurrenceSubmission.occurrence_submission_id @@ -107,7 +78,7 @@ const SurveyObservations: React.FC = () => { return; } - biohubApi.observation + biohubApi.dwca .getObservationSubmissionSignedURL(projectId, surveyId, occurrenceSubmission.occurrence_submission_id) .then((objectUrl: string) => { window.open(objectUrl); @@ -119,16 +90,6 @@ const SurveyObservations: React.FC = () => { return ( <> - - - - Observations diff --git a/app/src/features/surveys/view/survey-observations/components/ObservationFileCard.tsx b/app/src/features/surveys/view/survey-observations/components/ObservationFileCard.tsx index 0a1d28bad0..a3074bdf15 100644 --- a/app/src/features/surveys/view/survey-observations/components/ObservationFileCard.tsx +++ b/app/src/features/surveys/view/survey-observations/components/ObservationFileCard.tsx @@ -24,7 +24,7 @@ import { ProjectRoleGuard, SystemRoleGuard } from 'components/security/Guards'; import { PublishStatus } from 'constants/attachments'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; import { SurveyContext } from 'contexts/surveyContext'; -import { IGetObservationSubmissionResponse } from 'interfaces/useObservationApi.interface'; +import { IGetObservationSubmissionResponse } from 'interfaces/useDwcaApi.interface'; import React, { useState } from 'react'; //TODO: PRODUCTION_BANDAGE: Remove from `SubmitStatusChip` and `Remove or Resubmit` button diff --git a/app/src/features/surveys/view/survey-observations/components/ObservationMessagesCard.tsx b/app/src/features/surveys/view/survey-observations/components/ObservationMessagesCard.tsx index 1277d28b29..a81b1a32fc 100644 --- a/app/src/features/surveys/view/survey-observations/components/ObservationMessagesCard.tsx +++ b/app/src/features/surveys/view/survey-observations/components/ObservationMessagesCard.tsx @@ -7,7 +7,7 @@ import Typography from '@mui/material/Typography'; import { IGetObservationSubmissionResponse, IGetObservationSubmissionResponseMessages -} from 'interfaces/useObservationApi.interface'; +} from 'interfaces/useDwcaApi.interface'; export interface IObservationMessagesCardProps { observationRecord: IGetObservationSubmissionResponse; diff --git a/app/src/features/surveys/view/survey-observations/components/ValidatingObservationsCard.tsx b/app/src/features/surveys/view/survey-observations/components/ValidatingObservationsCard.tsx index 5512c13b7a..522ceed5a6 100644 --- a/app/src/features/surveys/view/survey-observations/components/ValidatingObservationsCard.tsx +++ b/app/src/features/surveys/view/survey-observations/components/ValidatingObservationsCard.tsx @@ -7,7 +7,7 @@ import Paper from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; import { makeStyles } from '@mui/styles'; import clsx from 'clsx'; -import { IGetObservationSubmissionResponse } from 'interfaces/useObservationApi.interface'; +import { IGetObservationSubmissionResponse } from 'interfaces/useDwcaApi.interface'; import BorderLinearProgress from './BorderLinearProgress'; const useStyles = makeStyles((theme: Theme) => ({ diff --git a/app/src/hooks/api/useAxios.test.tsx b/app/src/hooks/api/useAxios.test.tsx index 1ec8b9f151..c2b3efc377 100644 --- a/app/src/hooks/api/useAxios.test.tsx +++ b/app/src/hooks/api/useAxios.test.tsx @@ -1,9 +1,10 @@ -import { ReactKeycloakProvider } from '@react-keycloak/web'; -import { PropsWithChildren } from '@react-leaflet/core/types/component'; +import { act, cleanup } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; import axios, { AxiosError } from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import Keycloak, { KeycloakPromise } from 'keycloak-js'; +import { User } from 'oidc-client-ts'; +import { PropsWithChildren } from 'react'; +import { AuthContextProps, AuthProvider, AuthProviderProps, useAuth } from 'react-oidc-context'; import useAxios, { APIError } from './useAxios'; describe('APIError', () => { @@ -30,36 +31,42 @@ describe('APIError', () => { }); }); +jest.mock('react-oidc-context'); + describe('useAxios', () => { /// Mock `axios` instance let axiosMock: MockAdapter; - // Stub `Keycloak.updateToken` function to return `true` - const updateTokenStub = jest.fn(); - - // Mock `Keycloak` instance - const keycloakMock: Keycloak = { - authenticated: true, - token: 'a token', - init: () => Promise.resolve(true) as KeycloakPromise, - createLoginUrl: () => '', - createLogoutUrl: () => 'string', - createRegisterUrl: () => 'string', - createAccountUrl: () => 'string', - isTokenExpired: () => false, - updateToken: updateTokenStub, - clearToken: () => null, - hasRealmRole: () => true, - hasResourceRole: () => true, - loadUserInfo: () => {} - } as unknown as Keycloak; + const authConfig: AuthProviderProps = { + authority: 'authority', + client_id: 'client', + redirect_uri: 'redirect' + }; + + const mockAuthProvider = AuthProvider as jest.Mock; + const mockUseAuth = useAuth as jest.Mock>; + + const mockSigninSilent = jest.fn< + ReturnType, + Parameters + >(); beforeEach(() => { axiosMock = new MockAdapter(axios); + + // Assign the real implementation of `AuthProvider` + const { AuthProvider } = jest.requireActual('react-oidc-context'); + mockAuthProvider.mockImplementation(AuthProvider); + + // Assign a mock implementation of `useAuth` + mockUseAuth.mockImplementation(() => ({ + signinSilent: mockSigninSilent + })); }); afterEach(() => { axiosMock.restore(); + cleanup(); }); it('should make an http get request and return the response', async () => { @@ -69,20 +76,20 @@ describe('useAxios', () => { // Render the `useAxios` hook with necessary keycloak parent components const { result } = renderHook(() => useAxios(baseUrl), { - wrapper: ({ children }: PropsWithChildren) => ( - {children} - ) + wrapper: ({ children }: PropsWithChildren) => {children} }); - const response = await result.current.get('/some/url'); + await act(async () => { + const response = await result.current.get('/some/url'); - expect(response.status).toEqual(200); - expect(response.data).toEqual({ value: 'test value' }); + expect(response.status).toEqual(200); + expect(response.data).toEqual({ value: 'test value' }); + }); }); it('should retry once if the call fails with a 403', async () => { - // Simulate `updateToken` call success - updateTokenStub.mockResolvedValue(true); + // Simulate `signinSilent` call success + mockSigninSilent.mockResolvedValue({} as unknown as User); axiosMock.onAny().replyOnce(403).onAny().replyOnce(200, { value: 'test value' }); @@ -90,25 +97,24 @@ describe('useAxios', () => { // Render the `useAxios` hook with necessary keycloak parent components const { result } = renderHook(() => useAxios(baseUrl), { - wrapper: ({ children }: PropsWithChildren) => ( - {children} - ) + wrapper: ({ children }: PropsWithChildren) => {children} }); - const response = await result.current.get('/some/url'); + await act(async () => { + const response = await result.current.get('/some/url'); - expect(updateTokenStub).toHaveBeenCalledTimes(1); - expect(updateTokenStub).toHaveBeenCalledWith(86400); + expect(mockSigninSilent).toHaveBeenCalledTimes(1); - expect(axiosMock.history['get'].length).toEqual(2); + expect(axiosMock.history['get'].length).toEqual(2); - expect(response.status).toEqual(200); - expect(response.data).toEqual({ value: 'test value' }); + expect(response.status).toEqual(200); + expect(response.data).toEqual({ value: 'test value' }); + }); }); it('should retry once if the call fails with a 401', async () => { - // Simulate `updateToken` call success - updateTokenStub.mockResolvedValue(true); + // Simulate `signinSilent` call success + mockSigninSilent.mockResolvedValue({} as unknown as User); // Return 401 once axiosMock.onAny().replyOnce(401).onAny().replyOnce(200, { value: 'test value' }); @@ -117,25 +123,24 @@ describe('useAxios', () => { // Render the `useAxios` hook with necessary keycloak parent components const { result } = renderHook(() => useAxios(baseUrl), { - wrapper: ({ children }: PropsWithChildren) => ( - {children} - ) + wrapper: ({ children }: PropsWithChildren) => {children} }); - const response = await result.current.get('/some/url'); + await act(async () => { + const response = await result.current.get('/some/url'); - expect(updateTokenStub).toHaveBeenCalledTimes(1); - expect(updateTokenStub).toHaveBeenCalledWith(86400); + expect(mockSigninSilent).toHaveBeenCalledTimes(1); - expect(axiosMock.history['get'].length).toEqual(2); + expect(axiosMock.history['get'].length).toEqual(2); - expect(response.status).toEqual(200); - expect(response.data).toEqual({ value: 'test value' }); + expect(response.status).toEqual(200); + expect(response.data).toEqual({ value: 'test value' }); + }); }); it('should retry once and fail if the call continues to return 403', async () => { - // Simulate `updateToken` call success - updateTokenStub.mockResolvedValue(true); + // Simulate `signinSilent` call success + mockSigninSilent.mockResolvedValue({} as unknown as User); // Return 401 always axiosMock.onAny().reply(403); @@ -144,26 +149,25 @@ describe('useAxios', () => { // Render the `useAxios` hook with necessary keycloak parent components const { result } = renderHook(() => useAxios(baseUrl), { - wrapper: ({ children }: PropsWithChildren) => ( - {children} - ) + wrapper: ({ children }: PropsWithChildren) => {children} }); - try { - await result.current.get('/some/url'); - } catch (actualError) { - expect((actualError as APIError).status).toEqual(403); + await act(async () => { + try { + await result.current.get('/some/url'); + } catch (actualError) { + expect((actualError as APIError).status).toEqual(403); - expect(axiosMock.history['get'].length).toEqual(2); + expect(axiosMock.history['get'].length).toEqual(2); - expect(updateTokenStub).toHaveBeenCalledTimes(1); - expect(updateTokenStub).toHaveBeenCalledWith(86400); - } + expect(mockSigninSilent).toHaveBeenCalledTimes(1); + } + }); }); it('should retry once and fail if the call continues to return 401', async () => { - // Simulate `updateToken` call success - updateTokenStub.mockResolvedValue(true); + // Simulate `signinSilent` call success + mockSigninSilent.mockResolvedValue({} as unknown as User); // Return 401 always axiosMock.onAny().reply(401); @@ -172,26 +176,25 @@ describe('useAxios', () => { // Render the `useAxios` hook with necessary keycloak parent components const { result } = renderHook(() => useAxios(baseUrl), { - wrapper: ({ children }: PropsWithChildren) => ( - {children} - ) + wrapper: ({ children }: PropsWithChildren) => {children} }); - try { - await result.current.get('/some/url'); - } catch (actualError) { - expect((actualError as APIError).status).toEqual(401); + await act(async () => { + try { + await result.current.get('/some/url'); + } catch (actualError) { + expect((actualError as APIError).status).toEqual(401); - expect(axiosMock.history['get'].length).toEqual(2); + expect(axiosMock.history['get'].length).toEqual(2); - expect(updateTokenStub).toHaveBeenCalledTimes(1); - expect(updateTokenStub).toHaveBeenCalledWith(86400); - } + expect(mockSigninSilent).toHaveBeenCalledTimes(1); + } + }); }); it('should retry once and fail if the update token call fails', async () => { - // Simulate `updateToken` call failure - updateTokenStub.mockResolvedValue(false); + // Simulate `signinSilent` call failure + mockSigninSilent.mockResolvedValue(null); // Return 403 once axiosMock.onAny().replyOnce(403).onAny().replyOnce(200, { value: 'test value' }); @@ -200,20 +203,19 @@ describe('useAxios', () => { // Render the `useAxios` hook with necessary keycloak parent components const { result } = renderHook(() => useAxios(baseUrl), { - wrapper: ({ children }: PropsWithChildren) => ( - {children} - ) + wrapper: ({ children }: PropsWithChildren) => {children} }); - try { - await result.current.get('/some/url'); - } catch (actualError) { - expect((actualError as APIError).status).toEqual(403); + await act(async () => { + try { + await result.current.get('/some/url'); + } catch (actualError) { + expect((actualError as APIError).status).toEqual(403); - expect(axiosMock.history['get'].length).toEqual(1); + expect(axiosMock.history['get'].length).toEqual(1); - expect(updateTokenStub).toHaveBeenCalledTimes(1); - expect(updateTokenStub).toHaveBeenCalledWith(86400); - } + expect(mockSigninSilent).toHaveBeenCalledTimes(1); + } + }); }); }); diff --git a/app/src/hooks/api/useAxios.ts b/app/src/hooks/api/useAxios.ts index 85899777b4..5d99abea11 100644 --- a/app/src/hooks/api/useAxios.ts +++ b/app/src/hooks/api/useAxios.ts @@ -1,6 +1,6 @@ -import { useKeycloak } from '@react-keycloak/web'; import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; import { useMemo, useRef } from 'react'; +import { useAuth } from 'react-oidc-context'; import { ensureProtocol } from 'utils/Utils'; export class APIError extends Error { @@ -28,7 +28,7 @@ const AXIOS_AUTH_REFRESH_ATTEMPTS_MAX = Number(process.env.REACT_APP_AXIOS_AUTH_ * @return {*} {AxiosInstance} an instance of axios */ const useAxios = (baseUrl?: string): AxiosInstance => { - const { keycloak } = useKeycloak(); + const auth = useAuth(); // Track how many times its been attempted to refresh the token and re-send the failed request in order to prevent // the possibility of an infinite loop (in the case where the token is unable to ever successfully refresh). @@ -37,7 +37,7 @@ const useAxios = (baseUrl?: string): AxiosInstance => { return useMemo(() => { const instance = axios.create({ headers: { - Authorization: `Bearer ${keycloak.token}` + Authorization: `Bearer ${auth?.user?.access_token}` }, // Note: axios requires that the baseURL include a protocol (http:// or https://) baseURL: baseUrl && ensureProtocol(baseUrl) @@ -64,9 +64,9 @@ const useAxios = (baseUrl?: string): AxiosInstance => { // Attempt to refresh the keycloak token // Note: updateToken called with an arbitrarily large number of seconds to guarantee the update is executed - const isTokenRefreshed = await keycloak.updateToken(86400); + const user = await auth.signinSilent(); - if (!isTokenRefreshed) { + if (!user) { // Token was not refreshed successfully, throw original error throw new APIError(error); } @@ -76,15 +76,14 @@ const useAxios = (baseUrl?: string): AxiosInstance => { ...error.config, headers: { ...error.config.headers, - Authorization: `Bearer ${keycloak.token}` + Authorization: `Bearer ${user?.access_token}` } }); } ); return instance; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [keycloak, keycloak.token]); + }, [auth, baseUrl]); }; export default useAxios; diff --git a/app/src/hooks/api/useObservationApi.test.ts b/app/src/hooks/api/useDwcaApi.test.ts similarity index 64% rename from app/src/hooks/api/useObservationApi.test.ts rename to app/src/hooks/api/useDwcaApi.test.ts index 1da5074860..721ca65294 100644 --- a/app/src/hooks/api/useObservationApi.test.ts +++ b/app/src/hooks/api/useDwcaApi.test.ts @@ -1,8 +1,8 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import useObservationApi from './useObservationApi'; +import useDwcaApi from './useDwcaApi'; -describe('useObservationApi', () => { +describe('useDwcaApi', () => { let mock: any; beforeEach(() => { @@ -17,12 +17,12 @@ describe('useObservationApi', () => { const surveyId = 2; it('getObservationSubmission works as expected', async () => { - mock.onGet(`/api/project/${projectId}/survey/${surveyId}/observation/submission/get`).reply(200, { + mock.onGet(`/api/project/${projectId}/survey/${surveyId}/dwca/observations/submission/get`).reply(200, { surveyObservationData: { occurrence_submission_id: 1, inputFileName: 'file.txt' }, surveyObservationSupplementaryData: null }); - const result = await useObservationApi(axios).getObservationSubmission(projectId, surveyId); + const result = await useDwcaApi(axios).getObservationSubmission(projectId, surveyId); expect(result.surveyObservationData.occurrence_submission_id).toEqual(1); expect(result.surveyObservationData.inputFileName).toEqual('file.txt'); @@ -32,10 +32,10 @@ describe('useObservationApi', () => { const submissionId = 1; mock - .onDelete(`/api/project/${projectId}/survey/${surveyId}/observation/submission/${submissionId}/delete`) + .onDelete(`/api/project/${projectId}/survey/${surveyId}/dwca/observations/submission/${submissionId}/delete`) .reply(200, 1); - const result = await useObservationApi(axios).deleteObservationSubmission(projectId, surveyId, submissionId); + const result = await useDwcaApi(axios).deleteObservationSubmission(projectId, surveyId, submissionId); expect(result).toEqual(1); }); @@ -46,11 +46,11 @@ describe('useObservationApi', () => { }); mock - .onPost(`/api/project/${projectId}/survey/${surveyId}/observation/submission/upload`) + .onPost(`/api/project/${projectId}/survey/${surveyId}/dwca/observations/submission/upload`) .reply(200, { submissionId: 1 }); mock.onPost('/api/dwc/validate').reply(200); - const result = await useObservationApi(axios).uploadObservationSubmission(projectId, surveyId, file); + const result = await useDwcaApi(axios).uploadObservationSubmission(projectId, surveyId, file); expect(result.submissionId).toEqual(1); }); @@ -61,11 +61,11 @@ describe('useObservationApi', () => { }); mock - .onPost(`/api/project/${projectId}/survey/${surveyId}/observation/submission/upload`) + .onPost(`/api/project/${projectId}/survey/${surveyId}/dwca/observations/submission/upload`) .reply(200, { submissionId: 1 }); mock.onPost('/api/xlsx/validate').reply(200); - const result = await useObservationApi(axios).uploadObservationSubmission(projectId, surveyId, file); + const result = await useDwcaApi(axios).uploadObservationSubmission(projectId, surveyId, file); expect(result.submissionId).toEqual(1); }); @@ -75,9 +75,9 @@ describe('useObservationApi', () => { type: 'xlsx' }); - mock.onPost(`/api/project/${projectId}/survey/${surveyId}/observation/submission/upload`).reply(200, {}); + mock.onPost(`/api/project/${projectId}/survey/${surveyId}/dwca/observations/submission/upload`).reply(200, {}); - const result = await useObservationApi(axios).uploadObservationSubmission(projectId, surveyId, file); + const result = await useDwcaApi(axios).uploadObservationSubmission(projectId, surveyId, file); expect(result).toEqual({}); }); @@ -88,7 +88,7 @@ describe('useObservationApi', () => { const surveyId = 3; mock.onPost(`/api/xlsx/transform`).reply(200, true); - const result = await useObservationApi(axios).initiateXLSXSubmissionTransform(projectId, submissionId, surveyId); + const result = await useDwcaApi(axios).initiateXLSXSubmissionTransform(projectId, submissionId, surveyId); expect(result).toEqual(true); }); @@ -110,7 +110,7 @@ describe('useObservationApi', () => { mock.onPost(`/api/dwc/view-occurrences`).reply(200, data); - const result = await useObservationApi(axios).getOccurrencesForView(project_id, observation_submission_id); + const result = await useDwcaApi(axios).getOccurrencesForView(project_id, observation_submission_id); expect(result).toEqual(data); }); @@ -122,7 +122,7 @@ describe('useObservationApi', () => { mock.onPost(`/api/xlsx/process`).reply(200, true); - const result = await useObservationApi(axios).processOccurrences(projectId, submissionId, surveyId); + const result = await useDwcaApi(axios).processOccurrences(projectId, submissionId, surveyId); expect(result).toEqual(true); }); @@ -133,7 +133,7 @@ describe('useObservationApi', () => { mock.onPost(`api/dwc/process`).reply(200, true); - const result = await useObservationApi(axios).processDWCFile(projectId, submissionId); + const result = await useDwcaApi(axios).processDWCFile(projectId, submissionId); expect(result).toEqual(true); }); diff --git a/app/src/hooks/api/useDwcaApi.ts b/app/src/hooks/api/useDwcaApi.ts new file mode 100644 index 0000000000..cc33c37c59 --- /dev/null +++ b/app/src/hooks/api/useDwcaApi.ts @@ -0,0 +1,188 @@ +import { AxiosInstance, CancelTokenSource } from 'axios'; +import { GeoJsonProperties } from 'geojson'; +import { + IGetObservationSubmissionResponse, + ISpatialData, + IUploadObservationSubmissionResponse +} from 'interfaces/useDwcaApi.interface'; + +/** + * Returns a set of supported api methods for working with DarwinCore observations. + * + * @param {AxiosInstance} axios + * @return {*} object whose properties are supported api methods. + */ +const useDwcaApi = (axios: AxiosInstance) => { + /** + * Upload survey observation submission. + * + * @param {number} projectId + * @param {number} surveyId + * @param {File} file + * @param {CancelTokenSource} [cancelTokenSource] + * @param {(progressEvent: ProgressEvent) => void} [onProgress] + * @return {*} {Promise} + */ + const uploadObservationSubmission = async ( + projectId: number, + surveyId: number, + file: File, + cancelTokenSource?: CancelTokenSource, + onProgress?: (progressEvent: ProgressEvent) => void + ): Promise => { + const req_message = new FormData(); + + req_message.append('media', file); + + const { data } = await axios.post( + `/api/project/${projectId}/survey/${surveyId}/dwca/observations/submission/upload`, + req_message, + { + cancelToken: cancelTokenSource?.token, + onUploadProgress: onProgress + } + ); + + return data; + }; + + /** + * Get observation submission based on survey ID + * + * @param {number} projectId + * @param {number} surveyId + * @returns {*} {Promise} + */ + const getObservationSubmission = async ( + projectId: number, + surveyId: number + ): Promise => { + const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/dwca/observations/submission/get`); + + return data; + }; + + /** + * Get occurrence information for view-only purposes based on occurrence submission id + * + * @param {number} occurrenceSubmissionId + * @returns {*} {Promise} + */ + const getOccurrencesForView = async (projectId: number, occurrenceSubmissionId: number): Promise => { + const { data } = await axios.post(`/api/dwc/view-occurrences`, { + occurrence_submission_id: occurrenceSubmissionId, + project_id: projectId + }); + + return data; + }; + + const getSpatialMetadata = async (submissionSpatialComponentIds: number[]): Promise => { + const { data } = await axios.get(`/api/dwc/metadata`, { + params: { submissionSpatialComponentIds: submissionSpatialComponentIds } + }); + + return data; + }; + + /** + * Delete observation submission based on submission ID + * + * @param {number} projectId + * @param {number} surveyId + * @param {number} submissionId + * @returns {*} {Promise} + */ + const deleteObservationSubmission = async ( + projectId: number, + surveyId: number, + submissionId: number + ): Promise => { + const { data } = await axios.delete( + `/api/project/${projectId}/survey/${surveyId}/dwca/observations/submission/${submissionId}/delete` + ); + + return data; + }; + + /** + * Get observation submission S3 url based on survey and submission ID + * + * @param {AxiosInstance} axios + * @returns {*} {Promise} + */ + const getObservationSubmissionSignedURL = async ( + projectId: number, + surveyId: number, + submissionId: number + ): Promise => { + const { data } = await axios.get( + `/api/project/${projectId}/survey/${surveyId}/dwca/observations/submission/${submissionId}/getSignedUrl` + ); + + return data; + }; + + /** + * Initiate the transformation process for the submitted observation template. + * + * @param {number} projectId + * @param {number} submissionId + */ + const initiateXLSXSubmissionTransform = async (projectId: number, submissionId: number, surveyId: number) => { + const { data } = await axios.post(`/api/xlsx/transform`, { + project_id: projectId, + occurrence_submission_id: submissionId, + survey_id: surveyId + }); + + return data; + }; + + /** + * Processes an xlsx submission : validates, transforms and scrapes occurrences + * + * @param {number} projectId + * @param {number} submissionId + * @return {*} + */ + const processOccurrences = async (projectId: number, submissionId: number, surveyId: number) => { + const { data } = await axios.post(`/api/xlsx/process`, { + project_id: projectId, + occurrence_submission_id: submissionId, + survey_id: surveyId + }); + + return data; + }; + + /** + * Validates and processes a submitted Darwin Core File + * + * @param {number} projectId + * @param {number} submissionId + * @return {*} + */ + const processDWCFile = async (projectId: number, submissionId: number) => { + const { data } = await axios.post(`api/dwc/process`, { + project_id: projectId, + occurrence_submission_id: submissionId + }); + + return data; + }; + + return { + uploadObservationSubmission, + getObservationSubmission, + deleteObservationSubmission, + getObservationSubmissionSignedURL, + initiateXLSXSubmissionTransform, + getOccurrencesForView, + processOccurrences, + processDWCFile, + getSpatialMetadata + }; +}; + +export default useDwcaApi; diff --git a/app/src/hooks/api/useObservationApi.ts b/app/src/hooks/api/useObservationApi.ts index 0ce822ccd0..aec547a2ce 100644 --- a/app/src/hooks/api/useObservationApi.ts +++ b/app/src/hooks/api/useObservationApi.ts @@ -1,12 +1,10 @@ import { AxiosInstance, CancelTokenSource } from 'axios'; -import { IObservationTableRow } from 'contexts/observationsContext'; -import { GeoJsonProperties } from 'geojson'; import { - IGetObservationSubmissionResponse, - IGetSurveyObservationsResponse, - ISpatialData, - IUploadObservationSubmissionResponse -} from 'interfaces/useObservationApi.interface'; + IObservationRecord, + IObservationTableRow, + ISupplementaryObservationData +} from 'contexts/observationsTableContext'; +import { IGetSurveyObservationsResponse } from 'interfaces/useObservationApi.interface'; /** * Returns a set of supported api methods for working with observations. @@ -16,215 +14,120 @@ import { */ const useObservationApi = (axios: AxiosInstance) => { /** - * Upload survey observation submission. + * Insert/updates all survey observation records for the given survey * * @param {number} projectId * @param {number} surveyId - * @param {File} file - * @param {CancelTokenSource} [cancelTokenSource] - * @param {(progressEvent: ProgressEvent) => void} [onProgress] - * @return {*} {Promise} + * @param {IObservationTableRow[]} surveyObservations + * @return {*} */ - const uploadObservationSubmission = async ( + const insertUpdateObservationRecords = async ( projectId: number, surveyId: number, - file: File, - cancelTokenSource?: CancelTokenSource, - onProgress?: (progressEvent: ProgressEvent) => void - ): Promise => { - const req_message = new FormData(); - - req_message.append('media', file); - - const { data } = await axios.post( - `/api/project/${projectId}/survey/${surveyId}/observation/submission/upload`, - req_message, - { - cancelToken: cancelTokenSource?.token, - onUploadProgress: onProgress - } + surveyObservations: IObservationTableRow[] + ): Promise => { + const { data } = await axios.put( + `/api/project/${projectId}/survey/${surveyId}/observations`, + { surveyObservations } ); - return data; + return data.surveyObservations; }; /** - * Get observation submission based on survey ID + * Retrieves all survey observation records for the given survey * * @param {number} projectId * @param {number} surveyId - * @returns {*} {Promise} + * @return {*} {Promise} */ - const getObservationSubmission = async ( + const getObservationRecords = async ( projectId: number, surveyId: number - ): Promise => { - const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/observation/submission/get`); - - return data; - }; - - /** - * Get occurrence information for view-only purposes based on occurrence submission id - * - * @param {number} occurrenceSubmissionId - * @returns {*} {Promise} - */ - const getOccurrencesForView = async (projectId: number, occurrenceSubmissionId: number): Promise => { - const { data } = await axios.post(`/api/dwc/view-occurrences`, { - occurrence_submission_id: occurrenceSubmissionId, - project_id: projectId - }); - - return data; - }; - - const getSpatialMetadata = async (submissionSpatialComponentIds: number[]): Promise => { - const { data } = await axios.get(`/api/dwc/metadata`, { - params: { submissionSpatialComponentIds: submissionSpatialComponentIds } - }); + ): Promise => { + const { data } = await axios.get( + `/api/project/${projectId}/survey/${surveyId}/observations` + ); return data; }; /** - * Delete observation submission based on submission ID + * Uploads an observation CSV for import. * * @param {number} projectId * @param {number} surveyId - * @param {number} submissionId - * @returns {*} {Promise} + * @param {File} file + * @param {CancelTokenSource} [cancelTokenSource] + * @param {(progressEvent: ProgressEvent) => void} [onProgress] + * @return {*} {Promise<{ submissionId: number }>} */ - const deleteObservationSubmission = async ( + const uploadCsvForImport = async ( projectId: number, surveyId: number, - submissionId: number - ): Promise => { - const { data } = await axios.delete( - `/api/project/${projectId}/survey/${surveyId}/observation/submission/${submissionId}/delete` - ); + file: File, + cancelTokenSource?: CancelTokenSource, + onProgress?: (progressEvent: ProgressEvent) => void + ): Promise<{ submissionId: number }> => { + const formData = new FormData(); - return data; - }; + formData.append('media', file); - /** - * Get observation submission S3 url based on survey and submission ID - * - * @param {AxiosInstance} axios - * @returns {*} {Promise} - */ - const getObservationSubmissionSignedURL = async ( - projectId: number, - surveyId: number, - submissionId: number - ): Promise => { - const { data } = await axios.get( - `/api/project/${projectId}/survey/${surveyId}/observation/submission/${submissionId}/getSignedUrl` + const { data } = await axios.post<{ submissionId: number }>( + `/api/project/${projectId}/survey/${surveyId}/observations/upload`, + formData, + { + cancelToken: cancelTokenSource?.token, + onUploadProgress: onProgress + } ); return data; }; /** - * Initiate the transformation process for the submitted observation template. - * - * @param {number} projectId - * @param {number} submissionId - */ - const initiateXLSXSubmissionTransform = async (projectId: number, submissionId: number, surveyId: number) => { - const { data } = await axios.post(`/api/xlsx/transform`, { - project_id: projectId, - occurrence_submission_id: submissionId, - survey_id: surveyId - }); - - return data; - }; - - /** - * Processes an xlsx submission : validates, transforms and scrapes occurrences - * - * @param {number} projectId - * @param {number} submissionId - * @return {*} - */ - const processOccurrences = async (projectId: number, submissionId: number, surveyId: number) => { - const { data } = await axios.post(`/api/xlsx/process`, { - project_id: projectId, - occurrence_submission_id: submissionId, - survey_id: surveyId - }); - - return data; - }; - - /** - * Validates and processes a submitted Darwin Core File + * Begins processing an uploaded observation CSV for import * * @param {number} projectId + * @param {number} surveyId * @param {number} submissionId * @return {*} */ - const processDWCFile = async (projectId: number, submissionId: number) => { - const { data } = await axios.post(`api/dwc/process`, { - project_id: projectId, - occurrence_submission_id: submissionId + const processCsvSubmission = async (projectId: number, surveyId: number, submissionId: number) => { + const { data } = await axios.post(`/api/project/${projectId}/survey/${surveyId}/observations/process`, { + observation_submission_id: submissionId }); return data; }; /** - * Insert/updates all survey observation records for the given survey + * Deletes all of the observations having the given ID. * * @param {number} projectId * @param {number} surveyId - * @param {IObservationTableRow[]} surveyObservations - * @return {*} + * @param {((string | number)[])} surveyObservationIds + * @return {*} {Promise} */ - const insertUpdateObservationRecords = async ( + const deleteObservationRecords = async ( projectId: number, surveyId: number, - surveyObservations: IObservationTableRow[] - ): Promise => { - const { data } = await axios.put( - `/api/project/${projectId}/survey/${surveyId}/observation`, - { surveyObservations } - ); - - return data.surveyObservations; - }; - - /** - * Retrieves all survey observation records for the given survey - * - * @param {number} projectId - * @param {number} surveyId - * @return {*} {Promise} - */ - const getObservationRecords = async ( - projectId: number, - surveyId: number - ): Promise => { - const { data } = await axios.get( - `/api/project/${projectId}/survey/${surveyId}/observation` + surveyObservationIds: (string | number)[] + ): Promise<{ supplementaryObservationData: ISupplementaryObservationData }> => { + const { data } = await axios.post<{ supplementaryObservationData: ISupplementaryObservationData }>( + `/api/project/${projectId}/survey/${surveyId}/observations/delete`, + { surveyObservationIds } ); return data; }; return { - uploadObservationSubmission, - getObservationSubmission, - deleteObservationSubmission, - getObservationSubmissionSignedURL, - initiateXLSXSubmissionTransform, - getOccurrencesForView, - processOccurrences, - processDWCFile, - getSpatialMetadata, insertUpdateObservationRecords, - getObservationRecords + getObservationRecords, + deleteObservationRecords, + uploadCsvForImport, + processCsvSubmission }; }; diff --git a/app/src/hooks/api/useProjectApi.test.ts b/app/src/hooks/api/useProjectApi.test.ts index 0bd47aef7a..12fc6d24c4 100644 --- a/app/src/hooks/api/useProjectApi.test.ts +++ b/app/src/hooks/api/useProjectApi.test.ts @@ -3,7 +3,6 @@ import MockAdapter from 'axios-mock-adapter'; import { IEditReportMetaForm } from 'components/attachments/EditReportMetaForm'; import { IProjectDetailsForm } from 'features/projects/components/ProjectDetailsForm'; import { IProjectIUCNForm } from 'features/projects/components/ProjectIUCNForm'; -import { IProjectLocationForm } from 'features/projects/components/ProjectLocationForm'; import { IProjectObjectivesForm } from 'features/projects/components/ProjectObjectivesForm'; import { ICreateProjectRequest, UPDATE_GET_ENTITIES } from 'interfaces/useProjectApi.interface'; import { getProjectForViewResponse } from 'test-helpers/project-helpers'; @@ -106,7 +105,6 @@ describe('useProjectApi', () => { id: 1, name: 'project name', objectives: 'objectives', - location_description: 'location', start_date: '2020/04/04', end_date: '2020/05/05', comments: 'comment', @@ -175,7 +173,6 @@ describe('useProjectApi', () => { permit: null as unknown as ISurveyPermitForm, project: null as unknown as IProjectDetailsForm, objectives: null as unknown as IProjectObjectivesForm, - location: null as unknown as IProjectLocationForm, iucn: null as unknown as IProjectIUCNForm } as unknown as ICreateProjectRequest; diff --git a/app/src/hooks/api/usePublishApi.ts b/app/src/hooks/api/usePublishApi.ts index 9522739e96..ab5385161e 100644 --- a/app/src/hooks/api/usePublishApi.ts +++ b/app/src/hooks/api/usePublishApi.ts @@ -1,5 +1,6 @@ import { AxiosInstance } from 'axios'; import { IRemoveOrResubmitForm } from 'components/publish/components/RemoveOrResubmitForm'; +import { ISubmitSurvey } from 'components/publish/PublishSurveyDialog'; import { IProjectSubmitForm, ISurveySubmitForm } from 'interfaces/usePublishApi.interface'; /** @@ -33,6 +34,27 @@ const usePublishApi = (axios: AxiosInstance) => { return data; }; + /** + * Publish Survey Data + * + * @param {string} surveyUUID + * @param {number} surveyId + * @param {ISubmitSurvey} dataSubmission + * @return {*} {Promise<{ submission_id: number }>} + */ + const publishSurveyData = async ( + surveyId: number, + dataSubmission: ISubmitSurvey + ): Promise<{ submission_id: number }> => { + const sendData = { + surveyId: surveyId, + data: dataSubmission + }; + + const { data } = await axios.post('/api/publish/survey', sendData); + return data; + }; + /** * Publish Project Data * @@ -84,6 +106,7 @@ const usePublishApi = (axios: AxiosInstance) => { return { publishSurvey, + publishSurveyData, publishProject, resubmitAttachment }; diff --git a/app/src/hooks/api/useSamplingSiteApi.ts b/app/src/hooks/api/useSamplingSiteApi.ts index d0b3ee4735..76cfd6d8a6 100644 --- a/app/src/hooks/api/useSamplingSiteApi.ts +++ b/app/src/hooks/api/useSamplingSiteApi.ts @@ -56,10 +56,23 @@ const useSamplingSiteApi = (axios: AxiosInstance) => { await axios.put(`/api/project/${projectId}/survey/${surveyId}/sample-site/${sampleSiteId}`, sampleSite); }; + /** + * Delete Sample Site + * + * @param {number} projectId + * @param {number} surveyId + * @param {number} sampleSiteId + * @return {*} {Promise} + */ + const deleteSampleSite = async (projectId: number, surveyId: number, sampleSiteId: number): Promise => { + await axios.delete(`/api/project/${projectId}/survey/${surveyId}/sample-site/${sampleSiteId}`); + }; + return { createSamplingSites, getSampleSites, - editSampleSite + editSampleSite, + deleteSampleSite }; }; diff --git a/app/src/hooks/api/useSearchApi.test.ts b/app/src/hooks/api/useSearchApi.test.ts deleted file mode 100644 index 55c3c4be1e..0000000000 --- a/app/src/hooks/api/useSearchApi.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; -import useSearchApi from './useSearchApi'; - -describe('useSearchApi', () => { - let mock: any; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - it('getSearchResults works as expected', async () => { - const res = [ - { - id: '1', - name: 'name', - objectives: 'objectives', - geometry: [] - } - ]; - - mock.onGet('api/search').reply(200, res); - - const result = await useSearchApi(axios).getSearchResults(); - - expect(result[0].id).toEqual('1'); - }); -}); diff --git a/app/src/hooks/api/useSearchApi.ts b/app/src/hooks/api/useSearchApi.ts deleted file mode 100644 index cec839ff1a..0000000000 --- a/app/src/hooks/api/useSearchApi.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { AxiosInstance } from 'axios'; -import { IGetSearchResultsResponse } from 'interfaces/useSearchApi.interface'; - -/** - * Returns a set of supported api methods for working with search functionality - * - * @param {AxiosInstance} axios - * @return {*} object whose properties are supported api methods. - */ -const useSearchApi = (axios: AxiosInstance) => { - /** - * Get search results (spatial) - * - * @return {*} {Promise} - */ - const getSearchResults = async (): Promise => { - const { data } = await axios.get(`/api/search`); - - return data; - }; - - return { - getSearchResults - }; -}; - -export default useSearchApi; diff --git a/app/src/hooks/api/useSurveyApi.test.ts b/app/src/hooks/api/useSurveyApi.test.ts index 8f0a696746..ec2f5812e0 100644 --- a/app/src/hooks/api/useSurveyApi.test.ts +++ b/app/src/hooks/api/useSurveyApi.test.ts @@ -1,8 +1,13 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { AnimalSex, Critter, IAnimal } from 'features/surveys/view/survey-animals/animal'; -import { IAnimalDeployment } from 'features/surveys/view/survey-animals/device'; -import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; +import { IAnimalDeployment } from 'features/surveys/view/survey-animals/telemetry-device/device'; +import { + ICreateSurveyRequest, + ICreateSurveyResponse, + IDetailedCritterWithInternalId, + IGetSurveyForListResponse +} from 'interfaces/useSurveyApi.interface'; import { v4 } from 'uuid'; import useSurveyApi from './useSurveyApi'; @@ -21,6 +26,42 @@ describe('useSurveyApi', () => { const surveyId = 1; const critterId = 1; + describe('createSurvey', () => { + it('creates a survey', async () => { + const projectId = 1; + const survey = {} as unknown as ICreateSurveyRequest; + + const res: ICreateSurveyResponse = { + id: 1 + }; + + mock.onPost(`/api/project/${projectId}/survey/create`).reply(200, res); + + const result = await useSurveyApi(axios).createSurvey(projectId, survey); + + expect(result.id).toEqual(1); + }); + }); + + describe('getSurveysBasicFieldsByProjectId', () => { + it('fetches an array of surveys', async () => { + const projectId = 1; + + const res: IGetSurveyForListResponse[] = [ + { surveyData: { survey_id: 1 }, surveySupplementaryData: {} } as IGetSurveyForListResponse, + { surveyData: { survey_id: 2 }, surveySupplementaryData: {} } as IGetSurveyForListResponse + ]; + + mock.onGet(`/api/project/${projectId}/survey`).reply(200, res); + + const result = await useSurveyApi(axios).getSurveysBasicFieldsByProjectId(projectId); + + expect(result.length).toEqual(2); + expect(result[0].surveyData.survey_id).toEqual(1); + expect(result[1].surveyData.survey_id).toEqual(2); + }); + }); + describe('createCritterAndAddToSurvey', () => { it('creates a critter successfully', async () => { const animal: IAnimal = { @@ -38,7 +79,7 @@ describe('useSurveyApi', () => { mortality: [], family: [], images: [], - device: undefined, + device: [], collectionUnits: [] }; const critter = new Critter(animal); diff --git a/app/src/hooks/api/useSurveyApi.ts b/app/src/hooks/api/useSurveyApi.ts index 20ea2e3114..245dd59f0c 100644 --- a/app/src/hooks/api/useSurveyApi.ts +++ b/app/src/hooks/api/useSurveyApi.ts @@ -7,7 +7,7 @@ import { IAnimalTelemetryDevice, IDeploymentTimespan, ITelemetryPointCollection -} from 'features/surveys/view/survey-animals/device'; +} from 'features/surveys/view/survey-animals/telemetry-device/device'; import { IGetAttachmentDetails, IGetReportDetails, @@ -72,13 +72,13 @@ const useSurveyApi = (axios: AxiosInstance) => { }; /** - * Get surveys list. + * Fetches a subset of survey fields for all surveys under a project. * * @param {number} projectId * @return {*} {Promise} */ - const getSurveysList = async (projectId: number): Promise => { - const { data } = await axios.get(`/api/project/${projectId}/survey/list`); + const getSurveysBasicFieldsByProjectId = async (projectId: number): Promise => { + const { data } = await axios.get(`/api/project/${projectId}/survey`); return data; }; @@ -565,7 +565,7 @@ const useSurveyApi = (axios: AxiosInstance) => { body: IAnimalTelemetryDevice & { critter_id: string } ): Promise => { body.device_id = Number(body.device_id); //Turn this into validation class soon - body.frequency = Number(body.frequency); + body.frequency = body.frequency != null ? Number(body.frequency) : undefined; body.frequency_unit = body.frequency_unit?.length ? body.frequency_unit : undefined; if (!body.deployments || body.deployments.length !== 1) { throw Error('Calling this with any amount other than 1 deployments currently unsupported.'); @@ -579,7 +579,7 @@ const useSurveyApi = (axios: AxiosInstance) => { }; /** - * Update a deployment with a new timespan. + * Update a deployment with a new time span. * * @param {number} projectId * @param {number} surveyId @@ -648,7 +648,7 @@ const useSurveyApi = (axios: AxiosInstance) => { return { createSurvey, getSurveyForView, - getSurveysList, + getSurveysBasicFieldsByProjectId, getSurveyForUpdate, updateSurvey, uploadSurveyAttachments, diff --git a/app/src/hooks/api/useTaxonomyApi.ts b/app/src/hooks/api/useTaxonomyApi.ts index d5a9420595..bd48e8751b 100644 --- a/app/src/hooks/api/useTaxonomyApi.ts +++ b/app/src/hooks/api/useTaxonomyApi.ts @@ -14,9 +14,9 @@ const useTaxonomyApi = (axios: AxiosInstance) => { return data; }; - const getSpeciesFromIds = async (value: number[]): Promise => { + const getSpeciesFromIds = async (ids: number[]): Promise => { const { data } = await axios.get(`/api/taxonomy/species/list`, { - params: { ids: qs.stringify(value) }, + params: { ids: qs.stringify(ids) }, paramsSerializer: (params) => { return qs.stringify(params); } diff --git a/app/src/hooks/cb_api/useLookupApi.test.tsx b/app/src/hooks/cb_api/useLookupApi.test.tsx index 7ce0cb21d1..b748d6eef7 100644 --- a/app/src/hooks/cb_api/useLookupApi.test.tsx +++ b/app/src/hooks/cb_api/useLookupApi.test.tsx @@ -19,9 +19,16 @@ describe('useLookup', () => { key: 'colour_id', id: '7a516697-c7ee-43b3-9e17-2fc31572d819', value: 'Blue' + }, + { + key: 'colour_id', + id: '9a516697-c7ee-43b3-9e17-2fc31572d819', + value: 'Green' } ]; + const mockEnumLookup = ['A', 'B']; + const mockMeasurement = [ { taxon_measurement_id: '29425067-e5ea-4284-b629-26c3cac4cbbf', @@ -46,6 +53,46 @@ describe('useLookup', () => { expect(res[0].id).toBeDefined(); }); + it('should order lookups by asc if param provided', async () => { + mock.onGet('/api/critter-data/lookups/colours?format=asSelect').reply(200, mockLookup); + const result = await useLookupApi(axios).getSelectOptions({ route: 'lookups/colours', orderBy: 'asc' }); + const res = result as ICbSelectRows[]; + expect(res[0].key).toBe('colour_id'); + expect(res[0].value).toBe('Blue'); + expect(res[0].id).toBeDefined(); + expect(res[1].key).toBe('colour_id'); + expect(res[1].value).toBe('Green'); + expect(res[1].id).toBeDefined(); + }); + + it('should order string lookups by asc if param provided', async () => { + mock.onGet('/api/critter-data/lookups/colours?format=asSelect').reply(200, mockEnumLookup); + const result = await useLookupApi(axios).getSelectOptions({ route: 'lookups/colours', orderBy: 'asc' }); + const res = result as ICbSelectRows[]; + expect(res[0]).toBe('A'); + expect(res[1]).toBe('B'); + }); + + it('should order string lookups by desc if param provided', async () => { + mock.onGet('/api/critter-data/lookups/colours?format=asSelect').reply(200, mockEnumLookup); + const result = await useLookupApi(axios).getSelectOptions({ route: 'lookups/colours', orderBy: 'desc' }); + const res = result as ICbSelectRows[]; + expect(res[0]).toBe('B'); + expect(res[1]).toBe('A'); + }); + + it('should order lookups by desc if param provided', async () => { + mock.onGet('/api/critter-data/lookups/colours?format=asSelect').reply(200, mockLookup); + const result = await useLookupApi(axios).getSelectOptions({ route: 'lookups/colours', orderBy: 'desc' }); + const res = result as ICbSelectRows[]; + expect(res[0].key).toBe('colour_id'); + expect(res[0].value).toBe('Green'); + expect(res[0].id).toBeDefined(); + expect(res[1].key).toBe('colour_id'); + expect(res[1].value).toBe('Blue'); + expect(res[1].id).toBeDefined(); + }); + it('should retrieve all possible measurements for a specific taxon', async () => { const taxon_id = v4(); mock.onGet('/api/critter-data/xref/taxon-measurements?taxon_id=' + taxon_id).reply(200, mockMeasurement); diff --git a/app/src/hooks/cb_api/useLookupApi.tsx b/app/src/hooks/cb_api/useLookupApi.tsx index 1090f60aba..74fc835b14 100644 --- a/app/src/hooks/cb_api/useLookupApi.tsx +++ b/app/src/hooks/cb_api/useLookupApi.tsx @@ -1,5 +1,8 @@ +import { GridSortDirection } from '@mui/x-data-grid/models'; import { AxiosInstance } from 'axios'; +export type OrderBy = 'asc' | 'desc'; + export interface ICbSelectRows { key: string; id: string; @@ -13,6 +16,7 @@ interface SelectOptionsProps { param?: string; query?: string; asSelect?: boolean; + orderBy?: GridSortDirection; } export interface IMeasurementStub { @@ -23,16 +27,24 @@ export interface IMeasurementStub { unit?: string; } const useLookupApi = (axios: AxiosInstance) => { - const getSelectOptions = async ({ - route, - param, - query - }: SelectOptionsProps): Promise> => { + const getSelectOptions = async ({ route, param, query, orderBy }: SelectOptionsProps) => { const _param = param ? `/${param}` : ``; const _query = query ? `&${query}` : ``; - const { data } = await axios.get(`/api/critter-data/${route}${_param}?format=asSelect${_query}`); + const { data } = await axios.get>( + `/api/critter-data/${route}${_param}?format=asSelect${_query}` + ); - return data; + if (!orderBy) { + return data; + } + + const getSortValue = (val: string | ICbSelectRows) => (typeof val === 'string' ? val : val.value); + + const sorter = (aValue: string | ICbSelectRows, bValue: string | ICbSelectRows) => { + return getSortValue(aValue) > getSortValue(bValue) ? -1 : 1; + }; + + return orderBy === 'desc' ? data.sort(sorter) : data.sort(sorter).reverse(); }; const getTaxonMeasurements = async (taxon_id?: string): Promise | undefined> => { diff --git a/app/src/hooks/telemetry/useDeviceApi.test.tsx b/app/src/hooks/telemetry/useDeviceApi.test.tsx index 034d233ba0..bc76b85a31 100644 --- a/app/src/hooks/telemetry/useDeviceApi.test.tsx +++ b/app/src/hooks/telemetry/useDeviceApi.test.tsx @@ -1,6 +1,6 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import { Device } from 'features/surveys/view/survey-animals/device'; +import { Device } from 'features/surveys/view/survey-animals/telemetry-device/device'; import { useDeviceApi } from './useDeviceApi'; describe('useDeviceApi', () => { diff --git a/app/src/hooks/telemetry/useDeviceApi.tsx b/app/src/hooks/telemetry/useDeviceApi.tsx index 6d6c82ca22..823824cb55 100644 --- a/app/src/hooks/telemetry/useDeviceApi.tsx +++ b/app/src/hooks/telemetry/useDeviceApi.tsx @@ -1,5 +1,5 @@ import { AxiosInstance } from 'axios'; -import { Device, IAnimalDeployment } from 'features/surveys/view/survey-animals/device'; +import { Device, IAnimalDeployment } from 'features/surveys/view/survey-animals/telemetry-device/device'; interface ICodeResponse { code_header_title: string; @@ -9,6 +9,13 @@ interface ICodeResponse { 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. * @@ -51,12 +58,6 @@ const useDeviceApi = (axios: AxiosInstance) => { return []; }; - interface IGetDeviceDetailsResponse { - device: Record | undefined; - keyXStatus: boolean; - deployments: Omit[]; - } - /** * Returns details for a given device. * diff --git a/app/src/hooks/useAuthStateContext.tsx b/app/src/hooks/useAuthStateContext.tsx new file mode 100644 index 0000000000..ad7f8adaff --- /dev/null +++ b/app/src/hooks/useAuthStateContext.tsx @@ -0,0 +1,19 @@ +import { useContext } from 'react'; +import { AuthStateContext, IAuthState } from '../contexts/authStateContext'; + +/** + * Returns an instance of `IAuthState` from `AuthStateContext`. + * + * @return {*} {IAuthState} + */ +export const useAuthStateContext = (): IAuthState => { + const context = useContext(AuthStateContext); + + if (!context) { + throw Error( + 'AuthStateContext is undefined, please verify you are calling useAuthStateContext() as child of an component.' + ); + } + + return context; +}; diff --git a/app/src/hooks/useBioHubApi.ts b/app/src/hooks/useBioHubApi.ts index d89a6db8e7..cc4f2466dd 100644 --- a/app/src/hooks/useBioHubApi.ts +++ b/app/src/hooks/useBioHubApi.ts @@ -5,6 +5,7 @@ import useAdminApi from './api/useAdminApi'; import useAxios from './api/useAxios'; import useCodesApi from './api/useCodesApi'; import useDraftApi from './api/useDraftApi'; +import useDwcaApi from './api/useDwcaApi'; import useExternalApi from './api/useExternalApi'; import useFundingSourceApi from './api/useFundingSourceApi'; import useObservationApi from './api/useObservationApi'; @@ -13,7 +14,6 @@ import useProjectParticipationApi from './api/useProjectParticipationApi'; import usePublishApi from './api/usePublishApi'; import useResourcesApi from './api/useResourcesApi'; import useSamplingSiteApi from './api/useSamplingSiteApi'; -import useSearchApi from './api/useSearchApi'; import useSpatialApi from './api/useSpatialApi'; import useSurveyApi from './api/useSurveyApi'; import useTaxonomyApi from './api/useTaxonomyApi'; @@ -32,8 +32,6 @@ export const useBiohubApi = () => { const projectParticipants = useProjectParticipationApi(apiAxios); - const search = useSearchApi(apiAxios); - const taxonomy = useTaxonomyApi(apiAxios); const survey = useSurveyApi(apiAxios); @@ -48,6 +46,8 @@ export const useBiohubApi = () => { const observation = useObservationApi(apiAxios); + const dwca = useDwcaApi(apiAxios); + const resources = useResourcesApi(apiAxios); const external = useExternalApi(axios); @@ -64,10 +64,10 @@ export const useBiohubApi = () => { () => ({ project, projectParticipants, - search, taxonomy, survey, observation, + dwca, resources, codes, draft, diff --git a/app/src/hooks/useCritterbaseUserWrapper.tsx b/app/src/hooks/useCritterbaseUserWrapper.tsx new file mode 100644 index 0000000000..3821794dde --- /dev/null +++ b/app/src/hooks/useCritterbaseUserWrapper.tsx @@ -0,0 +1,31 @@ +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { ISimsUserWrapper } from 'hooks/useSimsUserWrapper'; + +export interface ICritterbaseUserWrapper { + /** + * Set to `true` if the user's information is still loading, false otherwise. + */ + isLoading: boolean; + /** + * The critterbase user uuid. + */ + critterbaseUserUuid: string | undefined; +} + +function useCritterbaseUserWrapper(simsUserWrapper: ISimsUserWrapper): ICritterbaseUserWrapper { + const cbApi = useCritterbaseApi(); + + const critterbaseSignupLoader = useDataLoader(async () => cbApi.authentication.signUp()); + + if (!simsUserWrapper.isLoading && simsUserWrapper.systemUserId) { + critterbaseSignupLoader.load(); + } + + return { + isLoading: simsUserWrapper.isLoading || (simsUserWrapper.systemUserId ? !critterbaseSignupLoader.isReady : false), + critterbaseUserUuid: critterbaseSignupLoader.data?.user_id + }; +} + +export default useCritterbaseUserWrapper; diff --git a/app/src/hooks/useKeycloakWrapper.test.tsx b/app/src/hooks/useKeycloakWrapper.test.tsx deleted file mode 100644 index ecc07f9190..0000000000 --- a/app/src/hooks/useKeycloakWrapper.test.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import { ReactKeycloakProvider } from '@react-keycloak/web'; -import { cleanup, renderHook } from '@testing-library/react-hooks'; -import Keycloak, { KeycloakPromise } from 'keycloak-js'; -import { PropsWithChildren } from 'react'; -import { act } from 'react-dom/test-utils'; -import { useBiohubApi } from './useBioHubApi'; -import useKeycloakWrapper, { SYSTEM_IDENTITY_SOURCE } from './useKeycloakWrapper'; - -const getMockTestWrapper = (userInfo?: any) => { - const mockLoadUserInfo = Promise.resolve( - userInfo || { - display_name: 'testname', - email: 'text@example.com', - email_verified: false, - idir_user_guid: 'aaaa', - idir_username: 'testuser', - preferred_username: 'aaaa@idir', - sub: 'aaaa@idir' - } - ); - - const keycloak: Keycloak = { - authenticated: true, - token: 'a token', - init: () => Promise.resolve(true) as KeycloakPromise, - createLoginUrl: (options: { redirectUri: string }) => `/login?my-keycloak-redirect=${options.redirectUri}`, - createLogoutUrl: () => 'string', - createRegisterUrl: () => 'string', - createAccountUrl: () => 'string', - isTokenExpired: () => false, - updateToken: () => null, - clearToken: () => null, - hasRealmRole: () => true, - hasResourceRole: () => true, - loadUserInfo: () => mockLoadUserInfo - } as unknown as Keycloak; - - return { - wrapper: (props: PropsWithChildren) => ( - {props.children} - ), - mockLoadUserInfo - }; -}; - -jest.mock('./useBioHubApi'); - -const mockBiohubApi = useBiohubApi as jest.Mock; - -const mockUseApi = { - user: { - getUser: jest.fn() - }, - admin: { - getAdministrativeActivityStanding: jest.fn() - } -}; - -describe('useKeycloakWrapper', () => { - beforeEach(() => { - mockBiohubApi.mockImplementation(() => mockUseApi); - }); - - afterEach(() => { - cleanup(); - }); - - describe('getLoginUrl', () => { - afterEach(() => { - cleanup(); - }); - - it('should get a redirect URL if no redirect URI is provided', async () => { - const { wrapper, mockLoadUserInfo } = getMockTestWrapper(); - const { result } = renderHook(() => useKeycloakWrapper(), { wrapper }); - - await act(async () => { - await mockLoadUserInfo; - }); - - expect(result.current.getLoginUrl()).toEqual('/login?my-keycloak-redirect=http://localhost/admin/projects'); - }); - - it('should get a redirect URL if a redirect URI is provided', async () => { - const { wrapper, mockLoadUserInfo } = getMockTestWrapper(); - const { result } = renderHook(() => useKeycloakWrapper(), { wrapper }); - - await act(async () => { - await mockLoadUserInfo; - }); - - expect(result.current.getLoginUrl('/test')).toEqual('/login?my-keycloak-redirect=http://localhost/test'); - }); - }); - - it('renders successfully', async () => { - const { wrapper, mockLoadUserInfo } = getMockTestWrapper(); - const { result } = renderHook(() => useKeycloakWrapper(), { - wrapper - }); - - await act(async () => { - await mockLoadUserInfo; - }); - - expect(result.current).toBeDefined(); - }); - - it('loads the Keycloak userinfo on mount', async () => { - const { wrapper, mockLoadUserInfo } = getMockTestWrapper(); - const { result } = renderHook(() => useKeycloakWrapper(), { - wrapper - }); - - await act(async () => { - await mockLoadUserInfo; - }); - - expect(result.current.keycloak).toBeDefined(); - expect(result.current.displayName).toEqual('testname'); - expect(result.current.email).toEqual('text@example.com'); - expect(result.current.getIdentitySource()).toEqual(SYSTEM_IDENTITY_SOURCE.IDIR); - expect(result.current.getUserIdentifier()).toEqual('testuser'); - }); - - it('returns a null user identifier', async () => { - const { wrapper, mockLoadUserInfo } = getMockTestWrapper({ - display_name: 'testname', - email: 'text@example.com', - email_verified: false, - preferred_username: 'aaaa@idir', - sub: 'aaaa@idir' - }); - - const { result } = renderHook(() => useKeycloakWrapper(), { - wrapper - }); - - await act(async () => { - await mockLoadUserInfo; - }); - - expect(result.current.keycloak).toBeDefined(); - expect(result.current.getUserIdentifier()).toEqual(null); - }); - - it('returns a null identity source', async () => { - const { wrapper, mockLoadUserInfo } = getMockTestWrapper({ - display_name: 'testname', - email: 'text@example.com', - email_verified: false, - preferred_username: 'aaaa@', - sub: 'aaaa@' - }); - - const { result } = renderHook(() => useKeycloakWrapper(), { - wrapper - }); - - await act(async () => { - await mockLoadUserInfo; - }); - - expect(result.current.keycloak).toBeDefined(); - expect(result.current.getIdentitySource()).toEqual(null); - }); - - it('returns an IDIR identity source', async () => { - const { wrapper, mockLoadUserInfo } = getMockTestWrapper({ - preferred_username: 'aaaa@idir', - sub: 'aaaa@idir' - }); - - const { result } = renderHook(() => useKeycloakWrapper(), { - wrapper - }); - - await act(async () => { - await mockLoadUserInfo; - }); - - expect(result.current.keycloak).toBeDefined(); - expect(result.current.getIdentitySource()).toEqual(SYSTEM_IDENTITY_SOURCE.IDIR); - }); - - it('returns an BCEID basic identity source', async () => { - const { wrapper, mockLoadUserInfo } = getMockTestWrapper({ - preferred_username: 'aaaa@bceidbasic', - sub: 'aaaa@bceidbasic' - }); - - const { result } = renderHook(() => useKeycloakWrapper(), { - wrapper - }); - - await act(async () => { - await mockLoadUserInfo; - }); - - expect(result.current.keycloak).toBeDefined(); - expect(result.current.getIdentitySource()).toEqual(SYSTEM_IDENTITY_SOURCE.BCEID_BASIC); - }); - - it('returns an BCEID business identity source', async () => { - const { wrapper, mockLoadUserInfo } = getMockTestWrapper({ - preferred_username: 'aaaa@bceidbusiness', - sub: 'aaaa@bceidbusiness' - }); - - const { result } = renderHook(() => useKeycloakWrapper(), { - wrapper - }); - - await act(async () => { - await mockLoadUserInfo; - }); - - expect(result.current.keycloak).toBeDefined(); - expect(result.current.getIdentitySource()).toEqual(SYSTEM_IDENTITY_SOURCE.BCEID_BUSINESS); - }); -}); diff --git a/app/src/hooks/useKeycloakWrapper.tsx b/app/src/hooks/useKeycloakWrapper.tsx deleted file mode 100644 index 02369ace20..0000000000 --- a/app/src/hooks/useKeycloakWrapper.tsx +++ /dev/null @@ -1,371 +0,0 @@ -import { useKeycloak } from '@react-keycloak/web'; -import { ISystemUser } from 'interfaces/useUserApi.interface'; -import Keycloak from 'keycloak-js'; -import { useCallback } from 'react'; -import { buildUrl } from 'utils/Utils'; -import { useBiohubApi } from './useBioHubApi'; -import { useCritterbaseApi } from './useCritterbaseApi'; -import useDataLoader from './useDataLoader'; - -export enum SYSTEM_IDENTITY_SOURCE { - BCEID_BUSINESS = 'BCEIDBUSINESS', - BCEID_BASIC = 'BCEIDBASIC', - IDIR = 'IDIR', - DATABASE = 'DATABASE', - UNVERIFIED = 'UNVERIFIED' -} - -export interface IUserInfo { - sub: string; - email_verified: boolean; - preferred_username: string; - identity_source: string; - display_name: string; - email: string; -} - -export interface IIDIRUserInfo extends IUserInfo { - idir_user_guid: string; - idir_username: string; - name: string; - given_name: string; - family_name: string; -} - -interface IBCEIDBasicUserInfo extends IUserInfo { - bceid_user_guid: string; - bceid_username: string; -} - -export interface IBCEIDBusinessUserInfo extends IBCEIDBasicUserInfo { - bceid_business_guid: string; - bceid_business_name: string; -} - -/** - * Interface defining the objects and helper functions returned by `useKeycloakWrapper` - * - * @export - * @interface IKeycloakWrapper - */ -export interface IKeycloakWrapper { - /** - * Original raw keycloak object. - * - * @type {(Keycloak)} - * @memberof IKeycloakWrapper - */ - keycloak: Keycloak; - /** - * Returns `true` if the user's information has finished being loaded, false otherwise. - * - * @type {boolean} - * @memberof IKeycloakWrapper - */ - hasLoadedAllUserInfo: boolean; - /** - * The user's system roles, if any. - * - * @type {string[]} - * @memberof IKeycloakWrapper - */ - systemRoles: string[]; - /** - * Returns `true` if the keycloak user is a registered system user, `false` otherwise. - * - * @memberof IKeycloakWrapper - */ - isSystemUser: () => boolean; - /** - * Returns `true` if the user's `systemRoles` contain at least 1 of the specified `validSystemRoles`, `false` otherwise. - * - * @memberof IKeycloakWrapper - */ - hasSystemRole: (validSystemRoles?: string[]) => boolean; - /** - * True if the user has at least 1 pending access request. - * - * @type {boolean} - * @memberof IKeycloakWrapper - */ - hasAccessRequest: boolean; - /** - * True if the user has at least 1 project participant roles. - * - * @type {boolean} - * @memberof IKeycloakWrapper - */ - hasOneOrMoreProjectRoles: boolean; - /** - * Get out the username portion of the preferred_username from the token. - * - * @memberof IKeycloakWrapper - */ - getUserIdentifier: () => string | null; - /** - * Get the identity source portion of the preferred_username from the token. - * - * @memberof IKeycloakWrapper - */ - getIdentitySource: () => string | null; - /** - * Get the user guid - * - * @memberof IKeycloakWrapper - */ - getUserGuid: () => string | null; - /** - * The user's auth username.F - * - * @type {(string | undefined)} - * @memberof IKeycloakWrapper - */ - username: string | undefined; - /** - * The user's display name. - * - * @type {(string | undefined)} - * @memberof IKeycloakWrapper - */ - displayName: string | undefined; - /** - * The user's email. - * - * @type {(string | undefined)} - * @memberof IKeycloakWrapper - */ - email: string | undefined; - /** - * The user's system user id. - * - * @type {(number | undefined)} - * @memberof IKeycloakWrapper - */ - systemUserId: number | undefined; - /** - * Force this keycloak wrapper to refresh its data. - * - * @memberof IKeycloakWrapper - */ - refresh: () => void; - /** - * Generates the URL to sign in using Keycloak. - * - * @param {string} [redirectUri] Optionally URL to redirect the user to upon logging in - * @memberof IKeycloakWrapper - */ - getLoginUrl: (redirectUri?: string) => string; - /** - * The logged in user's data. - * - * @type {(ISystemUser | undefined)} - * @memberof IKeycloakWrapper - */ - user: ISystemUser | undefined; - /** - * The critterbase Uuid. - * - * @memberof IKeycloakWrapper - */ - critterbaseUuid: () => string | undefined; -} - -/** - * Wraps the raw keycloak object, returning an object that contains the original raw keycloak object plus useful helper - * functions. - * - * @return {*} {IKeycloakWrapper} - */ -function useKeycloakWrapper(): IKeycloakWrapper { - const { keycloak } = useKeycloak(); - - const biohubApi = useBiohubApi(); - const cbApi = useCritterbaseApi(); - - const keycloakUserDataLoader = useDataLoader(async () => { - return ( - (keycloak.token && - (keycloak.loadUserInfo() as unknown as IIDIRUserInfo | IBCEIDBasicUserInfo | IBCEIDBusinessUserInfo)) || - undefined - ); - }); - - const userDataLoader = useDataLoader(() => biohubApi.user.getUser()); - - const critterbaseSignupLoader = useDataLoader(async () => { - if (userDataLoader?.data?.system_user_id != null) { - return cbApi.authentication.signUp(); - } - }); - - const administrativeActivityStandingDataLoader = useDataLoader(biohubApi.admin.getAdministrativeActivityStanding); - - if (keycloak) { - // keycloak is ready, load keycloak user info - keycloakUserDataLoader.load(); - } - - if (keycloak.authenticated) { - // keycloak user is authenticated, load system user info - userDataLoader.load(); - - if (userDataLoader.isReady && (!userDataLoader.data?.role_names.length || userDataLoader.data?.record_end_date)) { - // Authenticated user either has has no roles or has been deactivated - // Check if the user has a pending access request - administrativeActivityStandingDataLoader.load(); - } - - if (userDataLoader.isReady && !critterbaseSignupLoader.data) { - critterbaseSignupLoader.load(); - } - } - - /** - * Coerces a string into a user identity source, e.g. BCEID, IDIR, etc. - * - * @example _inferIdentitySource('idir'); // => SYSTEM_IDENTITY_SOURCE.IDIR - * - * @param userIdentitySource The user identity source string - * @returns {*} {SYSTEM_IDENTITY_SOURCE | null} - */ - const _inferIdentitySource = (userIdentitySource: string | undefined): SYSTEM_IDENTITY_SOURCE | null => { - switch (userIdentitySource) { - case SYSTEM_IDENTITY_SOURCE.BCEID_BASIC: - return SYSTEM_IDENTITY_SOURCE.BCEID_BASIC; - - case SYSTEM_IDENTITY_SOURCE.BCEID_BUSINESS: - return SYSTEM_IDENTITY_SOURCE.BCEID_BUSINESS; - - case SYSTEM_IDENTITY_SOURCE.IDIR: - return SYSTEM_IDENTITY_SOURCE.IDIR; - - default: - return null; - } - }; - - /** - * Parses out the username from a keycloak token, from either the `idir_username` or `bceid_username` field. - * - * @param {object} keycloakToken - * @return {*} {(string | null)} - */ - const getUserIdentifier = useCallback((): string | null => { - const userIdentifier = - (keycloakUserDataLoader.data as IIDIRUserInfo)?.idir_username || - (keycloakUserDataLoader.data as IBCEIDBasicUserInfo | IBCEIDBusinessUserInfo)?.bceid_username; - - if (!userIdentifier) { - return null; - } - - return userIdentifier.toLowerCase(); - }, [keycloakUserDataLoader.data]); - - /** - * Parses out the user global user id portion of the preferred_username from the token. - * - * @return {*} {(string | null)} - */ const getUserGuid = useCallback((): string | null => { - return keycloakUserDataLoader.data?.['preferred_username']?.split('@')?.[0].toLowerCase() || null; - }, [keycloakUserDataLoader.data]); - - /** - * Parses out the identity source portion of the preferred_username from the token. - * - * @param {object} keycloakToken - * @return {*} {(string | null)} - */ - const getIdentitySource = useCallback((): SYSTEM_IDENTITY_SOURCE | null => { - const userIdentitySource = - userDataLoader.data?.['identity_source'] ?? - keycloakUserDataLoader.data?.['preferred_username']?.split('@')?.[1].toUpperCase(); - - if (!userIdentitySource) { - return null; - } - - return _inferIdentitySource(userIdentitySource); - }, [keycloakUserDataLoader.data, userDataLoader.data]); - - const isSystemUser = (): boolean => { - return Boolean(userDataLoader.data?.system_user_id); - }; - - const getSystemRoles = (): string[] => { - return userDataLoader.data?.role_names ?? []; - }; - - const hasSystemRole = (validSystemRoles?: string[]) => { - if (!validSystemRoles?.length) { - return true; - } - - const userSystemRoles = getSystemRoles(); - - if (userSystemRoles.some((item) => validSystemRoles.includes(item))) { - return true; - } - - return false; - }; - - const username = (): string | undefined => { - return ( - (keycloakUserDataLoader.data as IIDIRUserInfo)?.idir_username || - (keycloakUserDataLoader.data as IBCEIDBasicUserInfo)?.bceid_username - ); - }; - - const displayName = (): string | undefined => { - return keycloakUserDataLoader.data?.display_name; - }; - - const email = (): string | undefined => { - return keycloakUserDataLoader.data?.email; - }; - - const refresh = () => { - userDataLoader.refresh(); - administrativeActivityStandingDataLoader.refresh(); - }; - - const systemUserId = (): number | undefined => { - return userDataLoader.data?.system_user_id; - }; - - const getLoginUrl = (redirectUri = '/admin/projects'): string => { - return keycloak?.createLoginUrl({ redirectUri: buildUrl(window.location.origin, redirectUri) }) || '/login'; - }; - - const user = (): ISystemUser | undefined => { - return userDataLoader.data; - }; - - const critterbaseUuid = useCallback(() => { - return critterbaseSignupLoader.data?.user_id; - }, [critterbaseSignupLoader.data?.user_id]); - - return { - keycloak, - hasLoadedAllUserInfo: userDataLoader.isReady || !!administrativeActivityStandingDataLoader.data, - systemRoles: getSystemRoles(), - hasSystemRole, - isSystemUser, - hasAccessRequest: !!administrativeActivityStandingDataLoader.data?.has_pending_access_request, - hasOneOrMoreProjectRoles: !!administrativeActivityStandingDataLoader.data?.has_one_or_more_project_roles, - getUserIdentifier, - getUserGuid, - getIdentitySource, - username: username(), - email: email(), - systemUserId: systemUserId(), - user: user(), - displayName: displayName(), - refresh, - getLoginUrl, - critterbaseUuid - }; -} - -export default useKeycloakWrapper; diff --git a/app/src/hooks/useRedirect.tsx b/app/src/hooks/useRedirect.tsx index 5050b8f963..9605e684ed 100644 --- a/app/src/hooks/useRedirect.tsx +++ b/app/src/hooks/useRedirect.tsx @@ -7,26 +7,28 @@ interface Redirect { redirect: () => void; } -export default function useRedirect(fallback: string): Redirect { - const queryParams = useQuery(); - - const redirectUri = useMemo( - () => - queryParams['redirect'] - ? buildUrl(window.location.origin, decodeURIComponent(queryParams['redirect'])) - : fallback, - - // eslint-disable-next-line react-hooks/exhaustive-deps - [queryParams] - ); +export default function useRedirect(fallbackUri: string): Redirect { + const redirectUri = useRedirectUri(fallbackUri); const redirect = useCallback(() => { if (redirectUri) { window.location.replace(redirectUri); } - - // eslint-disable-next-line react-hooks/exhaustive-deps }, [redirectUri]); return { redirectUri, redirect }; } + +export function useRedirectUri(fallbackUri: string) { + const queryParams = useQuery(); + + const redirectUri = useMemo( + () => + queryParams['redirect'] + ? buildUrl(window.location.origin, decodeURIComponent(queryParams['redirect'])) + : fallbackUri, + [fallbackUri, queryParams] + ); + + return redirectUri; +} diff --git a/app/src/hooks/useSimsUserWrapper.tsx b/app/src/hooks/useSimsUserWrapper.tsx new file mode 100644 index 0000000000..50c9d70d7f --- /dev/null +++ b/app/src/hooks/useSimsUserWrapper.tsx @@ -0,0 +1,125 @@ +import { SYSTEM_IDENTITY_SOURCE } from 'constants/auth'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { useAuth } from 'react-oidc-context'; +import { coerceIdentitySource } from 'utils/authUtils'; + +export interface ISimsUserWrapper { + /** + * Set to `true` if the user's information is still loading, false otherwise. + */ + isLoading: boolean; + /** + * The user's system user id. + */ + systemUserId: number | undefined; + /** + * The user's keycloak guid. + */ + userGuid: string | null | undefined; + /** + * The user's identifier (username). + */ + userIdentifier: string | undefined; + /** + * The user's display name. + */ + displayName: string | undefined; + /** + * The user's email address. + */ + email: string | undefined; + /** + * The user's agency. + */ + agency: string | null | undefined; + /** + * The user's system roles (by name). + */ + roleNames: string[] | undefined; + /** + * The logged in user's identity source (IDIR, BCEID BASIC, BCEID BUSINESS, etc). + */ + identitySource: SYSTEM_IDENTITY_SOURCE | null; + /** + * Set to `true` if the user has at least 1 pending access request, `false` otherwise. + */ + hasAccessRequest: boolean; + /** + * Set to `true` if the user has at least 1 project participant roles, `false` otherwise. + */ + hasOneOrMoreProjectRoles: boolean; + /** + * Force this sims user wrapper to refresh its data. + */ + refresh: () => void; +} + +function useSimsUserWrapper(): ISimsUserWrapper { + const auth = useAuth(); + + const biohubApi = useBiohubApi(); + + const simsUserDataLoader = useDataLoader(() => biohubApi.user.getUser()); + + const administrativeActivityStandingDataLoader = useDataLoader(() => + biohubApi.admin.getAdministrativeActivityStanding() + ); + + if (auth.isAuthenticated) { + simsUserDataLoader.load(); + administrativeActivityStandingDataLoader.load(); + } + + const isLoading = !simsUserDataLoader.isReady || !administrativeActivityStandingDataLoader.isReady; + + const systemUserId = simsUserDataLoader.data?.system_user_id; + + const userGuid = + simsUserDataLoader.data?.user_guid || + (auth.user?.profile?.idir_user_guid as string)?.toLowerCase() || + (auth.user?.profile?.bceid_user_guid as string)?.toLowerCase(); + + const userIdentifier = + simsUserDataLoader.data?.user_identifier || + (auth.user?.profile?.idir_username as string) || + (auth.user?.profile?.bceid_username as string); + + const displayName = simsUserDataLoader.data?.display_name || (auth.user?.profile?.display_name as string); + + const email = simsUserDataLoader.data?.email || (auth.user?.profile?.email as string); + + const agency = simsUserDataLoader.data?.agency; + + const roleNames = simsUserDataLoader.data?.role_names; + + const identitySource = coerceIdentitySource( + simsUserDataLoader.data?.identity_source || (auth.user?.profile?.identity_provider as string)?.toUpperCase() + ); + + const hasAccessRequest = !!administrativeActivityStandingDataLoader.data?.has_pending_access_request; + + const hasOneOrMoreProjectRoles = !!administrativeActivityStandingDataLoader.data?.has_one_or_more_project_roles; + + const refresh = () => { + simsUserDataLoader.refresh(); + administrativeActivityStandingDataLoader.refresh(); + }; + + return { + isLoading, + systemUserId, + userGuid, + userIdentifier, + displayName, + email, + agency, + roleNames, + identitySource, + hasAccessRequest, + hasOneOrMoreProjectRoles, + refresh + }; +} + +export default useSimsUserWrapper; diff --git a/app/src/hooks/useTelemetryApi.ts b/app/src/hooks/useTelemetryApi.ts index 19e9b78a24..e0467a3b9c 100644 --- a/app/src/hooks/useTelemetryApi.ts +++ b/app/src/hooks/useTelemetryApi.ts @@ -1,13 +1,145 @@ +import { CancelTokenSource } from 'axios'; import { ConfigContext } from 'contexts/configContext'; import { useContext } from 'react'; 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 = useContext(ConfigContext); - const apiAxios = useAxios(config?.API_HOST); - const devices = useDeviceApi(apiAxios); - return { devices }; + const axios = useAxios(config?.API_HOST); + const devices = useDeviceApi(axios); + + const getAllTelemetry = async (ids: string[]): Promise => { + const { data } = await axios.post('/api/telemetry/deployments', ids); + return data; + }; + + const getVendorTelemetry = async (ids: string[]): Promise => { + const { data } = await axios.post('/api/telemetry/vendor/deployments', ids); + return data; + }; + + const getManualTelemetry = async (ids: string[]): Promise => { + const { data } = await axios.post('/api/telemetry/manual/deployments', ids); + return data; + }; + + const createManualTelemetry = async (postData: ICreateManualTelemetry[]): Promise => { + const { data } = await axios.post('/api/telemetry/manual', postData); + return data; + }; + + const updateManualTelemetry = async (updateData: IUpdateManualTelemetry[]) => { + const { data } = await axios.patch('/api/telemetry/manual', updateData); + return data; + }; + + const deleteManualTelemetry = async (ids: string[]) => { + const { data } = await axios.post('/api/telemetry/manual/delete', ids); + return data; + }; + + /** + * Uploads a telemetry CSV for import. + * + * @param {number} projectId + * @param {number} surveyId + * @param {File} file + * @param {CancelTokenSource} [cancelTokenSource] + * @param {(progressEvent: ProgressEvent) => void} [onProgress] + * @return {*} {Promise<{ submission_id: number }>} + */ + const uploadCsvForImport = async ( + projectId: number, + surveyId: number, + file: File, + cancelTokenSource?: CancelTokenSource, + onProgress?: (progressEvent: ProgressEvent) => 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, + getAllTelemetry, + getManualTelemetry, + createManualTelemetry, + updateManualTelemetry, + getVendorTelemetry, + deleteManualTelemetry, + uploadCsvForImport, + processTelemetryCsvSubmission + }; }; type TelemetryApiReturnType = ReturnType; diff --git a/app/src/interfaces/useCodesApi.interface.ts b/app/src/interfaces/useCodesApi.interface.ts index a7bfa3edc4..eb50d26a4e 100644 --- a/app/src/interfaces/useCodesApi.interface.ts +++ b/app/src/interfaces/useCodesApi.interface.ts @@ -34,9 +34,7 @@ export interface IGetAllCodeSetsResponse { system_roles: CodeSet; project_roles: CodeSet; administrative_activity_status_type: CodeSet; - field_methods: CodeSet<{ id: number; name: string; description: string }>; intended_outcomes: CodeSet<{ id: number; name: string; description: string }>; - ecological_seasons: CodeSet<{ id: number; name: string; description: string }>; vantage_codes: CodeSet; survey_jobs: CodeSet; site_selection_strategies: CodeSet; diff --git a/app/src/interfaces/useDwcaApi.interface.ts b/app/src/interfaces/useDwcaApi.interface.ts new file mode 100644 index 0000000000..029d89537e --- /dev/null +++ b/app/src/interfaces/useDwcaApi.interface.ts @@ -0,0 +1,87 @@ +import { Feature, FeatureCollection } from 'geojson'; + +export interface IGetSubmissionCSVForViewItem { + name: string; + headers: string[]; + rows: string[][]; +} + +export interface IGetSubmissionCSVForViewResponse { + data: IGetSubmissionCSVForViewItem[]; +} + +export type ObservationSubmissionMessageSeverityLabel = 'Notice' | 'Error' | 'Warning'; + +export interface IGetObservationSubmissionResponseMessages { + severityLabel: ObservationSubmissionMessageSeverityLabel; + messageTypeLabel: string; + messageStatus: string; + messages: { id: number; message: string }[]; +} + +/** + * Get observation submission response object. + * + * @export + * @interface IGetObservationSubmissionResponse + */ +export interface IGetObservationSubmissionResponse { + surveyObservationData: ISurveyObservationData; + surveyObservationSupplementaryData: ISurveySupplementaryData | null; +} + +export interface ISurveySupplementaryData { + occurrence_submission_publish_id: number; + occurrence_submission_id: number; + event_timestamp: string; + queue_id: number; + create_date: string; + create_user: number; + update_date: string | null; + update_user: number | null; + revision_count: number; +} + +export interface ISurveyObservationData { + occurrence_submission_id: number; + inputFileName: string; + status?: string; + isValidating: boolean; + messageTypes: IGetObservationSubmissionResponseMessages[]; +} + +export interface IGetObservationSubmissionErrorListResponse { + id: number; + type: string; + status: string; + message: string; +} + +export interface IUploadObservationSubmissionResponse { + submissionId: number; +} + +export interface IGetOccurrencesForViewResponseDetails { + geometry: Feature | null; + taxonId: string; + lifeStage: string; + vernacularName: string; + individualCount: number; + organismQuantity: number; + organismQuantityType: string; + occurrenceId: number; + eventDate: string; +} + +export type EmptyObject = Record; + +export interface ITaxaData { + associated_taxa?: string; + vernacular_name?: string; + submission_spatial_component_id: number; +} + +export interface ISpatialData { + taxa_data: ITaxaData[]; + spatial_data: FeatureCollection | EmptyObject; +} diff --git a/app/src/interfaces/useObservationApi.interface.ts b/app/src/interfaces/useObservationApi.interface.ts index 52a7fbe049..bfda8e953c 100644 --- a/app/src/interfaces/useObservationApi.interface.ts +++ b/app/src/interfaces/useObservationApi.interface.ts @@ -1,92 +1,6 @@ -import { IObservationTableRow } from 'contexts/observationsContext'; -import { Feature, FeatureCollection } from 'geojson'; - -export interface IGetSubmissionCSVForViewItem { - name: string; - headers: string[]; - rows: string[][]; -} - -export interface IGetSubmissionCSVForViewResponse { - data: IGetSubmissionCSVForViewItem[]; -} - -export type ObservationSubmissionMessageSeverityLabel = 'Notice' | 'Error' | 'Warning'; - -export interface IGetObservationSubmissionResponseMessages { - severityLabel: ObservationSubmissionMessageSeverityLabel; - messageTypeLabel: string; - messageStatus: string; - messages: { id: number; message: string }[]; -} - -/** - * Get observation submission response object. - * - * @export - * @interface IGetObservationSubmissionResponse - */ -export interface IGetObservationSubmissionResponse { - surveyObservationData: ISurveyObservationData; - surveyObservationSupplementaryData: ISurveySupplementaryData | null; -} - -export interface ISurveySupplementaryData { - occurrence_submission_publish_id: number; - occurrence_submission_id: number; - event_timestamp: string; - queue_id: number; - create_date: string; - create_user: number; - update_date: string | null; - update_user: number | null; - revision_count: number; -} - -export interface ISurveyObservationData { - occurrence_submission_id: number; - inputFileName: string; - status?: string; - isValidating: boolean; - messageTypes: IGetObservationSubmissionResponseMessages[]; -} - -export interface IGetObservationSubmissionErrorListResponse { - id: number; - type: string; - status: string; - message: string; -} - -export interface IUploadObservationSubmissionResponse { - submissionId: number; -} - -export interface IGetOccurrencesForViewResponseDetails { - geometry: Feature | null; - taxonId: string; - lifeStage: string; - vernacularName: string; - individualCount: number; - organismQuantity: number; - organismQuantityType: string; - occurrenceId: number; - eventDate: string; -} - -export type EmptyObject = Record; - -export interface ITaxaData { - associated_taxa?: string; - vernacular_name?: string; - submission_spatial_component_id: number; -} - -export interface ISpatialData { - taxa_data: ITaxaData[]; - spatial_data: FeatureCollection | EmptyObject; -} +import { IObservationRecord } from 'contexts/observationsTableContext'; export interface IGetSurveyObservationsResponse { - surveyObservations: IObservationTableRow[]; + surveyObservations: IObservationRecord[]; + supplementaryObservationData: { observationCount: number }; } diff --git a/app/src/interfaces/useProjectApi.interface.ts b/app/src/interfaces/useProjectApi.interface.ts index 35bed74c9c..017b443045 100644 --- a/app/src/interfaces/useProjectApi.interface.ts +++ b/app/src/interfaces/useProjectApi.interface.ts @@ -2,9 +2,7 @@ import { PublishStatus } from 'constants/attachments'; import { PROJECT_PERMISSION, PROJECT_ROLE } from 'constants/roles'; import { IProjectDetailsForm } from 'features/projects/components/ProjectDetailsForm'; import { IProjectIUCNForm } from 'features/projects/components/ProjectIUCNForm'; -import { IProjectLocationForm } from 'features/projects/components/ProjectLocationForm'; import { IProjectObjectivesForm } from 'features/projects/components/ProjectObjectivesForm'; -import { Feature } from 'geojson'; export interface IGetProjectAttachment { id: number; @@ -127,11 +125,7 @@ export interface IProjectUserRoles { * @export * @interface ICreateProjectRequest */ -export type ICreateProjectRequest = IProjectDetailsForm & - IProjectObjectivesForm & - IProjectLocationForm & - IProjectIUCNForm & - IProjectUserRoles; +export type ICreateProjectRequest = IProjectDetailsForm & IProjectObjectivesForm & IProjectIUCNForm & IProjectUserRoles; /** * Create project response object. @@ -160,7 +154,6 @@ export enum UPDATE_GET_ENTITIES { export interface IGetProjectForUpdateResponse { project?: IGetProjectForUpdateResponseDetails; objectives?: IGetProjectForUpdateResponseObjectives; - location?: IGetProjectForUpdateResponseLocation; iucn?: IGetProjectForUpdateResponseIUCN; participants?: IGetProjectParticipant[]; } @@ -177,12 +170,6 @@ export interface IGetProjectForUpdateResponseObjectives { revision_count: number; } -export interface IGetProjectForUpdateResponseLocation { - location_description: string; - geometry: Feature[]; - revision_count: number; -} - interface IGetProjectForUpdateResponseIUCNArrayItem { classification: number; subClassification1: number; @@ -222,7 +209,6 @@ export interface IGetProjectForViewResponse { export interface ProjectViewObject { project: IGetProjectForViewResponseDetails; objectives: IGetProjectForViewResponseObjectives; - location: IGetProjectForViewResponseLocation; participants: IGetProjectParticipant[]; iucn: IGetProjectForViewResponseIUCN; } @@ -239,11 +225,6 @@ export interface IGetProjectForViewResponseObjectives { objectives: string; } -export interface IGetProjectForViewResponseLocation { - location_description: string; - geometry: Feature[]; -} - export interface IGetProjectParticipant { project_participation_id: number; project_id: number; diff --git a/app/src/interfaces/usePublishApi.interface.ts b/app/src/interfaces/usePublishApi.interface.ts index f11da5c254..781549e3c2 100644 --- a/app/src/interfaces/usePublishApi.interface.ts +++ b/app/src/interfaces/usePublishApi.interface.ts @@ -1,4 +1,4 @@ -import { ISurveyObservationData } from './useObservationApi.interface'; +import { ISurveyObservationData } from './useDwcaApi.interface'; import { IGetProjectAttachment, IGetProjectReportAttachment } from './useProjectApi.interface'; import { ISurveySummaryData } from './useSummaryResultsApi.interface'; import { IGetSurveyAttachment, IGetSurveyReportAttachment } from './useSurveyApi.interface'; diff --git a/app/src/interfaces/useSurveyApi.interface.ts b/app/src/interfaces/useSurveyApi.interface.ts index 42184bd8b1..c877d89e48 100644 --- a/app/src/interfaces/useSurveyApi.interface.ts +++ b/app/src/interfaces/useSurveyApi.interface.ts @@ -61,10 +61,8 @@ export interface IGetSurveyForViewResponseDetails { } export interface IGetSurveyForViewResponsePurposeAndMethodology { - intended_outcome_id: number; + intended_outcome_ids: number[]; additional_details: string; - field_method_id: number; - ecological_season_id: number; vantage_code_ids: number[]; surveyed_all_areas: StringBoolean; } @@ -109,6 +107,17 @@ export interface IGetSurveyLocation { revision_count: number; } +export interface IGetSurveyBlock { + survey_block_id: number; + name: string; + description: string; + create_date: string; + create_user: number; + update_date: string | null; + update_user: number | null; + revision_count: number; +} + export interface SurveyViewObject { survey_details: IGetSurveyForViewResponseDetails; species: IGetSpecies; @@ -120,6 +129,16 @@ export interface SurveyViewObject { participants: IGetSurveyParticipant[]; partnerships: IGetSurveyForViewResponsePartnerships; locations: IGetSurveyLocation[]; + blocks: IGetSurveyBlock[]; +} + +export interface SurveyBasicFieldsObject { + survey_id: number; + name: string; + start_date: string; + end_date: string | null; + focal_species: number[]; + focal_species_names: string[]; } export interface SurveyUpdateObject extends ISurveyLocationForm { @@ -150,10 +169,8 @@ export interface SurveyUpdateObject extends ISurveyLocationForm { ]; partnerships?: IGetSurveyForUpdateResponsePartnerships; purpose_and_methodology?: { - intended_outcome_id: number; + intended_outcome_ids: number[]; additional_details: string; - field_method_id: number; - ecological_season_id: number; vantage_code_ids: number[]; surveyed_all_areas: StringBoolean; revision_count: number; @@ -230,13 +247,13 @@ export interface ISurveySupplementaryData { } /** - * Get Survey list response object. + * Get survey basic fields response object. * * @export * @interface IGetSurveyForListResponse */ export interface IGetSurveyForListResponse { - surveyData: SurveyViewObject; + surveyData: SurveyBasicFieldsObject; surveySupplementaryData: ISurveySupplementaryData; } @@ -315,7 +332,7 @@ export interface IGetSurveyAttachmentsResponse { export interface ISurveyPermits { permits: { - id: number; + permit_id: number; permit_number: string; permit_type: string; }[]; @@ -380,6 +397,8 @@ export interface IGetSamplePeriodRecord { survey_sample_method_id: number; start_date: string; end_date: string; + start_time: string | null; + end_time: string | null; create_date: string; create_user: number; update_date: string | null; diff --git a/app/src/interfaces/useUserApi.interface.ts b/app/src/interfaces/useUserApi.interface.ts index e4294e445b..22a138ed0a 100644 --- a/app/src/interfaces/useUserApi.interface.ts +++ b/app/src/interfaces/useUserApi.interface.ts @@ -1,12 +1,12 @@ export interface ISystemUser { system_user_id: number; user_identifier: string; - user_guid: string; + user_guid: string | null; identity_source: string; - record_end_date: string; + record_end_date: string | null; role_ids: number[]; role_names: string[]; email: string; display_name: string; - agency: string; + agency: string | null; } diff --git a/app/src/layouts/AltLayout.tsx b/app/src/layouts/AltLayout.tsx new file mode 100644 index 0000000000..39c48f0703 --- /dev/null +++ b/app/src/layouts/AltLayout.tsx @@ -0,0 +1,55 @@ +import Alert from '@mui/material/Alert'; +import Box from '@mui/material/Box'; +import CssBaseline from '@mui/material/CssBaseline'; +import Footer from 'components/layout/Footer'; +import Header from 'components/layout/Header'; +import { DialogContextProvider } from 'contexts/dialogContext'; +import { PropsWithChildren } from 'react'; + +const BaseLayout = (props: PropsWithChildren) => { + function isSupportedBrowser() { + if ( + navigator.userAgent.indexOf('Chrome') !== -1 || + navigator.userAgent.indexOf('Firefox') !== -1 || + navigator.userAgent.indexOf('Safari') !== -1 || + navigator.userAgent.indexOf('Edge') !== -1 + ) { + return true; + } + + return false; + } + + return ( + + + + {!isSupportedBrowser() && ( + + This is an unsupported browser. Some functionality may not work as expected. + + )} + +
+ + + {props.children} + + +